diff --git a/PLAN.md b/PLAN.md index fd64e43..b0d4649 100644 --- a/PLAN.md +++ b/PLAN.md @@ -146,8 +146,6 @@ Task ├── timezone 默认使用系统时区,可选择 IANA 时区 ├── enabled 是否参与自动调度 ├── timeout_secs 单次执行超时 -├── concurrency_policy 首版固定为 skip -├── retry_policy 首版允许 0 次重试,结构上预留 ├── action 带 type 标签的任务动作 ├── created_at └── updated_at diff --git a/README.md b/README.md index 4c89e47..f40f772 100644 --- a/README.md +++ b/README.md @@ -114,14 +114,14 @@ uv run --no-project tools/verify.py --base-url http://127.0.0.1:3000 | 分组 | 能力 | | --- | --- | -| 任务管理 | 增删改查、启用/停用、按 ID/名称搜索、按启用状态与 Action 类型筛选、分页 | +| 任务管理 | 增删改查、启用/停用、按 ID/名称搜索、按启用状态与 Action 类型筛选 | | 调度 | 5/6 位 Cron(支持秒级、别名、月份/星期名)、IANA 时区、启动恢复调度、下次触发时间 | | 执行 | 手动立即运行;同一任务重叠触发记为 `skipped`;启动时对账遗留运行 | | 进程治理 | Windows Job Object 管理进程树;超时或取消时终止整棵树 | | 观测 | 运行历史(状态/触发方式筛选)、退出码、耗时、错误信息、日志下载 | | 日志 | SSE 实时推送 + 增量轮询 + 断线重连;编码自动识别;敏感值脱敏;容量与保留期清理 | | Python | 自动关联 `.venv`/`venv`/`env`、解释器可选、用 uv 从 `pyproject.toml` / `requirements*.txt` 创建环境(新任务保存时自动创建)、uv 只用程序目录副本或自定义路径(不读系统 PATH)、没有 uv 时可从科大镜像一键下载、`UV_*` 环境变量可配置、注入 PATH/`VIRTUAL_ENV`/`PYTHONIOENCODING`、装包时走默认 PyPI 镜像 | -| 界面 | 顶部品牌栏 + 三栏工作台(用户/概览/调度 · 任务分组 · uv/镜像/环境卡片)、任务类型 + 脚本语言联动表单、Cron 预览、虚拟环境状态条、页面内目录浏览器、深色日志弹窗 | +| 界面 | 顶部品牌栏 + 三栏工作台(用户/概览/调度 · 任务卡片 · uv/镜像/环境卡片)、任务类型 + 脚本语言联动表单、Cron 预览、虚拟环境状态条、页面内目录浏览器、深色日志弹窗 | | 安全 | 仅回环监听、Origin 白名单、写操作令牌、常量时间比较、请求体上限 | | 交付 | 单进程静态托管、SPA 回退、Release 打包、OpenAPI 契约、可选离线字体 | @@ -135,12 +135,11 @@ uv run --no-project tools/verify.py --base-url http://127.0.0.1:3000 | --- | --- | | `id` | 稳定标识,`[A-Za-z0-9_-]`,1–64 字符;同时决定日志目录名 | | `name` | 展示名,1–120 字符 | +| `description` | 一句话说明,≤500 字符;显示在任务卡片副标题和详情里,可留空 | | `cron` | 5 或 6 位表达式,例:`0 0 2 * * *`、`*/30 * * * * *`、`@daily` | | `timezone` | IANA 时区,例:`Asia/Shanghai` | | `enabled` | 是否注册调度 | | `timeout_secs` | 1–86400;超时终止整个进程树 | -| `concurrency_policy` | 目前仅 `skip`(同任务重叠触发被跳过) | -| `retry_policy` | `max_attempts` ≤ 10、`delay_secs` ≤ 3600;**当前只持久化,不自动重试** | | `action` | 见下 | ### 三类 Action @@ -187,7 +186,6 @@ queued ──► running ──► succeeded / failed / timed_out / cancelled - **取消后立即重跑**:允许;旧进程终止与新进程启动可能短暂重叠。 - **错过的触发不补跑**:下次触发始终从"现在"开始计算。 - **下次触发时间**:调度器保存在内存中,`GET /api/v1/schedule` 暴露给前端。 -- **重试**:字段已持久化但执行器尚未实现自动重试(见「已知限制」)。 --- @@ -231,12 +229,21 @@ queued ──► running ──► succeeded / failed / timed_out / cancelled - 创建时会使用当前默认 PyPI 镜像:注入 `UV_DEFAULT_INDEX`(实际生效的是它)以及兼容用的 `UV_INDEX_URL` / `PIP_INDEX_URL`,不会绕过镜像设置。 - uv 只用平台认的两份:程序目录下的 `uv\uv.exe`(「程序目录 uv」,默认),或你指定的 `uv.exe`(「指定路径」)。 **不读系统 PATH**——PATH 上的 uv 版本会随启动方式变化,不适合当调度器的隐式依赖。 - 没有 uv 时在 uv 配置弹窗点「下载 uv」,从科大镜像 - `mirrors.ustc.edu.cn/github-release/astral-sh/uv/LatestRelease` 下载当前架构的压缩包, - 校验 sha256 后解压到 `uv\uv.exe` 并自动切换过去;本机已经装了 uv 的话,把它填进「指定路径」即可。 + 没有 uv 时在 uv 配置弹窗点「下载 uv」,从「下载镜像」(默认科大 + `mirrors.ustc.edu.cn/github-release`)下载当前架构的压缩包, + 校验 sha256 后解压到 `uv\uv.exe` 并自动切换过去(弹窗状态条里会显示 uv 与解释器的下载地址); + 本机已经装了 uv 的话,把它填进「指定路径」即可。 +- **uv 的下载镜像不跟 PyPI 包索引混用**:它配在 uv 配置弹窗的「下载镜像」里(GitHub Release + 镜像根),uv 本体与 Python 解释器都从它派生(uv 地址 = `镜像根/astral-sh/uv/LatestRelease`, + 解释器地址 = `镜像根/astral-sh/python-build-standalone/releases/download`)。 + 解释器这一步也可以单独覆盖:环境变量里就有一条 `UV_PYTHON_INSTALL_MIRROR`(默认跟随镜像根, + 改它的值即可换成别的发行源),它和 `UV_PYTHON_INSTALL_DIR`、`UV_CACHE_DIR` 一样是固定项, + 只能改值、不能删除。 - uv 配置弹窗里还能配置 `UV_*` 环境变量(如 `UV_CACHE_DIR`、`UV_PYTHON_INSTALL_DIR`、`UV_HTTP_TIMEOUT`), 会注入到每一次 uv 调用,并优先于镜像注入与进程环境。两个路径变量默认是**相对程序目录**的 `python` / `uv-cache`(随包移动),也可以改成绝对路径。 +- Python 解释器的下载与查看也在 uv 配置弹窗里(`uv python install` / `uv python list`)—— + 解释器属于 uv,只是建虚拟环境时要用;虚拟环境弹窗只从这份已下载列表里挑版本。 - uv 不可用:保存任务时不会自动建环境,运行时会失败并提示先配置 uv。 - 运行任务时会把平台 uv 目录加到 PATH 前面,并注入 `UV_PYTHON_INSTALL_DIR`、弹窗里配置的 `UV_*` 和默认 `UV_DEFAULT_INDEX` (任务自己配置的环境变量优先)。这样脚本里直接写 `uv ...` 用的也是平台这一份,装包默认走镜像;不会再注入 `PIP_*`。 @@ -260,23 +267,30 @@ queued ──► running ──► succeeded / failed / timed_out / cancelled ## Web 界面 -- **顶部品牌栏**:品牌 + 右侧 Rust 引擎在线状态与版本;页面级导航已去掉,配置都收到任务调度页的卡片里。 -- **三栏工作台**(任务调度页):左栏用户卡片 + 概览指标 + 调度摘要;中栏搜索筛选 + 按类型(脚本 / HTTP / 二进制)分组的任务卡片与分页; - 右栏 uv(版本号 / 缓存目录 / Python 目录)、镜像源、托管虚拟环境摘要——点「配置 / 管理」打开对应**弹窗**。 -- **任务卡片**:名称、任务标识、状态、Cron、下次触发,以及运行 / 日志 / 编辑 / 启停 / 删除。 +- **顶部品牌栏**:只保留品牌与副标题;引擎在线状态显示在左栏用户卡片底部(跟随健康检查)。 + 页面级导航已去掉,配置都收到任务调度页的卡片里。 +- **三栏工作台**(任务调度页):左栏用户卡片 + 概览指标 + 调度摘要;中栏搜索筛选 + **任务卡片**(每个任务一张卡片,一次取最多 100 条,不分页); + 右栏 uv(版本号 / 解释器数量 / 缓存目录 / Python 目录 / 下载镜像)、镜像源、托管虚拟环境摘要——点「配置 / 管理」打开对应**弹窗**。 +- **任务卡片**:卡片顶部是任务名称(左侧主色竖线 + 整行标题,右上角是「详情」);内容区里是任务标识 + 类型标签、任务说明(没填时回退成执行目标)、Cron 与下次触发时间,以及状态和运行 / 日志 / 编辑 / 启停 / 删除。点「详情」打开只读详情弹窗(基本信息 / 执行配置 / 时间)。 - **编辑器**:先选任务类型(脚本 / HTTP / 二进制),脚本类型再选语言(Python / Shell / Node / Lua), 非 Python 语言会自动隐藏虚拟环境字段并调整脚本占位符;Cron 预览(实时计算未来 3/5/8/10 次)、 时区下拉、虚拟环境状态条、调用预览;探测到虚拟环境会自动回填「虚拟环境目录」,仍可手动修改; 缺少虚拟环境但项目有依赖文件时保存会自动用 uv 创建。解释器由语言自动选择,界面不再要求手填。 -- **目录选择**:页面内目录浏览器(盘符 → 逐级进入 → 使用此目录),同时保留手动输入。 +- **目录选择**:页面内目录浏览器(盘符 → 逐级进入 → 使用此目录),同时保留手动输入; + uv 的「指定路径」用同一个浏览器选文件(`include_files=true`,只列 `.exe`)。 - **日志弹窗**:运行历史筛选、SSE 实时日志、取消运行、下载日志、Escape/焦点陷阱。 -- **uv 配置弹窗**:图标标题 + 右侧规则提示 + 选项 chip 的版式;「程序目录 uv」与「指定路径」二选一, - 程序目录下没有可用 uv 时才出现「下载 uv」;`UV_*` 环境变量键值列表,底部是关闭(图标)/ 保存(整宽)。 - uv 来源不读系统 PATH。 -- **镜像源弹窗**:内置清华 TUNA / 阿里云 / 中科大 / PyPI 官方;可新增、编辑、启用停用、设为默认。 - 只在**装包**(建环境 / 预装依赖)时生效,任务运行时不注入镜像变量。 -- **虚拟环境弹窗**:Python 版本(uv 下载到 `python\` 的解释器,可填版本下载)+ 托管环境 - (`data\envs\<名称>`,从已下载版本里选 Python、可选预装包、查看已装包)。 +- **uv 配置弹窗**:顶部状态条(版本 / 可执行文件 / uv 下载与解释器镜像地址 / 解释器与环境变量数量)+ 按配置顺序排的三段。 + ① **uv 可执行文件**:单选「程序目录 uv / 指定路径」——程序目录只展示路径、不可编辑,缺失时才出现「下载 uv」; + 指定路径是可编辑输入框,右侧「浏览」(页面内文件浏览器挑 `uv.exe`,保存时校验能否运行);下面是「下载镜像」 + (GitHub Release 镜像根,uv 与解释器共用);② **环境变量**:`UV_PYTHON_INSTALL_DIR` / `UV_CACHE_DIR` 决定目录, + `UV_PYTHON_INSTALL_MIRROR` 决定解释器下载源,这三项只能改值、不能删除(清空或重复会拦下保存), + 其余 `UV_*` 变量可增删;③ **Python 解释器**:按上面配置的目录列出已下载版本,填版本号即下载。 + 底部关闭 / 保存设置;uv 来源不读系统 PATH。 +- **镜像源弹窗**:只放 PyPI 包索引镜像:内置清华 TUNA / 阿里云 / 中科大 / PyPI 官方; + 可新增、编辑、启用停用、设为默认。包索引只在**装包**(建环境 / 预装依赖)时生效,任务运行时不注入镜像变量。 + uv 自己的下载镜像不在这里,在 uv 配置弹窗里。 +- **虚拟环境弹窗**:只负责**使用**已下载的解释器创建托管环境 + (`data\envs\<名称>`,从已下载版本里选 Python、可选预装包、查看已装包);解释器下载入口在 uv 配置弹窗。 - **设计系统**:按钮、输入框、表格、标签、分页、弹窗、Toast、排版全部来自 `@stash/ui`(同级目录 `../stash-ui`,`file:` 依赖);本仓库只保留页面级布局与 业务块(三栏工作台、任务卡片、日志控制台、表单栅格)。 @@ -332,7 +346,7 @@ queued ──► running ──► succeeded / failed / timed_out / cancelled | `POST` | `/api/v1/cron/preview` | 计算未来若干次触发时间 | | `POST` | `/api/v1/python/venv` | 探测工作目录会关联的虚拟环境、uv 可用性与依赖文件 | | `POST` | `/api/v1/python/venv/create` | 用 uv 创建/同步项目 `.venv`(优先复用已有环境) | -| `GET` | `/api/v1/system/directories?path=` | 列盘符或子文件夹(目录浏览器) | +| `GET` | `/api/v1/system/directories?path=` | 列盘符或子文件夹(目录浏览器);`include_files=true` 时一并返回文件 | | `GET` | `/api/v1/stats` | 仪表盘计数(总数/启用/运行中/三类数量) | | `GET` | `/api/v1/schedule` | 已启用任务的下次触发时间 | @@ -352,8 +366,8 @@ queued ──► running ──► succeeded / failed / timed_out / cancelled | 方法 | 路径 | 说明 | | --- | --- | --- | | `GET` | `/api/v1/uv` | 当前 uv 来源、可执行文件、版本、`UV_*` 环境变量与解析后的 `python_dir` / `cache_dir`、两个路径变量的相对默认值 | -| `PUT` | `/api/v1/uv` | 保存 uv 来源(`managed` / `custom`)、自定义路径和 `extra_env`;`custom` 会校验可运行 | -| `POST` | `/api/v1/uv/download` | 从科大镜像下载当前架构的 uv、校验 sha256、安装到程序目录 `uv\` 并切换过去 | +| `PUT` | `/api/v1/uv` | 保存 uv 来源(`managed` / `custom`)、自定义路径、`extra_env` 和下载镜像根(`mirror_url`,空 = 内置默认);`custom` 会校验可运行 | +| `POST` | `/api/v1/uv/download` | 从下载镜像取当前架构的 uv、校验 sha256、安装到程序目录 `uv\` 并切换过去 | ### 托管虚拟环境 @@ -397,6 +411,7 @@ Content-Type: application/json { "id": "sync_data", "name": "数据同步", + "description": "把上游数据同步到本地仓库,失败时记录日志", "cron": "0 0 2 * * *", "timezone": "Asia/Shanghai", "enabled": true, @@ -447,7 +462,7 @@ Content-Type: application/json | `cron` | 可解析且存在未来触发点 | | `timezone` | 有效 IANA 名称 | | `timeout_secs` | 1–86400 | -| `retry_policy` | `max_attempts ≤ 10`,`delay_secs ≤ 3600` | +| `description` | ≤500 字符,可留空 | | `action.working_dir` / `script` | 非空 | | `action.language` | `python` / `shell` / `node` / `lua`;可省略,省略时按解释器推断 | | `action.interpreter` | 可空;空时按语言默认(Python 为 `python.exe`,Shell 为 `cmd.exe`,Node 为 `node.exe`,Lua 为 `lua.exe`) | @@ -509,7 +524,7 @@ React (Vite) ──fetch──► Axum router ──security::api_guard(Origin + | `python_envs.rs` | 托管虚拟环境与 uv Python 版本:`data/envs` 扫描、创建/删除、已装包 | | `uv.rs` | uv 来源解析与校验、镜像下载(sha256)、`UV_*` 环境变量与路径默认值 | | `python_mirrors/` | PyPI 镜像的模型、校验、CRUD 与默认项事务 | -| `directory_browser.rs` | 盘符与目录列举(页面内目录选择器) | +| `directory_browser.rs` | 盘符与目录列举(页面内目录选择器,可选返回文件) | | `security.rs` | Origin 白名单、令牌校验、常量时间比较 | | `database.rs` | 连接池与迁移(WAL/busy_timeout) | @@ -518,15 +533,14 @@ React (Vite) ──fetch──► Axum router ──security::api_guard(Origin + | 模块 | 职责 | | --- | --- | | `@stash/ui`(同级 `../stash-ui`) | 设计令牌与通用组件:按钮、输入、表格、标签、弹窗、分页、Toast、排版 | -| `components/AppHeader.tsx` | 顶部品牌栏、引擎状态 | -| `pages/TaskDashboard.tsx` | 三栏工作台:概览/调度、任务分组、uv/镜像/环境卡片与三个配置弹窗 | -| `components/UvSettingsSection.tsx` | uv 弹窗内容:来源 chip、下载、`UV_*` 环境变量 | -| `components/PythonRuntimeSection.tsx` | 虚拟环境弹窗内容:Python 版本列表与下载 | -| `components/PythonEnvsSection.tsx` | 虚拟环境弹窗内容:环境列表、创建、已装包 | +| `components/AppHeader.tsx` | 顶部品牌栏 | +| `pages/TaskDashboard.tsx` | 三栏工作台:概览/调度、任务卡片、uv/镜像/环境卡片与三个配置弹窗 | +| `components/UvSettingsModal.tsx` | uv 弹窗:状态条、来源选择、下载镜像、Python 解释器下载与列表、`UV_*` 环境变量 | +| `components/PythonEnvsSection.tsx` | 虚拟环境弹窗:用已下载解释器创建环境、环境列表、已装包 | | `components/PythonMirrorsSection.tsx` | 镜像源弹窗内容:列表、默认项、新增 / 编辑 / 删除 | | `components/PythonMirrorModal.tsx` | 镜像表单(名称 / 索引地址 / 信任主机 / 启用) | | `components/TaskEditorModal.tsx` | 三类动态表单、Cron 预览、虚拟环境状态条 | -| `components/DirectoryPickerDialog.tsx` | 页面内目录浏览器 | +| `components/DirectoryPickerDialog.tsx` | 页面内目录浏览器(`mode="file"` 时为文件选择器,用于挑 `uv.exe`) | | `components/RunLogDrawer.tsx` | 运行历史、日志、取消、下载、SSE | | `components/LogViewer.tsx` | 日志虚拟滚动、跟随底部/回到最新 | | `lib/logLines.ts` | 增量日志的行缓冲(残行拼接、CRLF 归一) | @@ -592,15 +606,12 @@ uv run --with fonttools --with brotli tools/build-fonts.py | 层 | 覆盖 | | --- | --- | -| Rust 单元(73) | Cron、虚拟环境解析与探测、项目依赖文件扫描、uv 来源解析/校验/压缩包解压/`UV_*` 环境变量校验、托管环境名校验、PATH 注入去重、执行器(编码/截断/清理/句柄归属/日志合并推送)、安全、目录列举 | -| Rust 集成(48) | 任务 CRUD 与校验、运行生命周期、取消与超时、日志分块、调度恢复、统计与调度接口、重启对账、镜像 CRUD、uv 设置读写与下载鉴权、托管环境校验与 404、venv 探测/创建、无环境运行提示、Shell 脚本执行 | -| 前端 Vitest(37) | 三栏工作台渲染、保存 uv 来源与 `UV_*` 环境变量、镜像下载、Python 版本下载、托管环境创建与包列表、新建任务前先建虚拟环境、表单载荷与脚本语言、虚拟环境状态、uv 创建入口、目录浏览器、下次触发时间、日志行切分/窗口计算/虚拟滚动 | +| Rust 单元(78) | Cron、虚拟环境解析与探测、项目依赖文件扫描、uv 来源解析/校验/压缩包解压/镜像地址派生/`UV_*` 环境变量校验、托管环境名校验、PATH 注入去重、执行器(编码/截断/清理/句柄归属/日志合并推送)、安全、目录列举 | +| Rust 集成(50) | 任务 CRUD 与校验、运行生命周期、取消与超时、日志分块、调度恢复、统计与调度接口、重启对账、镜像 CRUD、uv 设置(含下载镜像)读写与下载鉴权、托管环境校验与 404、venv 探测/创建、无环境运行提示、Shell 脚本执行 | +| 前端 Vitest(40) | 三栏工作台渲染、任务详情弹窗、uv 弹窗(保存来源与 `UV_*`、下载镜像、下载 uv、浏览选 `uv.exe`、下载 Python 版本)、镜像下载、托管环境创建与包列表、新建任务前先建虚拟环境、表单载荷与脚本语言、虚拟环境状态、uv 创建入口、目录浏览器、下次触发时间、日志行切分/窗口计算/虚拟滚动 | | Playwright(8) | 真实任务执行与日志、编辑/停用/删除、脚本语言切换、目录切换单状态、窄屏、**无外部网络渲染**、1500 行日志的虚拟滚动、镜像增删与设默认 | | Python 自检(14 项) | `tools/verify.py` 覆盖 API 全链路 | -> 前端 Vitest 里 6 个用例仍在按已删除的顶部导航点击(uv / 虚拟环境相关), -> 需要改成点右栏卡片并断言弹窗内容后才能全绿;改动停在 UI 调整阶段,尚未修。 - --- ## 故障排查 @@ -621,8 +632,7 @@ uv run --with fonttools --with brotli tools/build-fonts.py ## 已知限制 -- 自动重试尚未实现:`retry_policy` 只校验与存储。 -- 并发策略只有 `skip`;没有全局并发上限与队列。 +- 同一任务重叠触发时直接记为 `skipped`;没有全局并发上限与队列。 - HTTP Action 只校验状态码,不支持响应体断言、认证方案或 Header 断言。 - 敏感值脱敏基于变量名关键词与值替换,不是凭据保险箱。 - Conda 环境不会自动激活,需要显式填写解释器路径。 diff --git a/backend/migrations/0001_create_tasks.sql b/backend/migrations/0001_create_tasks.sql deleted file mode 100644 index 4f001ce..0000000 --- a/backend/migrations/0001_create_tasks.sql +++ /dev/null @@ -1,28 +0,0 @@ -CREATE TABLE IF NOT EXISTS tasks ( - id TEXT PRIMARY KEY NOT NULL, - name TEXT NOT NULL, - cron TEXT NOT NULL, - timezone TEXT NOT NULL, - enabled INTEGER NOT NULL CHECK (enabled IN (0, 1)), - timeout_secs INTEGER NOT NULL CHECK (timeout_secs > 0), - concurrency_policy TEXT NOT NULL DEFAULT 'skip' - CHECK (concurrency_policy = 'skip'), - retry_max_attempts INTEGER NOT NULL DEFAULT 0 - CHECK (retry_max_attempts >= 0), - retry_delay_secs INTEGER NOT NULL DEFAULT 0 - CHECK (retry_delay_secs >= 0), - action_type TEXT NOT NULL - CHECK (action_type IN ('script', 'http', 'process')), - action_json TEXT NOT NULL, - created_at TEXT NOT NULL, - updated_at TEXT NOT NULL -); - -CREATE INDEX IF NOT EXISTS idx_tasks_updated_at - ON tasks(updated_at DESC); - -CREATE INDEX IF NOT EXISTS idx_tasks_enabled - ON tasks(enabled); - -CREATE INDEX IF NOT EXISTS idx_tasks_action_type - ON tasks(action_type); diff --git a/backend/migrations/0001_init.sql b/backend/migrations/0001_init.sql new file mode 100644 index 0000000..2153e52 --- /dev/null +++ b/backend/migrations/0001_init.sql @@ -0,0 +1,127 @@ +-- TaskPilot 基线结构:任务、运行记录、Python 镜像与 uv 设置。 +-- 这是全新起点(不再兼容旧库):任务不再持久化并发/重试策略,运行触发只有定时与手动。 + +CREATE TABLE IF NOT EXISTS tasks ( + id TEXT PRIMARY KEY NOT NULL, + name TEXT NOT NULL, + description TEXT NOT NULL DEFAULT '', + cron TEXT NOT NULL, + timezone TEXT NOT NULL, + enabled INTEGER NOT NULL CHECK (enabled IN (0, 1)), + timeout_secs INTEGER NOT NULL CHECK (timeout_secs > 0), + action_type TEXT NOT NULL + CHECK (action_type IN ('script', 'http', 'process')), + action_json TEXT NOT NULL, + created_at TEXT NOT NULL, + updated_at TEXT NOT NULL +); + +CREATE INDEX IF NOT EXISTS idx_tasks_updated_at + ON tasks(updated_at DESC); + +CREATE INDEX IF NOT EXISTS idx_tasks_enabled + ON tasks(enabled); + +CREATE INDEX IF NOT EXISTS idx_tasks_action_type + ON tasks(action_type); + +CREATE TABLE IF NOT EXISTS task_runs ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + task_id TEXT NOT NULL REFERENCES tasks(id) ON DELETE CASCADE, + trigger_type TEXT NOT NULL + CHECK (trigger_type IN ('schedule', 'manual')), + status TEXT NOT NULL + CHECK (status IN ( + 'queued', 'running', 'succeeded', 'failed', + 'timed_out', 'cancelled', 'skipped' + )), + started_at TEXT, + finished_at TEXT, + duration_ms INTEGER, + exit_code INTEGER, + error_message TEXT, + created_at TEXT NOT NULL +); + +CREATE INDEX IF NOT EXISTS idx_task_runs_task_created + ON task_runs(task_id, created_at DESC); + +CREATE INDEX IF NOT EXISTS idx_task_runs_status + ON task_runs(status); + +CREATE TABLE IF NOT EXISTS python_mirrors ( + id TEXT PRIMARY KEY NOT NULL, + name TEXT NOT NULL, + index_url TEXT NOT NULL, + trusted_host TEXT, + enabled INTEGER NOT NULL DEFAULT 1 CHECK (enabled IN (0, 1)), + is_default INTEGER NOT NULL DEFAULT 0 CHECK (is_default IN (0, 1)), + created_at TEXT NOT NULL, + updated_at TEXT NOT NULL +); + +-- 同一时间只允许一个默认镜像 +CREATE UNIQUE INDEX IF NOT EXISTS idx_python_mirrors_default + ON python_mirrors(is_default) + WHERE is_default = 1; + +CREATE UNIQUE INDEX IF NOT EXISTS idx_python_mirrors_index_url + ON python_mirrors(index_url); + +INSERT OR IGNORE INTO python_mirrors + (id, name, index_url, trusted_host, enabled, is_default, created_at, updated_at) +VALUES + ( + 'tsinghua', + '清华 TUNA', + 'https://pypi.tuna.tsinghua.edu.cn/simple', + NULL, + 1, + 1, + strftime('%Y-%m-%dT%H:%M:%fZ', 'now'), + strftime('%Y-%m-%dT%H:%M:%fZ', 'now') + ), + ( + 'aliyun', + '阿里云', + 'https://mirrors.aliyun.com/pypi/simple/', + NULL, + 1, + 0, + strftime('%Y-%m-%dT%H:%M:%fZ', 'now'), + strftime('%Y-%m-%dT%H:%M:%fZ', 'now') + ), + ( + 'ustc', + '中科大', + 'https://pypi.mirrors.ustc.edu.cn/simple/', + NULL, + 1, + 0, + strftime('%Y-%m-%dT%H:%M:%fZ', 'now'), + strftime('%Y-%m-%dT%H:%M:%fZ', 'now') + ), + ( + 'pypi', + 'PyPI 官方', + 'https://pypi.org/simple', + NULL, + 1, + 0, + strftime('%Y-%m-%dT%H:%M:%fZ', 'now'), + strftime('%Y-%m-%dT%H:%M:%fZ', 'now') + ); + +-- uv 只认平台自带的副本(程序目录 uv)或用户指定的路径,不再读系统 PATH。 +-- extra_env / mirror_url 分别存额外的 UV_* 环境变量和下载镜像。 +CREATE TABLE IF NOT EXISTS uv_settings ( + id INTEGER PRIMARY KEY CHECK (id = 1), + source TEXT NOT NULL CHECK (source IN ('managed', 'custom')), + custom_path TEXT, + extra_env TEXT NOT NULL DEFAULT '{}', + mirror_url TEXT, + updated_at TEXT NOT NULL +); + +INSERT OR IGNORE INTO uv_settings (id, source, custom_path, extra_env, updated_at) +VALUES (1, 'managed', NULL, '{}', strftime('%Y-%m-%dT%H:%M:%fZ', 'now')); diff --git a/backend/migrations/0002_create_task_runs.sql b/backend/migrations/0002_create_task_runs.sql deleted file mode 100644 index a8556c9..0000000 --- a/backend/migrations/0002_create_task_runs.sql +++ /dev/null @@ -1,23 +0,0 @@ -CREATE TABLE IF NOT EXISTS task_runs ( - id INTEGER PRIMARY KEY AUTOINCREMENT, - task_id TEXT NOT NULL REFERENCES tasks(id) ON DELETE CASCADE, - trigger_type TEXT NOT NULL - CHECK (trigger_type IN ('schedule', 'manual', 'retry')), - status TEXT NOT NULL - CHECK (status IN ( - 'queued', 'running', 'succeeded', 'failed', - 'timed_out', 'cancelled', 'skipped' - )), - started_at TEXT, - finished_at TEXT, - duration_ms INTEGER, - exit_code INTEGER, - error_message TEXT, - created_at TEXT NOT NULL -); - -CREATE INDEX IF NOT EXISTS idx_task_runs_task_created - ON task_runs(task_id, created_at DESC); - -CREATE INDEX IF NOT EXISTS idx_task_runs_status - ON task_runs(status); diff --git a/backend/migrations/0003_create_python_mirrors.sql b/backend/migrations/0003_create_python_mirrors.sql deleted file mode 100644 index f0e0133..0000000 --- a/backend/migrations/0003_create_python_mirrors.sql +++ /dev/null @@ -1,62 +0,0 @@ -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') - ); diff --git a/backend/migrations/0004_create_uv_settings.sql b/backend/migrations/0004_create_uv_settings.sql deleted file mode 100644 index b1fe957..0000000 --- a/backend/migrations/0004_create_uv_settings.sql +++ /dev/null @@ -1,11 +0,0 @@ --- uv 可执行文件来自哪里:系统 PATH(system)、程序目录下的 uv 目录(managed), --- 或用户自己下载后指定的路径(custom)。只保留一行。 -CREATE TABLE IF NOT EXISTS uv_settings ( - id INTEGER PRIMARY KEY CHECK (id = 1), - source TEXT NOT NULL DEFAULT 'system' CHECK (source IN ('system', 'managed', 'custom')), - custom_path TEXT, - updated_at TEXT NOT NULL -); - -INSERT OR IGNORE INTO uv_settings (id, source, custom_path, updated_at) -VALUES (1, 'system', NULL, strftime('%Y-%m-%dT%H:%M:%fZ', 'now')); diff --git a/backend/migrations/0005_uv_settings_drop_system_source.sql b/backend/migrations/0005_uv_settings_drop_system_source.sql deleted file mode 100644 index 50628ac..0000000 --- a/backend/migrations/0005_uv_settings_drop_system_source.sql +++ /dev/null @@ -1,21 +0,0 @@ --- uv 只认平台自己的副本(程序目录 uv)或用户指定的路径,不再读系统 PATH。 -CREATE TABLE uv_settings_v2 ( - id INTEGER PRIMARY KEY CHECK (id = 1), - source TEXT NOT NULL CHECK (source IN ('managed', 'custom')), - custom_path TEXT, - updated_at TEXT NOT NULL -); - -INSERT INTO uv_settings_v2 (id, source, custom_path, updated_at) -SELECT id, - CASE WHEN source = 'custom' THEN 'custom' ELSE 'managed' END, - custom_path, - updated_at -FROM uv_settings; - -DROP TABLE uv_settings; - -ALTER TABLE uv_settings_v2 RENAME TO uv_settings; - -INSERT OR IGNORE INTO uv_settings (id, source, custom_path, updated_at) -VALUES (1, 'managed', NULL, strftime('%Y-%m-%dT%H:%M:%fZ', 'now')); diff --git a/backend/migrations/0006_uv_settings_extra_env.sql b/backend/migrations/0006_uv_settings_extra_env.sql deleted file mode 100644 index ca8114c..0000000 --- a/backend/migrations/0006_uv_settings_extra_env.sql +++ /dev/null @@ -1,2 +0,0 @@ --- uv 管理页允许额外注入 UV_* 环境变量(缓存目录、Python 安装目录、超时等)。 -ALTER TABLE uv_settings ADD COLUMN extra_env TEXT NOT NULL DEFAULT '{}'; diff --git a/backend/src/api.rs b/backend/src/api.rs index 51b9593..63b198d 100644 --- a/backend/src/api.rs +++ b/backend/src/api.rs @@ -45,9 +45,8 @@ use crate::{ state::AppState, tasks::{ model::{ - ConcurrencyPolicy, CreateTaskRequest, HttpMethod, RetryPolicy, ScheduledTaskResponse, - ScriptLanguage, TaskAction, TaskActionType, TaskListResponse, TaskResponse, - TaskStatsResponse, UpdateTaskRequest, + CreateTaskRequest, HttpMethod, ScheduledTaskResponse, ScriptLanguage, TaskAction, + TaskActionType, TaskListResponse, TaskResponse, TaskStatsResponse, UpdateTaskRequest, }, routes::{ __path_create_task, __path_delete_task, __path_disable_task, __path_enable_task, @@ -194,7 +193,7 @@ pub async fn create_python_venv( tag = "system", params(ListDirectoriesQuery), responses( - (status = 200, description = "Subdirectories of the requested path, or the drive list", body = DirectoryListingResponse), + (status = 200, description = "Subdirectories of the requested path (plus files when `include_files=true`), or the drive list", body = DirectoryListingResponse), (status = 400, description = "The path is not an existing absolute directory", body = ErrorResponse) ) )] @@ -336,8 +335,6 @@ pub async fn list_schedule( TaskActionType, ScriptLanguage, HttpMethod, - ConcurrencyPolicy, - RetryPolicy, TaskAction, CreateTaskRequest, UpdateTaskRequest, diff --git a/backend/src/directory_browser.rs b/backend/src/directory_browser.rs index 42aaf99..3a4c876 100644 --- a/backend/src/directory_browser.rs +++ b/backend/src/directory_browser.rs @@ -14,6 +14,9 @@ pub const MAX_ENTRIES: usize = 500; pub struct ListDirectoriesQuery { /// 绝对目录路径;留空表示列出盘符 pub path: Option, + /// 是否一并返回文件(挑选可执行文件时用);默认只返回目录 + #[serde(default)] + pub include_files: Option, } #[derive(Debug, Serialize, ToSchema)] @@ -22,6 +25,8 @@ pub struct DirectoryEntry { pub name: String, /// 绝对路径,选中后回填到表单 pub path: String, + /// true 表示目录(可继续进入),false 表示文件(`include_files` 时才出现) + pub is_dir: bool, } #[derive(Debug, Serialize, ToSchema)] @@ -79,6 +84,7 @@ pub fn list_directories( DirectoryError::Unreadable(format!("无法读取目录 {}: {error}", directory.display())) })?; + let include_files = query.include_files.unwrap_or(false); let mut entries = Vec::new(); let mut truncated = false; for entry in reader.flatten() { @@ -87,19 +93,26 @@ pub fn list_directories( .file_type() .map(|kind| kind.is_dir()) .unwrap_or_else(|_| entry_path.is_dir()); - if !is_directory { + if !is_directory && !include_files { continue; } entries.push(DirectoryEntry { name: entry.file_name().to_string_lossy().into_owned(), path: entry_path.to_string_lossy().into_owned(), + is_dir: is_directory, }); if entries.len() >= MAX_ENTRIES { truncated = true; break; } } - entries.sort_by_key(|entry| entry.name.to_lowercase()); + // 目录在前、文件在后,各自按名称排序:挑选文件时不用在一堆文件里找文件夹。 + entries.sort_by(|left, right| { + right + .is_dir + .cmp(&left.is_dir) + .then_with(|| left.name.to_lowercase().cmp(&right.name.to_lowercase())) + }); Ok(DirectoryListingResponse { path: Some(directory.to_string_lossy().into_owned()), @@ -126,6 +139,7 @@ fn logical_drives() -> Vec { DirectoryEntry { name: format!("{letter}:\\"), path: format!("{letter}:\\"), + is_dir: true, } }) .collect() @@ -136,6 +150,7 @@ fn logical_drives() -> Vec { vec![DirectoryEntry { name: "/".to_owned(), path: "/".to_owned(), + is_dir: true, }] } @@ -146,6 +161,14 @@ mod tests { fn query(path: Option<&str>) -> ListDirectoriesQuery { ListDirectoriesQuery { path: path.map(str::to_owned), + include_files: None, + } + } + + fn query_with_files(path: Option<&str>) -> ListDirectoriesQuery { + ListDirectoriesQuery { + path: path.map(str::to_owned), + include_files: Some(true), } } @@ -164,6 +187,7 @@ mod tests { .map(|entry| entry.name.as_str()) .collect::>(); assert_eq!(names, vec!["Alpha", "zeta"]); + assert!(listing.entries.iter().all(|entry| entry.is_dir)); assert_eq!( listing.parent.as_deref(), Some(temp.path().parent().unwrap().to_str().unwrap()) @@ -171,6 +195,26 @@ mod tests { assert!(!listing.truncated); } + #[test] + fn lists_files_when_requested_after_directories() { + let temp = tempfile::TempDir::new().unwrap(); + fs::create_dir(temp.path().join("bin")).unwrap(); + fs::write(temp.path().join("zeta.exe"), "x").unwrap(); + fs::write(temp.path().join("Alpha.txt"), "x").unwrap(); + + let listing = + list_directories(&query_with_files(Some(temp.path().to_str().unwrap()))).unwrap(); + + let names = listing + .entries + .iter() + .map(|entry| entry.name.as_str()) + .collect::>(); + assert_eq!(names, vec!["bin", "Alpha.txt", "zeta.exe"]); + assert!(listing.entries[0].is_dir); + assert!(listing.entries[1..].iter().all(|entry| !entry.is_dir)); + } + #[test] fn returns_absolute_paths_for_entries() { let temp = tempfile::TempDir::new().unwrap(); diff --git a/backend/src/runs/model.rs b/backend/src/runs/model.rs index 56a6a35..50f7c01 100644 --- a/backend/src/runs/model.rs +++ b/backend/src/runs/model.rs @@ -7,7 +7,6 @@ use utoipa::{IntoParams, ToSchema}; pub enum RunTrigger { Schedule, Manual, - Retry, } impl RunTrigger { @@ -15,7 +14,6 @@ impl RunTrigger { match self { Self::Schedule => "schedule", Self::Manual => "manual", - Self::Retry => "retry", } } } diff --git a/backend/src/runs/repository.rs b/backend/src/runs/repository.rs index b305a8f..eda4071 100644 --- a/backend/src/runs/repository.rs +++ b/backend/src/runs/repository.rs @@ -255,7 +255,6 @@ fn parse_trigger(value: &str) -> ApiResult { match value { "schedule" => Ok(RunTrigger::Schedule), "manual" => Ok(RunTrigger::Manual), - "retry" => Ok(RunTrigger::Retry), _ => Err(ApiError::internal(format!("unknown run trigger: {value}"))), } } diff --git a/backend/src/scheduler.rs b/backend/src/scheduler.rs index 457a8a7..284013f 100644 --- a/backend/src/scheduler.rs +++ b/backend/src/scheduler.rs @@ -198,7 +198,7 @@ fn lock_next_runs( mod tests { use chrono::{TimeZone, Timelike}; - use crate::tasks::model::{ConcurrencyPolicy, RetryPolicy, ScriptLanguage, TaskAction}; + use crate::tasks::model::{ScriptLanguage, TaskAction}; use super::*; @@ -206,12 +206,11 @@ mod tests { TaskResponse { id: "schedule_test".to_owned(), name: "Schedule test".to_owned(), + description: String::new(), cron: cron.to_owned(), timezone: timezone.to_owned(), enabled: true, timeout_secs: 30, - concurrency_policy: ConcurrencyPolicy::Skip, - retry_policy: RetryPolicy::default(), action: TaskAction::Script { working_dir: ".".to_owned(), venv: None, diff --git a/backend/src/tasks/model.rs b/backend/src/tasks/model.rs index 6caca32..01f81e8 100644 --- a/backend/src/tasks/model.rs +++ b/backend/src/tasks/model.rs @@ -84,22 +84,6 @@ pub enum HttpMethod { Post, } -#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize, ToSchema)] -#[serde(rename_all = "snake_case")] -pub enum ConcurrencyPolicy { - #[default] - Skip, -} - -#[derive(Debug, Clone, Default, Serialize, Deserialize, ToSchema)] -#[serde(deny_unknown_fields)] -pub struct RetryPolicy { - #[serde(default)] - pub max_attempts: u32, - #[serde(default)] - pub delay_secs: u64, -} - #[derive(Debug, Clone, Serialize, Deserialize, ToSchema)] #[serde(tag = "type", rename_all = "snake_case", deny_unknown_fields)] pub enum TaskAction { @@ -176,6 +160,9 @@ const fn default_timeout_secs() -> u64 { pub struct CreateTaskRequest { pub id: String, pub name: String, + /// 一句话说明,展示在任务卡片和详情里 + #[serde(default)] + pub description: String, pub cron: String, #[serde(default = "default_timezone")] pub timezone: String, @@ -183,10 +170,6 @@ pub struct CreateTaskRequest { pub enabled: bool, #[serde(default = "default_timeout_secs")] pub timeout_secs: u64, - #[serde(default)] - pub concurrency_policy: ConcurrencyPolicy, - #[serde(default)] - pub retry_policy: RetryPolicy, pub action: TaskAction, } @@ -194,6 +177,9 @@ pub struct CreateTaskRequest { #[serde(deny_unknown_fields)] pub struct UpdateTaskRequest { pub name: String, + /// 一句话说明,展示在任务卡片和详情里 + #[serde(default)] + pub description: String, pub cron: String, #[serde(default = "default_timezone")] pub timezone: String, @@ -201,10 +187,6 @@ pub struct UpdateTaskRequest { pub enabled: bool, #[serde(default = "default_timeout_secs")] pub timeout_secs: u64, - #[serde(default)] - pub concurrency_policy: ConcurrencyPolicy, - #[serde(default)] - pub retry_policy: RetryPolicy, pub action: TaskAction, } @@ -212,12 +194,12 @@ pub struct UpdateTaskRequest { pub struct TaskResponse { pub id: String, pub name: String, + /// 一句话说明 + pub description: String, pub cron: String, pub timezone: String, pub enabled: bool, pub timeout_secs: u64, - pub concurrency_policy: ConcurrencyPolicy, - pub retry_policy: RetryPolicy, pub action: TaskAction, pub created_at: DateTime, pub updated_at: DateTime, diff --git a/backend/src/tasks/repository.rs b/backend/src/tasks/repository.rs index 6df4a6a..b5ef963 100644 --- a/backend/src/tasks/repository.rs +++ b/backend/src/tasks/repository.rs @@ -4,15 +4,14 @@ use sqlx::{FromRow, QueryBuilder, Sqlite, SqlitePool}; use crate::{ error::{ApiError, ApiResult}, tasks::model::{ - ConcurrencyPolicy, CreateTaskRequest, RetryPolicy, TaskAction, TaskActionType, - TaskListResponse, TaskResponse, UpdateTaskRequest, + CreateTaskRequest, TaskAction, TaskActionType, TaskListResponse, TaskResponse, + UpdateTaskRequest, }, }; const TASK_COLUMNS: &str = " - id, name, cron, timezone, enabled, timeout_secs, concurrency_policy, - retry_max_attempts, retry_delay_secs, action_type, action_json, - created_at, updated_at + id, name, description, cron, timezone, enabled, timeout_secs, + action_type, action_json, created_at, updated_at "; #[derive(Debug, Clone, Copy, Default)] @@ -59,13 +58,11 @@ pub struct ListFilters { struct TaskRow { id: String, name: String, + description: String, cron: String, timezone: String, enabled: bool, timeout_secs: i64, - concurrency_policy: String, - retry_max_attempts: i64, - retry_delay_secs: i64, action_type: String, action_json: String, created_at: DateTime, @@ -76,13 +73,6 @@ impl TryFrom for TaskResponse { type Error = ApiError; fn try_from(row: TaskRow) -> Result { - if row.concurrency_policy != "skip" { - return Err(ApiError::internal(format!( - "unknown concurrency policy: {}", - row.concurrency_policy - ))); - } - let action: TaskAction = serde_json::from_str(&row.action_json)?; if row.action_type != action.action_type().as_str() { return Err(ApiError::internal(format!( @@ -94,15 +84,11 @@ impl TryFrom for TaskResponse { Ok(Self { id: row.id, name: row.name, + description: row.description, cron: row.cron, timezone: row.timezone, enabled: row.enabled, timeout_secs: u64::try_from(row.timeout_secs).map_err(ApiError::internal)?, - concurrency_policy: ConcurrencyPolicy::Skip, - retry_policy: RetryPolicy { - max_attempts: u32::try_from(row.retry_max_attempts).map_err(ApiError::internal)?, - delay_secs: u64::try_from(row.retry_delay_secs).map_err(ApiError::internal)?, - }, action, created_at: row.created_at, updated_at: row.updated_at, @@ -115,20 +101,17 @@ pub async fn create(pool: &SqlitePool, request: &CreateTaskRequest) -> ApiResult let action_json = serde_json::to_string(&request.action)?; let result = sqlx::query( "INSERT INTO tasks ( - id, name, cron, timezone, enabled, timeout_secs, concurrency_policy, - retry_max_attempts, retry_delay_secs, action_type, action_json, - created_at, updated_at - ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)", + id, name, description, cron, timezone, enabled, timeout_secs, + action_type, action_json, created_at, updated_at + ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)", ) .bind(&request.id) .bind(&request.name) + .bind(&request.description) .bind(&request.cron) .bind(&request.timezone) .bind(request.enabled) .bind(request.timeout_secs as i64) - .bind("skip") - .bind(i64::from(request.retry_policy.max_attempts)) - .bind(request.retry_policy.delay_secs as i64) .bind(request.action.action_type().as_str()) .bind(action_json) .bind(now) @@ -149,12 +132,11 @@ pub async fn create(pool: &SqlitePool, request: &CreateTaskRequest) -> ApiResult Ok(TaskResponse { id: request.id.clone(), name: request.name.clone(), + description: request.description.clone(), cron: request.cron.clone(), timezone: request.timezone.clone(), enabled: request.enabled, timeout_secs: request.timeout_secs, - concurrency_policy: request.concurrency_policy, - retry_policy: request.retry_policy.clone(), action: request.action.clone(), created_at: now, updated_at: now, @@ -164,9 +146,8 @@ pub async fn create(pool: &SqlitePool, request: &CreateTaskRequest) -> ApiResult pub async fn get(pool: &SqlitePool, id: &str) -> ApiResult> { let row = sqlx::query_as::<_, TaskRow>( "SELECT - id, name, cron, timezone, enabled, timeout_secs, concurrency_policy, - retry_max_attempts, retry_delay_secs, action_type, action_json, - created_at, updated_at + id, name, description, cron, timezone, enabled, timeout_secs, + action_type, action_json, created_at, updated_at FROM tasks WHERE id = ?", ) @@ -186,19 +167,16 @@ pub async fn update( let action_json = serde_json::to_string(&request.action)?; let result = sqlx::query( "UPDATE tasks SET - name = ?, cron = ?, timezone = ?, enabled = ?, timeout_secs = ?, - concurrency_policy = ?, retry_max_attempts = ?, retry_delay_secs = ?, + name = ?, description = ?, cron = ?, timezone = ?, enabled = ?, timeout_secs = ?, action_type = ?, action_json = ?, updated_at = ? WHERE id = ?", ) .bind(&request.name) + .bind(&request.description) .bind(&request.cron) .bind(&request.timezone) .bind(request.enabled) .bind(request.timeout_secs as i64) - .bind("skip") - .bind(i64::from(request.retry_policy.max_attempts)) - .bind(request.retry_policy.delay_secs as i64) .bind(request.action.action_type().as_str()) .bind(action_json) .bind(now) @@ -269,9 +247,8 @@ pub async fn list(pool: &SqlitePool, filters: &ListFilters) -> ApiResult ApiResult> { let rows = sqlx::query_as::<_, TaskRow>( "SELECT - id, name, cron, timezone, enabled, timeout_secs, concurrency_policy, - retry_max_attempts, retry_delay_secs, action_type, action_json, - created_at, updated_at + id, name, description, cron, timezone, enabled, timeout_secs, + action_type, action_json, created_at, updated_at FROM tasks WHERE enabled = 1 ORDER BY updated_at DESC, id ASC", diff --git a/backend/src/tasks/service.rs b/backend/src/tasks/service.rs index f41396d..1a649e6 100644 --- a/backend/src/tasks/service.rs +++ b/backend/src/tasks/service.rs @@ -14,6 +14,7 @@ use super::repository::{self, ListFilters}; const MAX_TASK_ID_LEN: usize = 64; const MAX_TASK_NAME_LEN: usize = 120; +const MAX_DESCRIPTION_LEN: usize = 500; const MAX_TIMEOUT_SECS: u64 = 86_400; const MAX_ARGS: usize = 256; const MAX_ARG_LEN: usize = 2_048; @@ -26,17 +27,18 @@ const MAX_PAGE_SIZE: u32 = 100; pub async fn create(pool: &SqlitePool, mut request: CreateTaskRequest) -> ApiResult { request.name = request.name.trim().to_owned(); + request.description = request.description.trim().to_owned(); request.cron = request.cron.trim().to_owned(); request.timezone = request.timezone.trim().to_owned(); normalize_action(&mut request.action); validate_task_id(&request.id)?; + validate_description(&request.description)?; validate_common( &request.name, &request.cron, &request.timezone, request.timeout_secs, - &request.retry_policy, )?; validate_action(&request.action)?; @@ -56,16 +58,17 @@ pub async fn update( ) -> ApiResult { validate_task_id(id)?; request.name = request.name.trim().to_owned(); + request.description = request.description.trim().to_owned(); request.cron = request.cron.trim().to_owned(); request.timezone = request.timezone.trim().to_owned(); normalize_action(&mut request.action); + validate_description(&request.description)?; validate_common( &request.name, &request.cron, &request.timezone, request.timeout_secs, - &request.retry_policy, )?; validate_action(&request.action)?; @@ -131,12 +134,21 @@ fn validate_task_id(id: &str) -> ApiResult<()> { Ok(()) } +fn validate_description(description: &str) -> ApiResult<()> { + if description.chars().count() > MAX_DESCRIPTION_LEN { + return Err(crate::error::ApiError::validation( + "description", + format!("任务说明不能超过 {MAX_DESCRIPTION_LEN} 个字符"), + )); + } + Ok(()) +} + fn validate_common( name: &str, cron_expression: &str, timezone: &str, timeout_secs: u64, - retry_policy: &RetryPolicy, ) -> ApiResult<()> { if name.is_empty() || name.chars().count() > MAX_TASK_NAME_LEN { return Err(crate::error::ApiError::validation( @@ -150,18 +162,6 @@ fn validate_common( format!("超时时间必须在 1 到 {MAX_TIMEOUT_SECS} 秒之间"), )); } - if retry_policy.max_attempts > 10 { - return Err(crate::error::ApiError::validation( - "retry_policy.max_attempts", - "最大重试次数不能超过 10", - )); - } - if retry_policy.delay_secs > 3_600 { - return Err(crate::error::ApiError::validation( - "retry_policy.delay_secs", - "重试间隔不能超过 3600 秒", - )); - } Tz::from_str(timezone).map_err(|_| { crate::error::ApiError::validation("timezone", "时区必须是有效的 IANA 时区名称") @@ -371,7 +371,6 @@ mod tests { "0 0 2 * * *", "Asia/Shanghai", 300, - &RetryPolicy::default(), ) .expect("task fields should be valid"); validate_action(&script_action()).expect("script action should be valid"); @@ -384,7 +383,6 @@ mod tests { "*/0 * * * * *", "Asia/Shanghai", 300, - &RetryPolicy::default(), ) .expect_err("invalid cron should fail"); diff --git a/backend/src/uv.rs b/backend/src/uv.rs index ed859e9..c68f9e1 100644 --- a/backend/src/uv.rs +++ b/backend/src/uv.rs @@ -15,6 +15,7 @@ use chrono::Utc; use serde::{Deserialize, Serialize}; use sha2::{Digest, Sha256}; use sqlx::SqlitePool; +use url::Url; use utoipa::ToSchema; use uuid::Uuid; @@ -32,11 +33,17 @@ const PYTHON_DIR_DEFAULT: &str = "python"; const CACHE_DIR_DEFAULT: &str = "uv-cache"; const PYTHON_INSTALL_DIR_ENV: &str = "UV_PYTHON_INSTALL_DIR"; const CACHE_DIR_ENV: &str = "UV_CACHE_DIR"; -/// 没有本机 uv 时从这里下载(中科大维护的 GitHub Release 镜像)。 -const UV_MIRROR: &str = "https://mirrors.ustc.edu.cn/github-release/astral-sh/uv/LatestRelease"; +const PYTHON_MIRROR_ENV: &str = "UV_PYTHON_INSTALL_MIRROR"; +/// 内置的 uv 下载镜像根(中科大维护的 GitHub Release 镜像);可在「镜像源」里改成别的镜像。 +const DEFAULT_MIRROR_BASE: &str = "https://mirrors.ustc.edu.cn/github-release"; +/// 镜像根下 uv 自己的发布目录。 +const UV_RELEASE_PATH: &str = "astral-sh/uv/LatestRelease"; +/// 镜像根下 python-build-standalone 的发布目录(uv 的 `UV_PYTHON_INSTALL_MIRROR`)。 +const PYTHON_RELEASE_PATH: &str = "astral-sh/python-build-standalone/releases/download"; const MAX_UV_ARCHIVE_BYTES: u64 = 200 * 1024 * 1024; const MAX_UV_ENV_ENTRIES: usize = 32; const MAX_UV_ENV_VALUE_CHARS: usize = 512; +const MAX_MIRROR_URL_CHARS: usize = 2048; /// uv 从哪里来。只用平台自己认的副本,不读系统 PATH:PATH 上的版本和可用性 /// 会随启动方式变化,不适合当调度器的隐式依赖。 @@ -72,6 +79,8 @@ pub struct UvSettings { pub source: UvSource, pub custom_path: Option, pub extra_env: BTreeMap, + /// uv 与 Python 解释器的下载镜像根(GitHub Release 镜像);空 = 内置默认镜像 + pub mirror_url: Option, } impl Default for UvSettings { @@ -80,6 +89,7 @@ impl Default for UvSettings { source: UvSource::Managed, custom_path: None, extra_env: BTreeMap::new(), + mirror_url: None, } } } @@ -99,8 +109,15 @@ impl UvSettings { /// 注入给 uv 的环境变量:两个路径变量没配时用相对默认值(`python` / `uv-cache`), /// 注入前统一解析成绝对路径——uv 的 cwd 会变,相对路径不能直接交给它。 + /// 解释器的下载镜像同理:用户没在 `UV_*` 里覆盖时,注入镜像源派生出的地址。 pub fn env(&self) -> BTreeMap { let mut env = self.extra_env.clone(); + if !env + .keys() + .any(|key| key.eq_ignore_ascii_case(PYTHON_MIRROR_ENV)) + { + env.insert(PYTHON_MIRROR_ENV.to_owned(), self.python_mirror_url()); + } for (key, default) in UV_PATH_DEFAULTS { let value = env .get(key) @@ -114,6 +131,27 @@ impl UvSettings { env } + /// uv 与 Python 解释器的下载镜像根(去掉结尾斜杠);没配就用内置默认。 + pub fn mirror_base(&self) -> String { + self.mirror_url + .as_deref() + .map(str::trim) + .filter(|value| !value.is_empty()) + .unwrap_or(DEFAULT_MIRROR_BASE) + .trim_end_matches('/') + .to_owned() + } + + /// uv 自己那个压缩包的下载地址前缀。 + pub fn uv_download_base(&self) -> String { + format!("{}/{}", self.mirror_base(), UV_RELEASE_PATH) + } + + /// `UV_PYTHON_INSTALL_MIRROR`:uv 下载 Python 解释器的地址前缀。 + pub fn python_mirror_url(&self) -> String { + format!("{}/{}", self.mirror_base(), PYTHON_RELEASE_PATH) + } + /// uv 下载 Python 的目录(解析后的绝对路径)。 pub fn python_install_dir(&self) -> PathBuf { let value = self @@ -244,13 +282,13 @@ fn install_archive(archive: &[u8], managed: &Path) -> Result { } /// 从镜像下载当前架构的 uv,校验 sha256 后安装到程序目录的 `uv` 文件夹。 -async fn download_and_install() -> Result { +async fn download_and_install(base: &str) -> Result { let client = reqwest::Client::builder() .timeout(Duration::from_secs(600)) .user_agent("TaskPilot/0.1") .build() .map_err(|error| format!("无法创建下载客户端: {error}"))?; - let url = format!("{UV_MIRROR}/{}", archive_name()); + let url = format!("{}/{}", base.trim_end_matches('/'), archive_name()); let checksum = client .get(format!("{url}.sha256")) @@ -312,6 +350,12 @@ pub struct UvSettingsResponse { pub python_dir: String, /// 实际生效的 uv 缓存目录(绝对路径) pub cache_dir: String, + /// 当前使用的下载镜像根(GitHub Release 镜像);空值时返回内置默认 + pub mirror_url: String, + /// uv 自身压缩包的下载地址前缀 + pub uv_download_url: String, + /// Python 解释器的下载地址前缀(注入 `UV_PYTHON_INSTALL_MIRROR`) + pub python_mirror_url: String, /// 两个路径变量的默认值(相对程序目录),页面用来显示「默认」那一行 pub env_defaults: BTreeMap, /// 当前设置指向的 uv;不可用时 `version` 为空 @@ -330,6 +374,9 @@ pub struct UpdateUvSettingsRequest { /// 额外注入给 uv 的环境变量;只允许 `UV_` 前缀 #[serde(default)] pub extra_env: BTreeMap, + /// uv 本体与解释器的下载镜像根(GitHub Release 镜像);空 = 内置默认 + #[serde(default)] + pub mirror_url: Option, } /// 执行期使用的 uv 设置;读取失败时回退到程序目录 uv,不让任务因此跑不起来。 @@ -378,10 +425,24 @@ fn describe(settings: &UvSettings) -> UvSettingsResponse { managed_dir: managed_directory.to_string_lossy().into_owned(), python_dir: settings.python_install_dir().to_string_lossy().into_owned(), cache_dir: settings.cache_dir().to_string_lossy().into_owned(), - env_defaults: UV_PATH_DEFAULTS - .iter() - .map(|(key, value)| ((*key).to_owned(), (*value).to_owned())) - .collect(), + mirror_url: settings.mirror_base(), + uv_download_url: settings.uv_download_base(), + python_mirror_url: settings + .extra_env + .get(PYTHON_MIRROR_ENV) + .cloned() + .unwrap_or_else(|| settings.python_mirror_url()), + env_defaults: { + let mut defaults: BTreeMap = UV_PATH_DEFAULTS + .iter() + .map(|(key, value)| ((*key).to_owned(), (*value).to_owned())) + .collect(); + defaults.insert( + PYTHON_MIRROR_ENV.to_owned(), + settings.python_mirror_url(), + ); + defaults + }, executable: current.map(|candidate| candidate.path.clone()), version: current.and_then(|candidate| candidate.version.clone()), candidates, @@ -460,40 +521,69 @@ fn validate(request: UpdateUvSettingsRequest) -> ApiResult { source: request.source, custom_path, extra_env: validate_extra_env(&request.extra_env)?, + mirror_url: validate_mirror_url(request.mirror_url)?, }) } +fn validate_mirror_url(value: Option) -> ApiResult> { + let Some(raw) = value else { + return Ok(None); + }; + let trimmed = raw.trim().trim_end_matches('/'); + if trimmed.is_empty() { + return Ok(None); + } + if trimmed.chars().count() > MAX_MIRROR_URL_CHARS { + return Err(ApiError::validation("url", "镜像地址过长")); + } + let parsed = Url::parse(trimmed) + .map_err(|_| ApiError::validation("url", "镜像地址不是有效的 URL"))?; + if !matches!(parsed.scheme(), "http" | "https") { + return Err(ApiError::validation("url", "镜像地址只支持 http 或 https")); + } + if parsed.host_str().is_none() { + return Err(ApiError::validation("url", "镜像地址缺少主机名")); + } + if !parsed.username().is_empty() || parsed.password().is_some() { + return Err(ApiError::validation("url", "镜像地址不能包含用户名或密码")); + } + Ok(Some(trimmed.to_owned())) +} + async fn load(pool: &SqlitePool) -> ApiResult { - let row = sqlx::query_as::<_, (String, Option, String)>( - "SELECT source, custom_path, extra_env FROM uv_settings WHERE id = 1", + let row = sqlx::query_as::<_, (String, Option, String, Option)>( + "SELECT source, custom_path, extra_env, mirror_url FROM uv_settings WHERE id = 1", ) .fetch_optional(pool) .await?; - let Some((source, custom_path, extra_env)) = row else { + let Some((source, custom_path, extra_env, mirror_url)) = row else { return Ok(UvSettings::default()); }; Ok(UvSettings { source: UvSource::parse(&source).unwrap_or(UvSource::Managed), custom_path, extra_env: serde_json::from_str(&extra_env).unwrap_or_default(), + mirror_url, }) } async fn save(pool: &SqlitePool, settings: &UvSettings) -> ApiResult<()> { let extra_env = serde_json::to_string(&settings.extra_env)?; sqlx::query( - "INSERT INTO uv_settings (id, source, custom_path, extra_env, updated_at) - VALUES (1, ?, ?, ?, ?) + "INSERT INTO uv_settings (id, source, custom_path, extra_env, mirror_url, updated_at) + VALUES (1, ?, ?, ?, ?, ?) ON CONFLICT(id) DO UPDATE SET source = excluded.source, custom_path = excluded.custom_path, extra_env = excluded.extra_env, + mirror_url = excluded.mirror_url, updated_at = excluded.updated_at", ) .bind(settings.source.as_str()) .bind(settings.custom_path.as_deref()) .bind(extra_env) + .bind(settings.mirror_url.as_deref()) .bind(Utc::now()) .execute(pool) .await?; @@ -551,12 +641,12 @@ pub async fn update_uv_settings( )] pub async fn download_uv(State(state): State) -> ApiResult> { // shortcut: 并发下载不互斥,后完成的覆盖先完成的;单用户本地工具够用,需要时加互斥。 - let version = download_and_install() + let mut settings = load(&state.pool).await?; + let version = download_and_install(&settings.uv_download_base()) .await .map_err(|message| ApiError::bad_request("UV_DOWNLOAD_FAILED", message))?; tracing::info!(%version, "downloaded uv into the managed directory"); - let mut settings = load(&state.pool).await?; // 下载的目的就是让它生效,所以直接切到程序目录 uv。 settings.source = UvSource::Managed; save(&state.pool, &settings).await?; @@ -589,6 +679,7 @@ mod tests { source, custom_path: custom_path.map(str::to_owned), extra_env: BTreeMap::new(), + mirror_url: None, } } @@ -598,6 +689,7 @@ mod tests { source: UvSource::Custom, custom_path: Some(r"C:\tools\uv\uv.exe".to_owned()), extra_env: BTreeMap::new(), + mirror_url: None, }; assert_eq!(custom.program(), PathBuf::from(r"C:\tools\uv\uv.exe")); @@ -605,6 +697,7 @@ mod tests { source: UvSource::Managed, custom_path: None, extra_env: BTreeMap::new(), + mirror_url: None, }; assert_eq!(managed.program(), managed_directory().join(UV_EXECUTABLE)); } @@ -638,6 +731,7 @@ mod tests { source: UvSource::Managed, custom_path: None, extra_env, + mirror_url: None, }) .unwrap(); @@ -656,6 +750,7 @@ mod tests { source: UvSource::Managed, custom_path: None, extra_env, + mirror_url: None, }); assert!(result.is_err(), "{name} should be rejected"); } @@ -667,6 +762,7 @@ mod tests { source: UvSource::Managed, custom_path: None, extra_env, + mirror_url: None, }) .is_err() ); @@ -693,6 +789,87 @@ mod tests { assert!(parse_sha256("").is_none()); } + #[test] + fn derives_uv_and_python_urls_from_the_mirror_base() { + let settings = UvSettings { + mirror_url: Some("https://mirror.example/github-release/".to_owned()), + ..UvSettings::default() + }; + + assert_eq!(settings.mirror_base(), "https://mirror.example/github-release"); + assert_eq!( + settings.uv_download_base(), + "https://mirror.example/github-release/astral-sh/uv/LatestRelease" + ); + assert_eq!( + settings.python_mirror_url(), + "https://mirror.example/github-release/astral-sh/python-build-standalone/releases/download" + ); + } + + #[test] + fn falls_back_to_the_builtin_mirror() { + let settings = UvSettings::default(); + + assert_eq!(settings.mirror_base(), DEFAULT_MIRROR_BASE); + assert_eq!( + settings.uv_download_base(), + "https://mirrors.ustc.edu.cn/github-release/astral-sh/uv/LatestRelease" + ); + assert_eq!( + settings.python_mirror_url(), + "https://mirrors.ustc.edu.cn/github-release/astral-sh/python-build-standalone/releases/download" + ); + } + + #[test] + fn validates_mirror_urls() { + assert_eq!(validate_mirror_url(None).unwrap(), None); + assert_eq!(validate_mirror_url(Some(" ".to_owned())).unwrap(), None); + assert_eq!( + validate_mirror_url(Some( + "https://mirrors.ustc.edu.cn/github-release/".to_owned() + )) + .unwrap() + .as_deref(), + Some("https://mirrors.ustc.edu.cn/github-release") + ); + assert!(validate_mirror_url(Some("ftp://mirror.example".to_owned())).is_err()); + assert!(validate_mirror_url(Some("not a url".to_owned())).is_err()); + assert!(validate_mirror_url(Some("https://user:pass@mirror.example".to_owned())).is_err()); + } + + #[test] + fn injects_the_python_mirror_and_keeps_user_overrides() { + let settings = UvSettings::default(); + assert_eq!( + settings + .env() + .get("UV_PYTHON_INSTALL_MIRROR") + .map(String::as_str), + Some( + "https://mirrors.ustc.edu.cn/github-release/astral-sh/python-build-standalone/releases/download" + ) + ); + + let mut extra_env = BTreeMap::new(); + extra_env.insert( + "UV_PYTHON_INSTALL_MIRROR".to_owned(), + "https://my.mirror/python".to_owned(), + ); + let custom = UvSettings { + extra_env, + ..UvSettings::default() + }; + assert_eq!( + custom + .env() + .get("UV_PYTHON_INSTALL_MIRROR") + .map(String::as_str), + Some("https://my.mirror/python") + ); + } + #[test] fn extracts_uv_from_the_archive() { use std::io::Write; diff --git a/backend/tests/api.rs b/backend/tests/api.rs index bc4173c..bec5d53 100644 --- a/backend/tests/api.rs +++ b/backend/tests/api.rs @@ -131,6 +131,30 @@ async fn directory_listing_endpoint_returns_subdirectories() { ); } +#[tokio::test] +async fn directory_listing_endpoint_includes_files_when_requested() { + let (app, temp_dir) = test_app().await; + let tools = temp_dir.path().join("tools"); + fs::create_dir_all(tools.join("bin")).expect("bin directory should be created"); + fs::write(tools.join("uv.exe"), "x").expect("file should be written"); + + let uri = format!( + "/api/v1/system/directories?path={}&include_files=true", + encode_query_path(&tools) + ); + let (status, body) = get_json(&app, &uri).await; + + assert_eq!(status, StatusCode::OK); + let entries = body["entries"] + .as_array() + .expect("entries should be an array"); + assert_eq!(entries.len(), 2); + assert_eq!(entries[0]["name"], "bin"); + assert_eq!(entries[0]["is_dir"], true); + assert_eq!(entries[1]["name"], "uv.exe"); + assert_eq!(entries[1]["is_dir"], false); +} + #[tokio::test] async fn directory_listing_endpoint_rejects_relative_paths() { let (app, _temp_dir) = test_app().await; diff --git a/backend/tests/uv.rs b/backend/tests/uv.rs index 2ea7a52..cfe748c 100644 --- a/backend/tests/uv.rs +++ b/backend/tests/uv.rs @@ -197,6 +197,81 @@ async fn uv_settings_mutations_require_token() { assert_eq!(body["code"], "FORBIDDEN"); } +#[tokio::test] +async fn uv_mirror_is_saved_with_the_settings_and_drives_both_urls() { + let (app, _temp_dir) = test_app().await; + let token = auth_token(&app).await; + + let (status, body) = send_json(&app, Method::GET, "/api/v1/uv", None, None).await; + assert_eq!(status, StatusCode::OK); + assert_eq!( + body["mirror_url"], + "https://mirrors.ustc.edu.cn/github-release" + ); + assert_eq!( + body["uv_download_url"], + "https://mirrors.ustc.edu.cn/github-release/astral-sh/uv/LatestRelease" + ); + assert_eq!( + body["python_mirror_url"], + "https://mirrors.ustc.edu.cn/github-release/astral-sh/python-build-standalone/releases/download" + ); + // 解释器镜像作为默认环境变量出现在列表里,可以被显式覆盖 + assert_eq!( + body["env_defaults"]["UV_PYTHON_INSTALL_MIRROR"], + "https://mirrors.ustc.edu.cn/github-release/astral-sh/python-build-standalone/releases/download" + ); + + // 非法镜像地址会被拒绝 + let (status, body) = send_json( + &app, + Method::PUT, + "/api/v1/uv", + Some(&token), + Some(json!({ + "source": "managed", + "mirror_url": "ftp://mirror.example" + })), + ) + .await; + assert_eq!(status, StatusCode::BAD_REQUEST); + assert_eq!(body["field"], "url"); + + // 保存自定义镜像:两个下载地址都由它派生 + let (status, saved) = send_json( + &app, + Method::PUT, + "/api/v1/uv", + Some(&token), + Some(json!({ + "source": "managed", + "mirror_url": "https://mirror.example/github-release/" + })), + ) + .await; + assert_eq!(status, StatusCode::OK); + assert_eq!(saved["mirror_url"], "https://mirror.example/github-release"); + assert_eq!( + saved["uv_download_url"], + "https://mirror.example/github-release/astral-sh/uv/LatestRelease" + ); + + // 传 null 恢复内置默认 + let (status, restored) = send_json( + &app, + Method::PUT, + "/api/v1/uv", + Some(&token), + Some(json!({ "source": "managed", "mirror_url": null })), + ) + .await; + assert_eq!(status, StatusCode::OK); + assert_eq!( + restored["mirror_url"], + "https://mirrors.ustc.edu.cn/github-release" + ); +} + #[tokio::test] async fn uv_download_requires_token() { let (app, _temp_dir) = test_app().await; diff --git a/contracts/openapi.json b/contracts/openapi.json index 9ea8c74..4e67fee 100644 --- a/contracts/openapi.json +++ b/contracts/openapi.json @@ -902,11 +902,20 @@ "schema": { "type": "string" } + }, + { + "name": "include_files", + "in": "query", + "description": "是否一并返回文件(挑选可执行文件时用);默认只返回目录", + "required": false, + "schema": { + "type": "boolean" + } } ], "responses": { "200": { - "description": "Subdirectories of the requested path, or the drive list", + "description": "Subdirectories of the requested path (plus files when `include_files=true`), or the drive list", "content": { "application/json": { "schema": { @@ -1465,12 +1474,6 @@ }, "components": { "schemas": { - "ConcurrencyPolicy": { - "type": "string", - "enum": [ - "skip" - ] - }, "CreateManagedEnvRequest": { "type": "object", "required": [ @@ -1548,12 +1551,13 @@ "action": { "$ref": "#/components/schemas/TaskAction" }, - "concurrency_policy": { - "$ref": "#/components/schemas/ConcurrencyPolicy" - }, "cron": { "type": "string" }, + "description": { + "type": "string", + "description": "一句话说明,展示在任务卡片和详情里" + }, "enabled": { "type": "boolean" }, @@ -1563,9 +1567,6 @@ "name": { "type": "string" }, - "retry_policy": { - "$ref": "#/components/schemas/RetryPolicy" - }, "timeout_secs": { "type": "integer", "format": "int64", @@ -1620,9 +1621,14 @@ "type": "object", "required": [ "name", - "path" + "path", + "is_dir" ], "properties": { + "is_dir": { + "type": "boolean", + "description": "true 表示目录(可继续进入),false 表示文件(`include_files` 时才出现)" + }, "name": { "type": "string", "description": "用于展示的名称" @@ -1869,22 +1875,6 @@ } } }, - "RetryPolicy": { - "type": "object", - "properties": { - "delay_secs": { - "type": "integer", - "format": "int64", - "minimum": 0 - }, - "max_attempts": { - "type": "integer", - "format": "int32", - "minimum": 0 - } - }, - "additionalProperties": false - }, "RunStatus": { "type": "string", "enum": [ @@ -1901,8 +1891,7 @@ "type": "string", "enum": [ "schedule", - "manual", - "retry" + "manual" ] }, "ScheduledTaskResponse": { @@ -2136,12 +2125,11 @@ "required": [ "id", "name", + "description", "cron", "timezone", "enabled", "timeout_secs", - "concurrency_policy", - "retry_policy", "action", "created_at", "updated_at" @@ -2150,9 +2138,6 @@ "action": { "$ref": "#/components/schemas/TaskAction" }, - "concurrency_policy": { - "$ref": "#/components/schemas/ConcurrencyPolicy" - }, "created_at": { "type": "string", "format": "date-time" @@ -2160,6 +2145,10 @@ "cron": { "type": "string" }, + "description": { + "type": "string", + "description": "一句话说明" + }, "enabled": { "type": "boolean" }, @@ -2169,9 +2158,6 @@ "name": { "type": "string" }, - "retry_policy": { - "$ref": "#/components/schemas/RetryPolicy" - }, "timeout_secs": { "type": "integer", "format": "int64", @@ -2369,21 +2355,19 @@ "action": { "$ref": "#/components/schemas/TaskAction" }, - "concurrency_policy": { - "$ref": "#/components/schemas/ConcurrencyPolicy" - }, "cron": { "type": "string" }, + "description": { + "type": "string", + "description": "一句话说明,展示在任务卡片和详情里" + }, "enabled": { "type": "boolean" }, "name": { "type": "string" }, - "retry_policy": { - "$ref": "#/components/schemas/RetryPolicy" - }, "timeout_secs": { "type": "integer", "format": "int64", @@ -2417,6 +2401,13 @@ "type": "string" } }, + "mirror_url": { + "type": [ + "string", + "null" + ], + "description": "uv 本体与解释器的下载镜像根(GitHub Release 镜像);空 = 内置默认" + }, "source": { "$ref": "#/components/schemas/UvSource" } @@ -2456,6 +2447,9 @@ "managed_dir", "python_dir", "cache_dir", + "mirror_url", + "uv_download_url", + "python_mirror_url", "env_defaults", "candidates" ], @@ -2508,13 +2502,25 @@ "type": "string", "description": "程序目录下的 uv 目录" }, + "mirror_url": { + "type": "string", + "description": "当前使用的下载镜像根(GitHub Release 镜像);空值时返回内置默认" + }, "python_dir": { "type": "string", "description": "实际生效的 Python 存放目录(绝对路径,相对值按程序目录解析)" }, + "python_mirror_url": { + "type": "string", + "description": "Python 解释器的下载地址前缀(注入 `UV_PYTHON_INSTALL_MIRROR`)" + }, "source": { "$ref": "#/components/schemas/UvSource" }, + "uv_download_url": { + "type": "string", + "description": "uv 自身压缩包的下载地址前缀" + }, "version": { "type": [ "string", diff --git a/docs/decisions/0009-uv-and-managed-environments.md b/docs/decisions/0009-uv-and-managed-environments.md index 7a1ec0e..836851f 100644 --- a/docs/decisions/0009-uv-and-managed-environments.md +++ b/docs/decisions/0009-uv-and-managed-environments.md @@ -15,6 +15,7 @@ Python 任务需要一个可复现的运行环境,但宿主机上的工具链 4. **路径默认值相对程序目录**:`UV_PYTHON_INSTALL_DIR` 默认 `python`、`UV_CACHE_DIR` 默认 `uv-cache`,注入前统一按可执行文件目录解析成绝对路径;用户可改成绝对路径。 5. **用户环境优先,缺失才创建**:解析顺序仍是显式 `venv` → 解释器路径推断 → 工作目录向上查找;都没有时在**保存任务**时用 uv 创建 `.venv`,运行期只校验、不再自动装依赖。 6. **托管环境**:`data/envs/<名称>` 预创建常用环境,Python 版本来自 `uv python install` 下载到平台的列表,可预装包、查看已装包。 +7. **解释器下载归 uv 配置**:`uv python install` / `uv python list` 的入口放在「uv 配置」弹窗(解释器由 uv 管理、落在 `UV_PYTHON_INSTALL_DIR`);「虚拟环境」弹窗只负责从已下载列表里选版本创建环境。 ## 原因 @@ -26,6 +27,6 @@ Python 任务需要一个可复现的运行环境,但宿主机上的工具链 ## 边界 - 托管环境不会自动升级依赖,也不跟随 `pyproject.toml` 变更重建。 -- `uv python install` 走 uv 自己的 Python 发行源,不受 PyPI 镜像影响;必要时用 `UV_PYTHON_INSTALL_MIRROR`。 +- `uv python install` 走 uv 自己的 Python 发行源(python-build-standalone),和 PyPI 镜像不是一回事;uv 配置弹窗里配「下载镜像」统一给 uv 本体与解释器用,解释器还可以用 `UV_PYTHON_INSTALL_MIRROR` 单独覆盖。 - 删除托管环境是直接删除目录,不可恢复。 - 自定义 uv 若版本过旧,`uv python list/envs` 等子命令可能与平台预期的参数不一致。 diff --git a/frontend/src/App.css b/frontend/src/App.css index 4eb1d66..2b34270 100644 --- a/frontend/src/App.css +++ b/frontend/src/App.css @@ -48,7 +48,7 @@ .page-container { width: 100%; - max-width: 1600px; + max-width: 1320px; min-width: 0; margin: 0 auto; } @@ -139,7 +139,8 @@ } .toolbar-search { - flex: 1 1 260px; + flex: 1 1 200px; + min-width: 180px; max-width: 420px; } @@ -151,7 +152,7 @@ } .filter-select { - min-width: 140px; + min-width: 110px; } .task-name-cell { @@ -227,16 +228,6 @@ gap: 4px; } -.pagination-bar { - display: flex; - align-items: center; - justify-content: space-between; - gap: 12px; - flex-wrap: wrap; - padding: 12px 16px; - border-top: 1px solid var(--su-border); -} - /* ---------- 任务编辑器 ---------- */ .task-editor-modal { @@ -651,6 +642,19 @@ background-color: var(--su-bg-subtle); } +/* 文件模式下选中的文件 */ +.directory-row-selected, +.directory-row-selected:hover { + background-color: var(--su-color-primary-soft); + color: var(--su-color-primary); +} + +.directory-check { + margin-left: auto; + flex-shrink: 0; + color: var(--su-color-primary); +} + .directory-icon { flex-shrink: 0; color: var(--su-text-muted); @@ -956,28 +960,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; } @@ -1197,10 +1179,145 @@ height: 50vh; flex: none; } +} - .pagination-bar { - align-items: flex-start; - flex-direction: column; +/* ---------- uv 配置弹窗 ---------- */ + +.uv-settings-modal { + width: min(720px, calc(100vw - 32px)); + max-width: 720px; +} + +.uv-settings { + display: flex; + flex-direction: column; +} + +/* 标签页:三段配置共用一块面板,内容自己撑高度 */ +.uv-tabs { + margin-bottom: 4px; +} + +.uv-panel-head { + display: flex; + align-items: center; + justify-content: space-between; + gap: 12px; +} + +.uv-section { + display: flex; + flex-direction: column; + gap: 12px; +} + +/* 来源那一行:路径输入框 + 右侧动作(下载 / 浏览) */ +.uv-path-row { + display: grid; + grid-template-columns: minmax(0, 1fr) auto; + align-items: center; + gap: 8px; +} + +/* 程序目录的 uv:固定位置,只读展示,不做成输入框 */ +.uv-path-static { + display: flex; + height: var(--su-control-height-md); + align-items: center; + gap: 8px; + overflow: hidden; + border: 1px dashed var(--su-border-strong); + border-radius: var(--su-radius-sm); + padding: 0 10px; + background-color: var(--su-bg-subtle); + color: var(--su-text-sub); + font-family: var(--su-font-mono); + font-size: var(--su-font-size-sm); +} + +.uv-path-static-text { + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.uv-mirror-hint code { + font-family: var(--su-font-mono); + color: var(--su-text-sub); +} + +/* 镜像源弹窗:PyPI 表格列多,比默认 lg 宽一点 */ +.mirror-settings-modal { + width: min(760px, calc(100vw - 32px)); + max-width: 760px; +} + +/* 固定的路径变量:不可编辑、不可删除,样式上做出区别 */ +.uv-env-locked { + display: flex; + height: var(--su-control-height-md); + align-items: center; + gap: 8px; + overflow: hidden; + border: 1px dashed var(--su-border-strong); + border-radius: var(--su-radius-sm); + padding: 0 10px; + background-color: var(--su-bg-subtle); + color: var(--su-text-sub); + font-family: var(--su-font-mono); + font-size: var(--su-font-size-sm); +} + +.uv-env-locked-name { + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.uv-version-input { + width: 160px; +} + +.uv-inline-state { + display: flex; + align-items: center; + gap: 8px; + padding: 8px 0; +} + +.uv-empty { + min-height: 0; + padding: 8px 0; +} + +.uv-python-list { + display: flex; + flex-direction: column; + gap: 8px; +} + +.uv-python-item { + display: grid; + grid-template-columns: 96px minmax(0, 1fr); + align-items: center; + gap: 12px; +} + +.uv-python-version { + font-variant-numeric: tabular-nums; +} + +.uv-python-path { + display: block; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +@media (max-width: 640px) { + .uv-python-item { + grid-template-columns: minmax(0, 1fr); + gap: 4px; } } @@ -1231,37 +1348,53 @@ padding: 12px 16px; } -/* ---------- 顶部导航(三栏工作台) ---------- */ +/* 虚拟环境管理弹窗:标题 + 说明在左,操作在右 */ +.env-manager-head { + display: flex; + align-items: flex-start; + justify-content: space-between; + gap: 12px; + flex-wrap: wrap; +} + +.env-manager-head > :first-child { + min-width: 0; + flex: 1; +} + +.env-manager-empty { + min-height: 0; + align-items: center; + justify-content: center; + padding: 40px 24px; + text-align: center; +} + +/* ---------- 顶部导航 ---------- */ +/* 整条由 Layout 的 header 承担(实底 + 下边线),这里只管内部:左品牌、右图标 */ .app-header { display: flex; align-items: center; justify-content: space-between; - gap: 16px; + gap: 24px; width: 100%; - padding: 0 8px; + max-width: 1320px; + height: 100%; + margin: 0 auto; + padding: 0 16px; } -.app-header-brand { +.app-header-actions { display: flex; + flex: none; align-items: center; - gap: 12px; - min-width: 0; + gap: 4px; } -.app-header-nav { - display: flex; - align-items: center; - gap: 6px; -} - -.app-header-status { - margin-left: auto; -} - -/* ---------- 三栏工作台 ---------- */ +/* ---------- 三栏工作台:左右各 1 份、中间 2 份(参考站 3/6/3) ---------- */ .dashboard { display: grid; - grid-template-columns: 280px minmax(0, 1fr) 320px; + grid-template-columns: minmax(0, 1fr) minmax(0, 2fr) minmax(0, 1fr); gap: 16px; align-items: start; padding: 16px 16px 40px; @@ -1287,27 +1420,26 @@ .dash-metrics { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); - gap: 8px; - margin-top: 10px; + gap: 12px 16px; + margin-top: 12px; } +/* 概览指标:标签与数字在各自格子里居中 */ .dash-metric { display: flex; + min-width: 0; flex-direction: column; + align-items: center; gap: 2px; - padding: 10px; - border: 1px solid var(--su-border); - border-radius: 8px; - background-color: var(--su-bg-subtle); + text-align: center; } .dash-metric-value { + font-family: var(--font-display); font-size: 22px; - font-weight: 600; -} - -.dash-metric-danger { - color: var(--su-color-danger); + font-weight: var(--su-font-weight-bold); + font-variant-numeric: tabular-nums; + line-height: 1.2; } .dash-list { @@ -1345,57 +1477,185 @@ white-space: nowrap; } -.task-grid { - display: grid; - grid-template-columns: repeat(2, minmax(0, 1fr)); - gap: 10px; - margin-top: 12px; -} - -.task-item { +/* 首页「镜像源」卡片:默认镜像 + 运行时注入的环境变量 */ +.mirror-card-summary { display: flex; + min-width: 0; flex-direction: column; - gap: 8px; - padding: 10px; - border: 1px solid var(--su-border); - border-radius: 8px; - background-color: var(--su-bg-subtle); - min-width: 0; + gap: 4px; + margin-top: 10px; } -.task-item-main { +.mirror-card-url { + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.mirror-card-inject { + margin-top: 8px; +} + +/* 同一行放两组键值:uv 卡片里的「版本 + Python 解释器」 */ +.dash-inline-kv { display: flex; - align-items: flex-start; - gap: 10px; + align-items: baseline; + gap: 16px; + margin-top: 10px; + min-width: 0; + flex-wrap: wrap; +} + +.dash-inline-item { + display: inline-flex; + min-width: 0; + align-items: baseline; + gap: 6px; +} + +.dash-inline-item > * { + min-width: 0; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +/* 最近触发:任务名在左、触发时间在右,名字过长时省略 */ +.dash-inline { + display: flex; + align-items: baseline; + justify-content: space-between; + gap: 12px; + margin-top: 8px; min-width: 0; } -.task-item-mark { - display: grid; - place-items: center; +.dash-inline-name { + min-width: 0; +} + +.dash-inline-time { flex: none; - width: 32px; - height: 32px; - border: 1px solid var(--su-border); - border-radius: 8px; - background-color: var(--su-bg-card); - font-weight: 700; + font-variant-numeric: tabular-nums; + white-space: nowrap; } -.task-item-text { +.dash-inline-empty { + display: block; + margin-top: 8px; +} + +/* 每个任务一张卡片:标题行(任务名 + 类型 + 活跃状态 + 右上角详情)、内容行(说明 · Cron/下次 · 操作) */ +.task-card-head { display: flex; + align-items: center; + gap: 8px; + margin-bottom: 6px; +} + +/* 标题只占自身文字宽度:类型与活跃中标签紧跟任务名,详情按钮始终贴右 */ +.task-card-title { + flex: 0 1 auto; + min-width: 0; +} + +.task-card-detail { + margin-left: auto; +} + +/* 标题旁的状态标签:整体比默认标签小约三分之一(高 16、字 11、圆点 4) */ +.task-card-head .task-card-status-badge { + gap: 3px; + height: 16px; + padding: 0 6px; + font-size: 11px; + line-height: 1; +} + +.task-card-head .task-card-status-badge > span { + width: 4px; + height: 4px; +} + +.task-card { + display: flex; + flex-wrap: wrap; + align-items: flex-start; + justify-content: space-between; + gap: 8px 16px; +} + +.task-card-main { + display: flex; + flex: 1 1 160px; min-width: 0; flex-direction: column; gap: 2px; } -.task-item-actions { +/* 右侧调度块:Cron 与下次运行在左,操作按钮贴右,整块从更靠左的位置起排 */ +.task-card-plan { + display: flex; + flex: 0 0 auto; + min-width: 136px; + flex-direction: column; + align-items: flex-start; + gap: 3px; +} + +.task-card-next { + display: inline-flex; + align-items: center; + border: 1px solid var(--su-border); + border-radius: var(--su-radius-sm); + padding: 2px 8px; + color: var(--su-text-sub); + font-size: 12px; + font-variant-numeric: tabular-nums; + line-height: 1.2; + white-space: nowrap; +} + +/* 操作按钮贴卡片内容区的底边,和「下次运行」那一行齐平 */ +.task-card-side { + display: flex; + flex: none; + align-self: flex-end; + flex-direction: column; + align-items: flex-end; + gap: 4px; +} + +.task-card-actions { justify-content: flex-end; } +/* 任务详情弹窗:只读的键值列表 */ +.detail-grid { + display: flex; + flex-direction: column; + gap: 10px; +} + +.detail-row { + display: grid; + grid-template-columns: 88px minmax(0, 1fr); + gap: 12px; + align-items: start; +} + +.detail-value { + min-width: 0; + color: var(--su-text-main); + font-size: var(--su-font-size-sm); + line-height: var(--su-line-height-sm); + overflow-wrap: anywhere; + white-space: pre-line; +} + @media (max-width: 1200px) { .dashboard { - grid-template-columns: 240px minmax(0, 1fr); + grid-template-columns: minmax(0, 1fr) minmax(0, 2fr); } .dashboard-right { @@ -1414,108 +1674,77 @@ grid-template-columns: minmax(0, 1fr); } - .task-grid { - grid-template-columns: minmax(0, 1fr); - } - .app-header { flex-wrap: wrap; } } -/* ---------- 左栏用户卡片 ---------- */ -.user-card { +/* ---------- 左栏用户卡片:居中的资料卡 ---------- */ +.user-profile { + display: flex; + flex-direction: column; + align-items: center; + gap: 10px; + padding: 6px 4px 2px; + text-align: center; +} + +/* Avatar 的尺寸走组件自己的局部变量(见 Avatar.module.css),这里只放大一档 */ +.user-profile-avatar { + --avatar-size: 84px; + --avatar-badge: 22px; + + width: var(--avatar-size); + height: var(--avatar-size); + font-size: 28px; +} + +.user-profile-name-row { display: flex; align-items: center; - gap: 12px; + gap: 6px; + margin-top: 2px; } -.user-avatar { - position: relative; - flex: none; -} - -.user-online { - position: absolute; - right: 0; - bottom: 0; - width: 10px; - height: 10px; - border: 2px solid var(--su-bg-card); - border-radius: 50%; - background-color: #22c55e; -} - -.user-meta { - display: flex; - min-width: 0; - flex-direction: column; - gap: 2px; -} - -.user-dot { - display: inline-block; +.user-profile-status { width: 6px; height: 6px; - margin-left: 6px; border-radius: 50%; - background-color: #22c55e; + background-color: var(--su-color-success); } -.user-bio { - margin: 12px 0 0; - padding-top: 10px; - border-top: 1px solid var(--su-border); +/* 参考稿是描边胶囊,Tag 默认是浅底,这里补一圈边框 */ +.user-profile-role { + border: 1px solid var(--su-border-strong); + background-color: transparent; + color: var(--su-text-secondary); +} + +.user-profile-bio { + display: -webkit-box; + -webkit-line-clamp: 3; + -webkit-box-orient: vertical; + overflow: hidden; color: var(--su-text-muted); - font-size: 12px; - line-height: 1.6; + font-size: 13px; + line-height: 1.7; } -/* 弹窗里的多个区块 */ -.modal-stack { - display: flex; - flex-direction: column; - gap: 16px; -} - -/* uv 弹窗:环境变量左标签 + 底部操作 */ -/* 弹窗表单:区块标题 + 选项 chip + 底部操作条 */ -.form-section { - display: flex; - flex-direction: column; - gap: 10px; -} - -.form-section-head { - display: flex; +.user-profile-meta { + display: inline-flex; align-items: center; - justify-content: space-between; - gap: 12px; + gap: 6px; + margin-top: 2px; } -.form-section-icon { - color: var(--su-text-muted); +.user-profile-engine-dot { + width: 6px; + height: 6px; + border-radius: 50%; + background-color: var(--su-color-success); } -.chip-row { - display: flex; - flex-wrap: wrap; - gap: 8px; +.user-profile-engine-dot.is-offline { + background-color: var(--su-color-danger); } -.form-actions { - display: flex; - align-items: center; - gap: 8px; - padding-top: 4px; -} - -.form-actions > button:last-child { - flex: 1; -} - -@media (max-width: 720px) { - .form-section-head { - flex-wrap: wrap; - } -} \ No newline at end of file diff --git a/frontend/src/App.test.tsx b/frontend/src/App.test.tsx index a4d6974..4891809 100644 --- a/frontend/src/App.test.tsx +++ b/frontend/src/App.test.tsx @@ -9,15 +9,11 @@ import App from './App' const task: Task = { id: 'echo_task', name: 'Echo task', + description: 'Echo the configured message', cron: '0 0 2 * * *', timezone: 'Asia/Shanghai', enabled: true, timeout_secs: 300, - concurrency_policy: 'skip', - retry_policy: { - max_attempts: 0, - delay_secs: 0, - }, action: { type: 'script', working_dir: 'D:\\Projects\\echo', @@ -53,17 +49,32 @@ interface FetchMockOptions { }> envPackages?: Record> pythons?: Array<{ version: string; path: string }> + /** 模拟「程序目录 uv 已安装」时的版本号,例如 `uv 0.9.0` */ + uvVersion?: string } function uvSettingsPayload( source: string, customPath: string | null, managedVersion: string | null = null, + mirrorUrl: string | null = null, ) { + const mirrorBase = mirrorUrl ?? 'https://mirrors.ustc.edu.cn/github-release' return { source, custom_path: customPath, + extra_env: {}, + env_defaults: { + UV_CACHE_DIR: 'uv-cache', + UV_PYTHON_INSTALL_DIR: 'python', + UV_PYTHON_INSTALL_MIRROR: `${mirrorBase}/astral-sh/python-build-standalone/releases/download`, + }, managed_dir: 'D:\\TaskPilot\\uv', + python_dir: 'D:\\TaskPilot\\python', + cache_dir: 'D:\\TaskPilot\\uv-cache', + mirror_url: mirrorBase, + uv_download_url: `${mirrorBase}/astral-sh/uv/LatestRelease`, + python_mirror_url: `${mirrorBase}/astral-sh/python-build-standalone/releases/download`, executable: source === 'managed' ? 'D:\\TaskPilot\\uv\\uv.exe' @@ -93,7 +104,8 @@ function uvSettingsPayload( function createFetchMock(options: FetchMockOptions = {}) { let uvSource = 'managed' let uvCustomPath: string | null = null - let uvManagedVersion: string | null = null + let uvManagedVersion: string | null = options.uvVersion ?? null + let uvMirror: string | null = null let managedEnvs = options.envs ?? [] let managedPythons = options.pythons ?? [] const managedPackages = options.envPackages ?? {} @@ -192,21 +204,37 @@ function createFetchMock(options: FetchMockOptions = {}) { if (url.endsWith('/api/v1/uv/download')) { uvSource = 'managed' uvManagedVersion = 'uv 0.9.0' - return jsonResponse(uvSettingsPayload(uvSource, uvCustomPath, uvManagedVersion)) + return jsonResponse( + uvSettingsPayload( + uvSource, + uvCustomPath, + uvManagedVersion, + uvMirror, + ), + ) } if (url.endsWith('/api/v1/uv')) { const request = init?.body ? (JSON.parse(String(init.body)) as { source: string custom_path?: string | null + mirror_url?: string | null }) : null if (request) { uvSource = request.source uvCustomPath = request.custom_path ?? null + if (request.mirror_url !== undefined) { + uvMirror = request.mirror_url + } } return jsonResponse( - uvSettingsPayload(uvSource, uvCustomPath, uvManagedVersion), + uvSettingsPayload( + uvSource, + uvCustomPath, + uvManagedVersion, + uvMirror, + ), ) } if (url.endsWith('/api/v1/cron/preview')) { @@ -310,53 +338,91 @@ describe('TaskDashboard', () => { vi.stubGlobal('fetch', createFetchMock()) renderApp() - expect(await screen.findByText('Echo task')).toBeInTheDocument() - expect(screen.getByText('echo_task')).toBeInTheDocument() + // 任务名会同时出现在卡片和左下角「最近触发」里 + expect((await screen.findAllByText('Echo task')).length).toBeGreaterThan(0) + expect( + screen.getByText('Echo the configured message'), + ).toBeInTheDocument() expect(screen.getByText('0 0 2 * * *')).toBeInTheDocument() - expect(await screen.findByText('在线')).toBeInTheDocument() + // 顶部品牌栏和用户卡片都会显示引擎状态 + expect((await screen.findAllByText('引擎在线')).length).toBeGreaterThan(0) }) - it('navigates to the python settings page from the sidebar', async () => { + it('opens the uv settings dialog from the right column', async () => { vi.stubGlobal('fetch', createFetchMock()) const user = userEvent.setup() renderApp() await user.click( - await screen.findByRole('button', { name: 'Python 环境' }), + await screen.findByRole('button', { name: '配置 uv' }), ) expect( - await screen.findByRole('heading', { name: 'Python 环境' }), - ).toBeInTheDocument() - // 镜像和 uv 在同一页 - expect( - await screen.findByRole('row', { name: /清华 TUNA/ }), - ).toBeInTheDocument() - expect( - await screen.findByText('uv 可执行文件'), + await screen.findByRole('heading', { name: 'uv 配置' }), ).toBeInTheDocument() + // 三段配置改用标签页切换:可执行文件 / 环境变量 / Python 解释器 + expect(screen.getByRole('tab', { name: 'uv 可执行文件' })).toBeInTheDocument() + expect(screen.getByRole('tab', { name: /环境变量/ })).toBeInTheDocument() + expect(screen.getByRole('tab', { name: /Python 解释器/ })).toBeInTheDocument() + // 顶部不再重复首页已有的版本 / 数量 / 镜像地址 + expect(screen.queryByText(/uv 下载/)).toBeNull() }) - it('saves the uv executable and env vars picked on the python page', async () => { + it('saves the uv download mirror from the uv dialog', async () => { const fetchMock = createFetchMock() vi.stubGlobal('fetch', fetchMock) const user = userEvent.setup() renderApp() - await user.click(await screen.findByRole('button', { name: 'Python 环境' })) - expect(await screen.findByText('未检测到可用的 uv')).toBeInTheDocument() + await user.click(await screen.findByRole('button', { name: '配置 uv' })) + const input = await screen.findByLabelText('下载镜像') + await user.clear(input) + await user.type(input, 'https://mirror.example/github-release') + await user.click(screen.getByRole('button', { name: '保存设置' })) - await user.click( - screen.getByRole('radio', { name: '指定自己下载的 uv 路径' }), + await waitFor(() => { + expect( + fetchMock.mock.calls.some( + ([request, init]) => + String(request).endsWith('/api/v1/uv') && init?.method === 'PUT', + ), + ).toBe(true) + }) + const saved = fetchMock.mock.calls.find( + ([request, init]) => + String(request).endsWith('/api/v1/uv') && init?.method === 'PUT', ) + expect(JSON.parse(String(saved?.[1]?.body)).mirror_url).toBe( + 'https://mirror.example/github-release', + ) + }) + + it('saves the uv executable and env vars from the uv dialog', async () => { + const fetchMock = createFetchMock() + vi.stubGlobal('fetch', fetchMock) + const user = userEvent.setup() + renderApp() + + await user.click(await screen.findByRole('button', { name: '配置 uv' })) + expect( + await screen.findByRole('button', { name: '下载 uv' }), + ).toBeInTheDocument() + await user.click(screen.getByRole('radio', { name: '指定路径' })) await user.type( screen.getByLabelText('uv 可执行文件'), 'D:\\tools\\uv.exe', ) + // 环境变量在第二个标签页里 + await user.click(screen.getByRole('tab', { name: /环境变量/ })) + // 三个关键变量(两个路径 + 解释器镜像)固定在列表里,不能删除 + const uvDialog = await screen.findByRole('dialog', { name: 'uv 配置' }) + expect( + within(uvDialog).queryByRole('button', { name: '删除' }), + ).toBeNull() await user.click(screen.getByRole('button', { name: '+ 添加变量' })) - await user.type(screen.getByLabelText('环境变量名 1'), 'UV_CACHE_DIR') - await user.type(screen.getByLabelText('环境变量值 1'), 'D:\\uv-cache') - await user.click(screen.getByRole('button', { name: '保存' })) + await user.type(screen.getByLabelText('环境变量名 4'), 'UV_HTTP_TIMEOUT') + await user.type(screen.getByLabelText('环境变量值 4'), '60') + await user.click(screen.getByRole('button', { name: '保存设置' })) await waitFor(() => { expect( @@ -373,17 +439,63 @@ describe('TaskDashboard', () => { expect(JSON.parse(String(saved?.[1]?.body))).toEqual({ source: 'custom', custom_path: 'D:\\tools\\uv.exe', - extra_env: { UV_CACHE_DIR: 'D:\\uv-cache' }, + extra_env: { UV_HTTP_TIMEOUT: '60' }, + mirror_url: 'https://mirrors.ustc.edu.cn/github-release', }) }) + it('picks the uv executable through the file browser', async () => { + vi.stubGlobal( + 'fetch', + createFetchMock({ + directories: (path) => + jsonResponse( + path + ? { + path, + parent: 'C:\\', + entries: [ + { + name: 'uv.exe', + path: `${path}${path.endsWith('\\') ? '' : '\\'}uv.exe`, + is_dir: false, + }, + ], + truncated: false, + } + : { + path: null, + parent: null, + entries: [{ name: 'C:\\', path: 'C:\\', is_dir: true }], + truncated: false, + }, + ), + }), + ) + const user = userEvent.setup() + renderApp() + + await user.click(await screen.findByRole('button', { name: '配置 uv' })) + await user.click(screen.getByRole('radio', { name: '指定路径' })) + await user.click(screen.getByRole('button', { name: '浏览' })) + + expect( + await screen.findByRole('heading', { name: '选择 uv 可执行文件' }), + ).toBeInTheDocument() + await user.click(await screen.findByRole('button', { name: 'C:\\' })) + await user.click(await screen.findByRole('button', { name: 'uv.exe' })) + await user.click(screen.getByRole('button', { name: '使用此文件' })) + + expect(screen.getByLabelText('uv 可执行文件')).toHaveValue('C:\\uv.exe') + }) + it('downloads uv from the mirror and switches to it', async () => { const fetchMock = createFetchMock() vi.stubGlobal('fetch', fetchMock) const user = userEvent.setup() renderApp() - await user.click(await screen.findByRole('button', { name: 'Python 环境' })) + await user.click(await screen.findByRole('button', { name: '配置 uv' })) await user.click(await screen.findByRole('button', { name: '下载 uv' })) await waitFor(() => { @@ -395,16 +507,17 @@ describe('TaskDashboard', () => { ), ).toBe(true) }) - expect((await screen.findAllByText('uv 0.9.0')).length).toBeGreaterThan(0) + expect((await screen.findAllByText('0.9.0')).length).toBeGreaterThan(0) }) - it('downloads a python version with uv', async () => { - const fetchMock = createFetchMock() + it('downloads a python version from the uv dialog', async () => { + const fetchMock = createFetchMock({ uvVersion: 'uv 0.9.0' }) vi.stubGlobal('fetch', fetchMock) const user = userEvent.setup() renderApp() - await user.click(await screen.findByRole('button', { name: '虚拟环境' })) + await user.click(await screen.findByRole('button', { name: '配置 uv' })) + await user.click(screen.getByRole('tab', { name: /Python 解释器/ })) await user.type(screen.getByLabelText('要下载的 Python 版本'), '3.12') await user.click(screen.getByRole('button', { name: '下载 Python' })) @@ -440,8 +553,13 @@ describe('TaskDashboard', () => { const user = userEvent.setup() renderApp() - await user.click(await screen.findByRole('button', { name: '虚拟环境' })) - expect(await screen.findByText('还没有预创建的环境')).toBeInTheDocument() + await user.click(await screen.findByRole('button', { name: '管理' })) + const envDialog = await screen.findByRole('dialog', { + name: '虚拟环境管理', + }) + expect( + await within(envDialog).findByText(/还没有预创建的环境/), + ).toBeInTheDocument() await user.click(screen.getByRole('button', { name: '+ 新建环境' })) await user.type(screen.getByLabelText('环境名'), 'data-science') @@ -493,7 +611,7 @@ describe('TaskDashboard', () => { const user = userEvent.setup() renderApp() - await user.click(await screen.findByRole('button', { name: '虚拟环境' })) + await user.click(await screen.findByRole('button', { name: '管理' })) await user.click(await screen.findByRole('button', { name: '查看包' })) expect(await screen.findByText('numpy')).toBeInTheDocument() @@ -533,11 +651,29 @@ describe('TaskDashboard', () => { }) }) + it('opens the task details from the card', async () => { + vi.stubGlobal('fetch', createFetchMock()) + const user = userEvent.setup() + renderApp() + + await user.click(await screen.findByRole('button', { name: '详情' })) + + const dialog = await screen.findByRole('dialog', { name: '任务详情' }) + expect(within(dialog).getByText('echo_task')).toBeInTheDocument() + expect( + within(dialog).getByText('Echo the configured message'), + ).toBeInTheDocument() + expect(within(dialog).getByText('D:\\Projects\\echo')).toBeInTheDocument() + }) + it('shows the next run time reported by the schedule endpoint', async () => { vi.stubGlobal('fetch', createFetchMock()) renderApp() - expect(await screen.findByText(/下次/)).toBeInTheDocument() + // 卡片里直接显示排好的触发时间(MM/DD HH:mm) + expect( + (await screen.findAllByText(/^\d{2}\/\d{2} \d{2}:\d{2}$/)).length, + ).toBeGreaterThan(0) }) it('creates a script task with the visible form values', async () => { diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index d3d99b7..84e5ac9 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -24,6 +24,7 @@ function App() { } + headerVariant="solid" maxWidth="full" contentPadding="none" > diff --git a/frontend/src/api/client.ts b/frontend/src/api/client.ts index 354ba2a..5ecb9a8 100644 --- a/frontend/src/api/client.ts +++ b/frontend/src/api/client.ts @@ -311,13 +311,18 @@ export function createPythonVenv( /** * 列出目录下的子文件夹;不传 path 时返回盘符列表。 * 用于页面内的目录浏览器,选中后把绝对路径回填到表单。 + * `includeFiles` 为 true 时一并返回文件(挑选 uv.exe 这类可执行文件时用)。 */ export function listDirectories( path?: string, + options: { includeFiles?: boolean } = {}, signal?: AbortSignal, ): Promise { return requestJson( - withQuery('/api/v1/system/directories', { path }), + withQuery('/api/v1/system/directories', { + path, + include_files: options.includeFiles ? 'true' : undefined, + }), { signal }, ) } diff --git a/frontend/src/api/generated.ts b/frontend/src/api/generated.ts index 284cbb7..2c879e5 100644 --- a/frontend/src/api/generated.ts +++ b/frontend/src/api/generated.ts @@ -456,8 +456,6 @@ export interface paths { export type webhooks = Record; export interface components { schemas: { - /** @enum {string} */ - ConcurrencyPolicy: "skip"; CreateManagedEnvRequest: { /** @description 目录名,`[A-Za-z0-9._-]`,也是任务里引用它的标识 */ name: string; @@ -478,12 +476,12 @@ export interface components { }; CreateTaskRequest: { action: components["schemas"]["TaskAction"]; - concurrency_policy?: components["schemas"]["ConcurrencyPolicy"]; cron: string; + /** @description 一句话说明,展示在任务卡片和详情里 */ + description?: string; enabled?: boolean; id: string; name: string; - retry_policy?: components["schemas"]["RetryPolicy"]; /** Format: int64 */ timeout_secs?: number; timezone?: string; @@ -498,6 +496,8 @@ export interface components { timezone: string; }; DirectoryEntry: { + /** @description true 表示目录(可继续进入),false 表示文件(`include_files` 时才出现) */ + is_dir: boolean; /** @description 用于展示的名称 */ name: string; /** @description 绝对路径,选中后回填到表单 */ @@ -574,16 +574,10 @@ export interface components { /** Format: date-time */ updated_at: string; }; - RetryPolicy: { - /** Format: int64 */ - delay_secs?: number; - /** Format: int32 */ - max_attempts?: number; - }; /** @enum {string} */ RunStatus: "queued" | "running" | "succeeded" | "failed" | "timed_out" | "cancelled" | "skipped"; /** @enum {string} */ - RunTrigger: "schedule" | "manual" | "retry"; + RunTrigger: "schedule" | "manual"; /** @description 已注册调度的任务及其下次触发时间。 */ ScheduledTaskResponse: { /** @@ -644,14 +638,14 @@ export interface components { }; TaskResponse: { action: components["schemas"]["TaskAction"]; - concurrency_policy: components["schemas"]["ConcurrencyPolicy"]; /** Format: date-time */ created_at: string; cron: string; + /** @description 一句话说明 */ + description: string; enabled: boolean; id: string; name: string; - retry_policy: components["schemas"]["RetryPolicy"]; /** Format: int64 */ timeout_secs: number; timezone: string; @@ -726,11 +720,11 @@ export interface components { }; UpdateTaskRequest: { action: components["schemas"]["TaskAction"]; - concurrency_policy?: components["schemas"]["ConcurrencyPolicy"]; cron: string; + /** @description 一句话说明,展示在任务卡片和详情里 */ + description?: string; enabled?: boolean; name: string; - retry_policy?: components["schemas"]["RetryPolicy"]; /** Format: int64 */ timeout_secs?: number; timezone?: string; @@ -741,6 +735,8 @@ export interface components { extra_env?: { [key: string]: string; }; + /** @description uv 本体与解释器的下载镜像根(GitHub Release 镜像);空 = 内置默认 */ + mirror_url?: string | null; source: components["schemas"]["UvSource"]; }; UvCandidate: { @@ -767,9 +763,15 @@ export interface components { }; /** @description 程序目录下的 uv 目录 */ managed_dir: string; + /** @description 当前使用的下载镜像根(GitHub Release 镜像);空值时返回内置默认 */ + mirror_url: string; /** @description 实际生效的 Python 存放目录(绝对路径,相对值按程序目录解析) */ python_dir: string; + /** @description Python 解释器的下载地址前缀(注入 `UV_PYTHON_INSTALL_MIRROR`) */ + python_mirror_url: string; source: components["schemas"]["UvSource"]; + /** @description uv 自身压缩包的下载地址前缀 */ + uv_download_url: string; version?: string | null; }; /** @@ -1553,6 +1555,8 @@ export interface operations { query?: { /** @description 绝对目录路径;留空表示列出盘符 */ path?: string; + /** @description 是否一并返回文件(挑选可执行文件时用);默认只返回目录 */ + include_files?: boolean; }; header?: never; path?: never; @@ -1560,7 +1564,7 @@ export interface operations { }; requestBody?: never; responses: { - /** @description Subdirectories of the requested path, or the drive list */ + /** @description Subdirectories of the requested path (plus files when `include_files=true`), or the drive list */ 200: { headers: { [name: string]: unknown; diff --git a/frontend/src/components/AppHeader.tsx b/frontend/src/components/AppHeader.tsx index 25da5f7..3451aa6 100644 --- a/frontend/src/components/AppHeader.tsx +++ b/frontend/src/components/AppHeader.tsx @@ -1,45 +1,60 @@ -import { useQuery } from '@tanstack/react-query' -import { Brand, Space, Tag, Text } from '@stash/ui' -import { fetchHealth } from '../api/client' +import { useIsFetching, useQueryClient } from '@tanstack/react-query' +import { Brand, Icon, IconButton, toast } from '@stash/ui' +import { useEffect, useState } from 'react' + +type Scheme = 'light' | 'dark' + +const THEME_STORAGE_KEY = 'taskpilot.theme' + +function readScheme(): Scheme { + const saved = window.localStorage.getItem(THEME_STORAGE_KEY) + return saved === 'dark' ? 'dark' : 'light' +} /** - * 顶部导航:品牌 + 引擎状态。 + * 顶部栏:左边品牌、右边操作图标。 * - * 配置入口都收进任务调度页的三栏卡片里(uv / 镜像源 / 虚拟环境都用弹窗), - * 所以这里不再放页面级导航。 + * 引擎状态、任务数量这些在左栏的用户卡片和「概览」里已经看得到,页头不再重复。 + * 明暗切换只写本机偏好(``),刷新会重拉页面数据; + * 「配置 / 退出」暂时没有对应入口,点了给个提示,不做假按钮。 */ export function AppHeader() { - const healthQuery = useQuery({ - queryKey: ['health'], - queryFn: ({ signal }) => fetchHealth(signal), - refetchInterval: 30_000, - }) + const queryClient = useQueryClient() + const [scheme, setScheme] = useState(readScheme) + const fetching = useIsFetching() > 0 - const status = healthQuery.isPending - ? { tone: 'neutral' as const, label: '检测中' } - : healthQuery.isError - ? { tone: 'danger' as const, label: '引擎离线' } - : { tone: 'success' as const, label: '引擎在线' } + useEffect(() => { + document.documentElement.dataset.scheme = scheme + window.localStorage.setItem(THEME_STORAGE_KEY, scheme) + }, [scheme]) return (
-
- - - 本地任务调度 · Windows - -
+ - - - {status.label} - - - {healthQuery.data?.version - ? `Rust 引擎 v${healthQuery.data.version}` - : '127.0.0.1:3000'} - - +
+ } + onClick={() => setScheme(scheme === 'dark' ? 'light' : 'dark')} + /> + } + spinning={fetching} + onClick={() => void queryClient.invalidateQueries()} + /> + } + onClick={() => toast('配置入口还在任务卡片里(uv / 镜像源 / 虚拟环境)')} + /> + } + onClick={() => toast('本地单机版本没有登录,可以直接关掉窗口')} + /> +
) } diff --git a/frontend/src/components/DirectoryPickerDialog.tsx b/frontend/src/components/DirectoryPickerDialog.tsx index a227aa3..c9a57d9 100644 --- a/frontend/src/components/DirectoryPickerDialog.tsx +++ b/frontend/src/components/DirectoryPickerDialog.tsx @@ -17,12 +17,25 @@ interface DirectoryPickerDialogProps { initialPath: string onSelect: (path: string) => void onClose: () => void + /** directory:选目录(默认);file:选单个文件 */ + mode?: 'directory' | 'file' + /** file 模式下只显示这些扩展名的文件,例如 ['.exe'];目录始终显示 */ + fileExtensions?: string[] + /** 覆盖弹窗标题 */ + title?: string +} + +function isFilePath(value: string): boolean { + return /[^\\/]+\.[A-Za-z0-9]+$/.test(value.trim()) } /** * 页面内的目录浏览器:从盘符开始逐级进入文件夹,最终把绝对路径回填到表单。 * 浏览器拿不到系统文件对话框的真实路径,因此目录浏览由后端提供列表接口。 * + * `mode="file"` 时同一套浏览逻辑也用来挑单个文件(例如 `uv.exe`): + * 目录可以继续进入,文件点击选中后点「使用此文件」。 + * * 外壳是组件库的 `Modal`,路径输入 / 按钮 / 空态 / 错误提示分别是 * `Input` / `Button` / `Empty` / `Notice`。 */ @@ -31,9 +44,14 @@ export function DirectoryPickerDialog({ initialPath, onSelect, onClose, + mode = 'directory', + fileExtensions, + title, }: DirectoryPickerDialogProps) { + const isFileMode = mode === 'file' const [currentPath, setCurrentPath] = useState(null) const [draftPath, setDraftPath] = useState('') + const [selectedFile, setSelectedFile] = useState(null) useEffect(() => { if (!open) { @@ -42,11 +60,17 @@ export function DirectoryPickerDialog({ const start = initialPath.trim() setCurrentPath(start || null) setDraftPath(start) + setSelectedFile(null) }, [initialPath, open]) const listing = useQuery({ - queryKey: ['directories', currentPath], - queryFn: ({ signal }) => listDirectories(currentPath ?? undefined, signal), + queryKey: ['directories', currentPath, isFileMode], + queryFn: ({ signal }) => + listDirectories( + currentPath ?? undefined, + { includeFiles: isFileMode }, + signal, + ), enabled: open, retry: false, }) @@ -57,20 +81,30 @@ export function DirectoryPickerDialog({ } }, [listing.data?.path]) - const entries = listing.data?.entries ?? [] + const entries = (listing.data?.entries ?? []).filter((entry) => { + // 老接口条目没有 is_dir 时按目录处理,行为与之前一致。 + if (!isFileMode || entry.is_dir !== false || !fileExtensions?.length) { + return true + } + const lower = entry.name.toLowerCase() + return fileExtensions.some((extension) => lower.endsWith(extension)) + }) const currentDirectory = listing.data?.path ?? null const parent = listing.data?.parent ?? null + const typedFile = isFileMode && isFilePath(draftPath) ? draftPath.trim() : null + const chosen = isFileMode ? (selectedFile ?? typedFile) : currentDirectory const navigate = (path: string | null) => { setCurrentPath(path) setDraftPath(path ?? '') + setSelectedFile(null) } return ( - {currentDirectory ?? '尚未选择目录'} + {chosen ?? (isFileMode ? '尚未选择文件' : '尚未选择目录')} } @@ -148,26 +182,49 @@ export function DirectoryPickerDialog({ {!listing.isPending && !listing.isError && entries.length === 0 ? ( ) : null} {entries.length > 0 ? (
    - {entries.map((entry) => ( -
  • - -
  • - ))} + {entries.map((entry) => { + const file = entry.is_dir === false + const selected = file && entry.path === selectedFile + + return ( +
  • + +
  • + ) + })}
) : null} diff --git a/frontend/src/components/PythonEnvsSection.tsx b/frontend/src/components/PythonEnvsSection.tsx index b769c5d..70dd84a 100644 --- a/frontend/src/components/PythonEnvsSection.tsx +++ b/frontend/src/components/PythonEnvsSection.tsx @@ -54,6 +54,8 @@ function linesToPackages(value: string): string[] { * * 环境放在 `data/envs/<名称>`,可以预装常用包;任务里把绝对路径填进 * 「虚拟环境目录」就能复用。列表只读 `pyvenv.cfg`,包列表按需查询。 + * + * 解释器由 uv 下载(入口在「uv 配置」弹窗),这里只负责挑一个版本创建环境。 */ export function PythonEnvsSection() { const queryClient = useQueryClient() @@ -149,15 +151,15 @@ export function PythonEnvsSection() { return ( <> - - +
+ 虚拟环境 - 预创建常用环境;任务里把这里的绝对路径填进「虚拟环境目录」即可复用。 + 用已下载的 Python 解释器创建常用环境;任务里把这里的绝对路径填进「虚拟环境目录」即可复用。 - +
@@ -182,9 +184,8 @@ export function PythonEnvsSection() { ) : envs.length === 0 ? ( setCreateOpen(true)}>新建环境} + className="env-manager-empty" + text="还没有预创建的环境,点右上角「+ 新建环境」创建一个。" /> ) : ( @@ -293,8 +294,8 @@ export function PythonEnvsSection() { label="Python 版本(可选)" hint={ (pythonsQuery.data?.length ?? 0) === 0 - ? '还没有下载 Python;先在上方「Python 版本」里下载,或选默认让 uv 自动下载' - : '列表来自 uv 下载到平台目录的版本;留空由 uv 决定' + ? '还没有下载解释器:先在「uv 配置 → Python 解释器」里下载,或选默认让 uv 自动决定' + : '列表来自 uv 下载到程序目录的版本(在「uv 配置」里下载);留空由 uv 决定' } > setVersion(event.target.value)} - className="mono-input" - placeholder={PYTHON_VERSION_HINT} - aria-label="要下载的 Python 版本" - spellCheck={false} - /> - - {installMutation.isPending ? ( - - 正在下载,可能需要几十秒... - - ) : null} - - - {pythonsQuery.isError ? ( - void pythonsQuery.refetch()} - > - 重试 - - } - /> - ) : pythonsQuery.isPending ? ( -
- -
- ) : pythons.length === 0 ? ( - - ) : ( -
- )} - - - ) -} diff --git a/frontend/src/components/RunLogDrawer.tsx b/frontend/src/components/RunLogDrawer.tsx index 6b01b7e..65d72fc 100644 --- a/frontend/src/components/RunLogDrawer.tsx +++ b/frontend/src/components/RunLogDrawer.tsx @@ -53,7 +53,6 @@ const TRIGGER_FILTERS = [ { value: '', label: '全部触发' }, { value: 'schedule', label: '定时' }, { value: 'manual', label: '手动' }, - { value: 'retry', label: '重试' }, ] /** diff --git a/frontend/src/components/StatusBadge.tsx b/frontend/src/components/StatusBadge.tsx index d0797ac..83e8033 100644 --- a/frontend/src/components/StatusBadge.tsx +++ b/frontend/src/components/StatusBadge.tsx @@ -30,14 +30,20 @@ export function RunStatusBadge({ status }: RunStatusBadgeProps) { interface EnabledBadgeProps { enabled: boolean + className?: string } -export function EnabledBadge({ enabled }: EnabledBadgeProps) { +export function EnabledBadge({ enabled, className }: EnabledBadgeProps) { return ( {enabled ? '活跃中' : '已暂停'} diff --git a/frontend/src/components/TaskDetailModal.tsx b/frontend/src/components/TaskDetailModal.tsx new file mode 100644 index 0000000..e4f9e1b --- /dev/null +++ b/frontend/src/components/TaskDetailModal.tsx @@ -0,0 +1,175 @@ +import { Button, Divider, Modal, Space, Tag, Text } from '@stash/ui' +import type { ReactNode } from 'react' +import type { Task } from '../api/client' +import { + actionTypeLabel, + formatDateTime, + formatShortDateTime, +} from '../lib/format' +import { EnabledBadge } from './StatusBadge' + +interface TaskDetailModalProps { + task: Task | null + nextRunAt?: string + onClose: () => void +} + +function DetailRow({ label, children }: { label: string; children: ReactNode }) { + return ( +
+ + {label} + +
{children}
+
+ ) +} + +function envSummary(env: Record | undefined): string { + const entries = Object.entries(env ?? {}) + if (entries.length === 0) { + return '无' + } + return entries.map(([key, value]) => `${key}=${value}`).join('\n') +} + +/** + * 任务详情:只读展示任务配置,入口是任务卡片上的「详情」。 + * + * 和编辑表单分开:这里不做校验,也不会因为字段缺失而崩,方便核对线上实际配置。 + */ +export function TaskDetailModal({ + task, + nextRunAt, + onClose, +}: TaskDetailModalProps) { + return ( + + 关闭 + + } + > + {task ? ( + + + + + {task.name} + + {actionTypeLabel(task.action.type)} + + + + {task.id} + + {task.description ? ( + + {task.description} + + ) : null} + + + + + + 基本信息 + +
+ + {task.cron} + + {task.timezone} + + {task.enabled + ? nextRunAt + ? formatShortDateTime(nextRunAt) + : '调度中…' + : '未调度'} + + {task.timeout_secs} 秒 +
+ + + + + 执行配置 + +
+ {task.action.type === 'script' ? ( + <> + + {task.action.working_dir} + + {task.action.interpreter} + {task.action.script} + + {task.action.args.length > 0 + ? task.action.args.join(' ') + : '无'} + + + {envSummary(task.action.env)} + + + ) : null} + + {task.action.type === 'http' ? ( + <> + {task.action.url} + {task.action.method} + + {task.action.expected_status.length > 0 + ? task.action.expected_status.join(', ') + : '2xx'} + + + {envSummary(task.action.headers)} + + {task.action.body || '无'} + + ) : null} + + {task.action.type === 'process' ? ( + <> + + {task.action.executable} + + + {task.action.working_dir ?? '继承程序目录'} + + + {task.action.args.length > 0 + ? task.action.args.join(' ') + : '无'} + + + {envSummary(task.action.env)} + + + ) : null} +
+ + + + + 时间 + +
+ + {formatDateTime(task.created_at)} + + + {formatDateTime(task.updated_at)} + +
+
+ ) : null} +
+ ) +} diff --git a/frontend/src/components/TaskEditorModal.tsx b/frontend/src/components/TaskEditorModal.tsx index a41f3f7..ed0ac93 100644 --- a/frontend/src/components/TaskEditorModal.tsx +++ b/frontend/src/components/TaskEditorModal.tsx @@ -462,6 +462,21 @@ export function TaskEditorModal({ + +