feat: implement full-stack TaskPilot scheduler
This commit is contained in:
+26
@@ -0,0 +1,26 @@
|
||||
# Rust
|
||||
/target/
|
||||
/backend/target/
|
||||
|
||||
# Frontend
|
||||
/frontend/node_modules/
|
||||
/frontend/dist/
|
||||
/frontend/coverage/
|
||||
/frontend/playwright-report/
|
||||
/frontend/test-results/
|
||||
/.pnpm-store/
|
||||
|
||||
# Local configuration and runtime data
|
||||
.env
|
||||
.env.*
|
||||
!.env.example
|
||||
/data/
|
||||
/logs/
|
||||
/.e2e-data/
|
||||
/release/
|
||||
|
||||
# Editors and operating system files
|
||||
/.idea/
|
||||
.vscode/
|
||||
.DS_Store
|
||||
Thumbs.db
|
||||
@@ -0,0 +1,464 @@
|
||||
# TaskPilot 开发计划
|
||||
|
||||
> 更新时间:2026-10-18
|
||||
> 目标后端:Rust
|
||||
> 目标前端:React
|
||||
> 首要平台:Windows
|
||||
> 当前阶段:Phase 6 已完成,项目进入维护与迭代
|
||||
|
||||
## 1. 项目目标
|
||||
|
||||
将现有的单文件 HTML 原型升级为一个真实可用的本地定时任务管理平台,满足以下核心场景:
|
||||
|
||||
- 创建、编辑、启用、停用和删除定时任务。
|
||||
- 使用 Cron 表达式按计划自动执行任务,也可以手动立即执行。
|
||||
- 支持脚本、HTTP 请求和 Windows 二进制进程三类任务。
|
||||
- 实时展示任务状态、下次执行时间和每次运行结果。
|
||||
- 查看真实 stdout、stderr、退出码和耗时。
|
||||
- 任务配置和运行历史在重启后仍然保留。
|
||||
- 超时或取消时可靠终止目标进程及其子进程。
|
||||
- React 前端成为真实 API 的使用者,不再用静态数据或 `alert` 模拟成功。
|
||||
|
||||
### 首版范围
|
||||
|
||||
- Windows 单机部署。
|
||||
- 单用户、本地运行,默认只监听 `127.0.0.1`。
|
||||
- 单后端实例。
|
||||
- SQLite 持久化。
|
||||
- 同一任务默认不并发执行,重叠触发时跳过并记录原因。
|
||||
- 日志按运行实例保存,并配置容量和保留期限。
|
||||
|
||||
### 暂不纳入首版
|
||||
|
||||
- 多租户、登录和远程用户管理。
|
||||
- 多机分布式调度。
|
||||
- DAG、任务依赖和分布式队列。
|
||||
- 集群高可用。
|
||||
- 跨平台进程树管理。
|
||||
- 移动端专用应用。
|
||||
|
||||
这些能力在单机版本稳定后再评估,避免首版范围过大。
|
||||
|
||||
## 2. 当前基线
|
||||
|
||||
仓库目前只有 `task_pilot.html` 和文档,没有前后端工程。现有页面具备完整视觉原型,但运行、删除、保存和日志均为演示行为。
|
||||
|
||||
已确认的本地工具链:
|
||||
|
||||
| 工具 | 版本 | 来源 |
|
||||
| --- | --- | --- |
|
||||
| Volta | 2.0.2 | 本地安装 |
|
||||
| Node.js | 24.12.0 | Volta |
|
||||
| npm | 11.6.2 | Volta |
|
||||
| pnpm | 11.20.0 | Volta |
|
||||
| Rust | 1.95.0 | `stable-x86_64-pc-windows-msvc` |
|
||||
| Cargo | 1.95.0 | 本地 Rust 工具链 |
|
||||
|
||||
开发时使用 pnpm,并通过 `package.json` 的 `volta` 字段固定 Node.js 和 pnpm 版本。Rust 使用 `rust-toolchain.toml` 固定 MSVC 工具链。
|
||||
|
||||
## 3. 技术方案
|
||||
|
||||
### 3.1 前端
|
||||
|
||||
- React + TypeScript + Vite。
|
||||
- pnpm 作为包管理器,Volta 固定工具版本。
|
||||
- React Router 管理任务列表和任务详情路由。
|
||||
- TanStack Query 管理服务端状态、缓存、刷新和错误状态。
|
||||
- React Hook Form + Zod 管理表单和前端校验。
|
||||
- 原生 `fetch` 封装类型化 API 客户端。
|
||||
- 沿用现有 CSS 变量和视觉语言,组件样式使用 CSS Modules,不先引入大型 UI 框架。
|
||||
- Vitest + React Testing Library 进行单元和组件测试。
|
||||
- Playwright 进行关键业务流程端到端测试。
|
||||
|
||||
### 3.2 后端
|
||||
|
||||
- Rust + Axum + Tokio。
|
||||
- Serde 定义 API 和持久化 DTO。
|
||||
- SQLx + SQLite 负责迁移、查询和事务。
|
||||
- `tracing` + `tracing-subscriber` 输出结构化运行日志。
|
||||
- `reqwest` 执行 HTTP 任务。
|
||||
- Windows Job Object 管理子进程树,实现超时和取消时的可靠终止。
|
||||
- SSE 或轮询先用于运行状态与日志更新,后续再评估 WebSocket。
|
||||
- Cron 引擎在 Phase 1 通过小型 POC 选定,必须支持动态增删任务、秒级 Cron、时区和下次执行时间计算。
|
||||
|
||||
初步候选为 `tokio-cron-scheduler`。进入正式实现前需要验证其维护状态、Cron 语义和动态任务能力;如果不能满足要求,则改用 Cron 解析 crate 与自建 Tokio 调度循环。
|
||||
|
||||
### 3.3 接口契约
|
||||
|
||||
- 后端 DTO 是 API 数据的唯一来源。
|
||||
- 使用 OpenAPI 生成接口描述,并提交稳定快照供前端生成 TypeScript 类型。
|
||||
- 前端不得手写与后端重复的 DTO 类型。
|
||||
- 接口变更时,必须同时更新 OpenAPI 快照、生成类型和契约测试。
|
||||
|
||||
### 3.4 总体结构
|
||||
|
||||
```text
|
||||
React SPA
|
||||
|
|
||||
| REST /api/v1 + SSE
|
||||
v
|
||||
Axum HTTP API
|
||||
|
|
||||
+--> Task Service --> SQLite
|
||||
|
|
||||
+--> Scheduler
|
||||
|
|
||||
+--> Script Executor
|
||||
+--> HTTP Executor
|
||||
+--> Process Executor
|
||||
|
|
||||
+--> Windows Job Object
|
||||
+--> Log Capture
|
||||
```
|
||||
|
||||
计划目录结构:
|
||||
|
||||
```text
|
||||
task_pilot/
|
||||
├── backend/
|
||||
│ ├── Cargo.toml
|
||||
│ ├── migrations/
|
||||
│ ├── src/
|
||||
│ └── tests/
|
||||
├── frontend/
|
||||
│ ├── package.json
|
||||
│ ├── src/
|
||||
│ └── tests/
|
||||
├── contracts/
|
||||
│ └── openapi.json
|
||||
├── docs/
|
||||
│ └── decisions/
|
||||
├── task_pilot.html # 迁移完成前保留作视觉参考
|
||||
├── PLAN.md
|
||||
├── README.md
|
||||
└── rust-toolchain.toml
|
||||
```
|
||||
|
||||
## 4. 核心数据模型
|
||||
|
||||
### 4.1 Task
|
||||
|
||||
```text
|
||||
Task
|
||||
├── id 用户可读的唯一任务标识
|
||||
├── name 显示名称
|
||||
├── cron 5 位或 6 位 Cron 表达式
|
||||
├── timezone 默认使用系统时区,可选择 IANA 时区
|
||||
├── enabled 是否参与自动调度
|
||||
├── timeout_secs 单次执行超时
|
||||
├── concurrency_policy 首版固定为 skip
|
||||
├── retry_policy 首版允许 0 次重试,结构上预留
|
||||
├── action 带 type 标签的任务动作
|
||||
├── created_at
|
||||
└── updated_at
|
||||
```
|
||||
|
||||
### 4.2 Action
|
||||
|
||||
```text
|
||||
Action
|
||||
├── script
|
||||
│ ├── working_dir
|
||||
│ ├── interpreter
|
||||
│ ├── script
|
||||
│ ├── args[]
|
||||
│ └── env{}
|
||||
├── http
|
||||
│ ├── url
|
||||
│ ├── method
|
||||
│ ├── headers{}
|
||||
│ ├── body
|
||||
│ └── expected_status[]
|
||||
└── process
|
||||
├── executable
|
||||
├── working_dir
|
||||
├── args[]
|
||||
└── env{}
|
||||
```
|
||||
|
||||
- 参数必须使用结构化数组,禁止默认经过 Shell 拼接。
|
||||
- 环境变量需要区分普通值和敏感值;敏感值返回前端时使用遮罩。
|
||||
- 路径、URL、超时、Cron 和任务类型必须执行后端校验。
|
||||
|
||||
### 4.3 TaskRun
|
||||
|
||||
```text
|
||||
TaskRun
|
||||
├── id
|
||||
├── task_id
|
||||
├── trigger schedule / manual / retry
|
||||
├── status queued / running / succeeded / failed / timed_out / cancelled / skipped
|
||||
├── started_at
|
||||
├── finished_at
|
||||
├── duration_ms
|
||||
├── exit_code
|
||||
├── error_message
|
||||
└── logs
|
||||
```
|
||||
|
||||
## 5. API 草案
|
||||
|
||||
所有业务接口使用 `/api/v1` 前缀。
|
||||
|
||||
| 方法 | 路径 | 用途 |
|
||||
| --- | --- | --- |
|
||||
| `GET` | `/api/v1/health` | 健康检查 |
|
||||
| `GET` | `/api/v1/tasks` | 查询任务,支持搜索、筛选和分页 |
|
||||
| `POST` | `/api/v1/tasks` | 创建任务 |
|
||||
| `GET` | `/api/v1/tasks/{id}` | 查询任务详情 |
|
||||
| `PUT` | `/api/v1/tasks/{id}` | 更新任务 |
|
||||
| `DELETE` | `/api/v1/tasks/{id}` | 删除任务 |
|
||||
| `POST` | `/api/v1/tasks/{id}/enable` | 启用任务 |
|
||||
| `POST` | `/api/v1/tasks/{id}/disable` | 停用任务 |
|
||||
| `POST` | `/api/v1/tasks/{id}/run` | 手动执行 |
|
||||
| `GET` | `/api/v1/tasks/{id}/runs` | 查询运行历史 |
|
||||
| `GET` | `/api/v1/runs/{run_id}` | 查询单次运行结果 |
|
||||
| `GET` | `/api/v1/runs/{run_id}/logs` | 分页或增量读取日志 |
|
||||
| `GET` | `/api/v1/events` | SSE 状态与日志事件 |
|
||||
|
||||
统一错误结构至少包含:
|
||||
|
||||
```json
|
||||
{
|
||||
"code": "TASK_CRON_INVALID",
|
||||
"message": "Cron expression is invalid",
|
||||
"field": "cron",
|
||||
"details": {}
|
||||
}
|
||||
```
|
||||
|
||||
## 6. 里程碑
|
||||
|
||||
### Phase 0:工程基线
|
||||
|
||||
状态:已完成
|
||||
|
||||
状态:进行中
|
||||
|
||||
- [x] 盘点现有 HTML 原型和功能。
|
||||
- [x] 生成项目 README。
|
||||
- [x] 确认 Volta、Node.js、pnpm 和 Rust 工具链。
|
||||
- [x] 制定本开发计划。
|
||||
- [x] 为 Cron 引擎和静态资源托管方式编写 ADR。
|
||||
|
||||
验收:计划中的技术边界明确,后续任务有清晰顺序。
|
||||
|
||||
### Phase 1:工程脚手架与契约
|
||||
|
||||
状态:已完成(2026-10-18)
|
||||
|
||||
- [x] 创建 `frontend/` React + TypeScript + Vite 工程。
|
||||
- [x] 通过 Volta 固定 Node.js 24.12.0 和 pnpm 11.20.0。
|
||||
- [x] 创建 `backend/` Axum 工程并固定 Rust 1.95.0。
|
||||
- [x] 配置 Vite 开发代理到 Rust API。
|
||||
- [x] 提供 `/api/v1/health`。
|
||||
- [x] 配置 Cargo fmt、clippy、test,以及前端 lint、typecheck、test、build。
|
||||
- [x] 添加 OpenAPI 生成与前端类型生成脚本。
|
||||
- [x] 确定 Cron 引擎依赖并完成 POC。
|
||||
- [x] 保留旧 HTML,作为迁移期间的视觉参考。
|
||||
|
||||
验收:
|
||||
|
||||
- `pnpm install`、`pnpm build`、`cargo test` 和 `cargo clippy` 可执行。
|
||||
- 前端开发服务器能够通过代理访问 Rust 健康检查。
|
||||
- OpenAPI 快照可以重复生成,前后端对接口类型没有手工漂移。
|
||||
|
||||
### Phase 2:任务 CRUD 与持久化
|
||||
|
||||
状态:已完成(2026-10-18)
|
||||
|
||||
- [x] 创建 SQLite 迁移和任务表。
|
||||
- [x] 实现领域模型、仓储层和服务层。
|
||||
- [x] 实现任务列表、详情、创建、更新、删除接口。
|
||||
- [x] 实现 Cron、时区、超时、路径和 Action 类型校验。
|
||||
- [x] 实现统一 API 错误格式。
|
||||
- [x] 增加任务列表搜索、筛选和分页参数。
|
||||
- [x] 为 CRUD、校验和持久化编写后端测试。
|
||||
|
||||
说明:独立启停接口调整到 Phase 3,与调度器注册和取消同时实现,避免产生只改数据库但不更新调度的中间状态。
|
||||
|
||||
验收:
|
||||
|
||||
- 创建三类任务后重启进程,数据仍然存在。
|
||||
- 非法 Cron、空路径、无效 URL 和错误 Action 字段被后端拒绝。
|
||||
- 删除不存在的任务返回明确错误,而不是成功提示。
|
||||
|
||||
### Phase 3:调度器与脚本任务闭环
|
||||
|
||||
状态:已完成(2026-10-18)
|
||||
|
||||
- [x] 启动时从 SQLite 加载启用任务并注册调度。
|
||||
- [x] 支持创建、更新、启停和删除任务时动态刷新调度。
|
||||
- [x] 实现手动立即执行。
|
||||
- [x] 实现脚本任务执行,使用结构化参数,不经过 Shell。
|
||||
- [x] 捕获 stdout、stderr、退出码和耗时。
|
||||
- [x] 实现同一任务并发触发时跳过。
|
||||
- [x] 通过 Windows Job Object 实现超时和取消时的进程树终止。
|
||||
- [x] 记录每次运行状态并处理进程异常退出。
|
||||
- [x] 引入确定性时间计算测试和实时秒级调度测试,验证调度逻辑。
|
||||
|
||||
验收:
|
||||
|
||||
- 创建一个每秒执行的临时脚本任务后,自动运行并生成可查看日志。
|
||||
- 超时脚本及其子进程被终止,运行状态为 `timed_out`。
|
||||
- 后端重启后调度恢复,错过的历史运行不会批量重复执行。
|
||||
- 前端尚未接入前,可通过 API 完整验证脚本任务链路。
|
||||
|
||||
### Phase 4:React 前端迁移与垂直切片
|
||||
|
||||
状态:已完成(2026-10-18)
|
||||
|
||||
- [x] 将现有页面拆分为布局、指标卡、任务表格、任务编辑器和日志查看器。
|
||||
- [x] 接入真实任务列表、详情、创建、更新、删除和启停 API。
|
||||
- [x] 接入手动运行 API,并展示 `queued/running/succeeded/failed` 状态。
|
||||
- [x] 使用表单校验替代浏览器无效的 `required` 假象。
|
||||
- [x] 根据 Action 类型提交完整、不同的载荷。
|
||||
- [x] 实现加载、空数据、失败、重试和保存中状态。
|
||||
- [x] 日志页面先使用轮询读取真实运行日志。
|
||||
- [x] 移除伪造的成功提示和静态任务数据。
|
||||
|
||||
验收:
|
||||
|
||||
- 页面中的每个 CRUD、启停和手动运行操作都对应真实 API。
|
||||
- 刷新页面后任务状态与 SQLite 一致。
|
||||
- HTTP 和进程任务不会在尚未实现执行器时被错误提交为脚本任务。
|
||||
- 表单错误能定位到具体字段,Cron 校验错误来自前后端一致结果。
|
||||
|
||||
### Phase 5:HTTP、进程任务与实时日志
|
||||
|
||||
状态:已完成(2026-10-18)
|
||||
|
||||
- [x] 实现 HTTP Executor,支持 GET/POST、Header、Body 和期望状态码。
|
||||
- [x] 实现二进制进程 Executor。
|
||||
- [x] 将 SSE 接入任务状态和日志增量更新,轮询作为降级方案。
|
||||
- [x] 支持取消正在运行的任务。
|
||||
- [x] 增加运行历史列表、状态/触发筛选、退出码、错误摘要和日志下载。
|
||||
- [x] 实现日志大小上限、保留期限和清理策略。
|
||||
- [x] 对敏感环境变量和 Header 值进行输入遮罩,并在日志中脱敏。
|
||||
|
||||
验收:
|
||||
|
||||
- 三类任务均可从 React 页面创建、执行和查看结果。
|
||||
- 运行中日志可以持续更新,完成后状态正确收敛。
|
||||
- HTTP 非预期状态码和进程非零退出码能明确标记失败。
|
||||
- 取消或超时不会遗留目标进程树。
|
||||
|
||||
### Phase 6:质量、安全与交付
|
||||
|
||||
状态:已完成(2026-10-18)
|
||||
|
||||
- [x] 补齐后端单元、集成和 Windows 平台测试。
|
||||
- [x] 补齐前端组件测试和 Playwright 主流程测试。
|
||||
- [x] 完成键盘导航、焦点管理、ARIA 和窄屏布局。
|
||||
- [x] 移除所有用户输入的 `innerHTML` 拼接,统一安全文本渲染。
|
||||
- [x] 后端默认绑定 `127.0.0.1`,配置严格 CORS 和 Origin 校验。
|
||||
- [x] 增加本地管理令牌或等价的本机接口保护方案。
|
||||
- [x] 检查路径逃逸、命令注入、日志注入和敏感信息泄露。
|
||||
- [x] 由 Axum 托管生产构建后的 React 静态资源。
|
||||
- [x] 提供一键启动脚本、生产构建说明和故障排查文档。
|
||||
- [x] 更新 README,并评审是否将旧 HTML 移入 `legacy/`。
|
||||
|
||||
说明:旧 `task_pilot.html` 暂时保留在根目录作为视觉参考;正式运行入口为 React 构建产物。
|
||||
|
||||
验收:
|
||||
|
||||
- 完整测试、格式检查、静态检查和前端生产构建通过。
|
||||
- 单机 Release 构建能够启动、自动打开或明确提示访问地址。
|
||||
- 关闭程序后,任务数据仍保留;重新启动后启用任务恢复调度。
|
||||
- README 中的命令全部经过实际验证。
|
||||
|
||||
## 7. 开发顺序与检查点
|
||||
|
||||
每个阶段按以下顺序推进:
|
||||
|
||||
1. 先确定数据模型和 API 契约。
|
||||
2. 实现后端能力并补充自动化测试。
|
||||
3. 通过 API 或命令行验证真实行为。
|
||||
4. 接入 React 页面,不先做没有后端支持的假交互。
|
||||
5. 运行完整检查并更新本计划的勾选状态。
|
||||
|
||||
阶段之间保留可运行状态,不进行长期无法启动的大规模重构。旧 HTML 一直保留到 React 版本完成等价迁移。
|
||||
|
||||
## 8. 测试策略
|
||||
|
||||
### 后端
|
||||
|
||||
- 单元测试:Cron 校验、任务校验、状态转换、重试和并发策略。
|
||||
- 集成测试:使用临时 SQLite 验证 CRUD、迁移和重启恢复。
|
||||
- 调度测试:使用可控时钟验证下次执行时间和动态调度。
|
||||
- 平台测试:在 Windows 上验证脚本参数、退出码、超时和进程树终止。
|
||||
- 契约测试:检查 OpenAPI 与实现一致。
|
||||
|
||||
### 前端
|
||||
|
||||
- 组件测试:表单类型切换、错误展示、状态徽章和权限式操作可用性。
|
||||
- API Mock 测试:加载、失败、空数据和重试。
|
||||
- E2E:创建脚本任务、手动执行、查看日志、编辑、停用和删除。
|
||||
|
||||
### 提交前检查
|
||||
|
||||
计划提供以下统一命令,具体脚本在 Phase 1 落地:
|
||||
|
||||
```powershell
|
||||
# 前端
|
||||
pnpm --dir frontend lint
|
||||
pnpm --dir frontend typecheck
|
||||
pnpm --dir frontend test
|
||||
pnpm --dir frontend build
|
||||
|
||||
# 后端
|
||||
cargo fmt --manifest-path backend/Cargo.toml --check
|
||||
cargo clippy --manifest-path backend/Cargo.toml --all-targets -- -D warnings
|
||||
cargo test --manifest-path backend/Cargo.toml
|
||||
```
|
||||
|
||||
## 9. 安全与可靠性约束
|
||||
|
||||
- 后端默认只监听本机地址,不默认暴露到局域网。
|
||||
- 脚本和进程任务使用结构化参数调用,不通过字符串拼接进入 Shell。
|
||||
- 所有路径在执行前规范化并校验允许范围。
|
||||
- 用户提交的文本只通过 React 文本节点渲染,禁止直接拼接 HTML。
|
||||
- 环境变量中的敏感值在列表、详情、日志和错误信息中脱敏。
|
||||
- stdout/stderr 必须并发读取,避免管道写满导致子进程卡死。
|
||||
- 所有运行都有超时和取消路径,终止时覆盖子进程树。
|
||||
- 日志设置单次大小和总容量上限,避免磁盘被无限占用。
|
||||
- 数据库迁移必须可重复执行,并在失败时给出明确错误。
|
||||
- 前端不得把请求失败或未接入能力显示为成功。
|
||||
|
||||
## 10. 主要风险
|
||||
|
||||
| 风险 | 影响 | 应对 |
|
||||
| --- | --- | --- |
|
||||
| Cron crate 语义或动态调度能力不满足要求 | 自动执行时间错误 | Phase 1 POC,覆盖 5/6 位、时区、日和星期语义 |
|
||||
| Windows 子进程树难以可靠终止 | 超时后遗留进程 | 使用 Job Object,并增加真实 Windows 集成测试 |
|
||||
| stdout/stderr 处理不当 | 子进程阻塞或日志丢失 | 异步并发读取,设置背压和日志上限 |
|
||||
| SQLite 与调度器状态不一致 | 重启后漏跑或重复跑 | 事务更新、启动时重建调度、记录运行状态 |
|
||||
| 前端迁移范围过大 | 长期无法交付 | 先完成脚本任务垂直切片,再扩展三类 Action |
|
||||
| 敏感环境变量泄露 | 凭据暴露 | 遮罩、日志脱敏,后续接入系统凭据存储 |
|
||||
| 动态依赖下载受网络影响 | 工程搭建受阻 | 锁定版本和 lockfile,必要时分阶段安装并记录依赖 |
|
||||
| 本地接口被其他网页调用 | 未授权操作 | localhost 绑定、Origin 校验和本机管理令牌 |
|
||||
|
||||
## 11. MVP 完成标准
|
||||
|
||||
满足以下条件后,第一版可以视为完成:
|
||||
|
||||
- React 页面完全使用真实 API 数据。
|
||||
- 任务 CRUD、启停和手动运行形成闭环。
|
||||
- 脚本、HTTP 和二进制进程任务至少各有一条成功路径和一条失败路径验证。
|
||||
- Cron 自动调度、时区和下次运行时间可验证。
|
||||
- 任务配置、运行历史和日志重启后保留。
|
||||
- 超时和取消可以终止 Windows 进程树。
|
||||
- 页面能够展示运行中、成功、失败、超时和跳过状态。
|
||||
- 后端默认仅本机可访问,敏感值不会出现在日志中。
|
||||
- 自动化测试、格式检查和 Release 构建通过。
|
||||
|
||||
## 12. 后续维护
|
||||
|
||||
六个计划阶段已经完成。后续版本优先评估:
|
||||
|
||||
1. 自动重试与退避策略。
|
||||
2. Windows 服务安装和开机自启。
|
||||
3. 系统凭据存储与更严格的秘密管理。
|
||||
4. 任务模板、导入导出和批量操作。
|
||||
5. 运行指标、告警和长期监控。
|
||||
6. HTTP 响应断言、认证方案和请求重放。
|
||||
@@ -0,0 +1,360 @@
|
||||
# TaskPilot
|
||||
|
||||
TaskPilot 是一个面向 Windows 的本地定时任务管理平台。项目目标是用 React 提供任务管理界面,用 Rust 实现 Cron 调度、Windows 进程管理和运行日志。
|
||||
|
||||
当前已完成 Phase 1 至 Phase 6:React 任务管理界面、Rust API、SQLite 持久化、Cron 调度、三类任务执行器、实时日志、安全边界、生产静态托管和 Windows 本地打包均已落地。
|
||||
|
||||
仓库根目录的 `task_pilot.html` 是最初的单文件视觉原型,目前只作为 React 迁移期间的设计参考,不是正式前端入口。
|
||||
|
||||
React 页面沿用该原型作为视觉基线:1040px 主容器、紧凑指标卡、任务表格、660px 编辑弹窗、Cron 预览区和深色日志查看器均在保留真实功能的前提下复刻。
|
||||
|
||||
## 当前能力
|
||||
|
||||
- React 页面可以检测 Rust 后端连接状态。
|
||||
- Vite 开发服务器将 `/api` 代理到 Axum 后端。
|
||||
- Rust 服务提供健康检查和 OpenAPI 文档。
|
||||
- OpenAPI 可以导出为稳定快照,并生成前端 TypeScript 类型。
|
||||
- Cron POC 支持 5/6 位表达式、秒级调度、别名、月份/星期名称和 POSIX 日/星期语义。
|
||||
- 任务可以持久化到 SQLite,并支持创建、查询、更新和删除。
|
||||
- 任务列表支持名称/ID 搜索、启用状态筛选、Action 类型筛选和分页。
|
||||
- 脚本、HTTP 和二进制进程任务具有独立的类型化载荷与后端校验。
|
||||
- 启用的脚本任务由 Tokio 调度循环自动执行,并支持启动后恢复调度。
|
||||
- 后端重启后,已启用任务仍可持久化并重新注册。
|
||||
- 支持手动立即运行,重复触发时记录为 `skipped`,不会并发执行同一任务。
|
||||
- 每次运行记录触发来源、状态、耗时、退出码和错误信息。
|
||||
- 已实现 Windows Job Object,超时或关闭时会终止目标进程树。
|
||||
- stdout 和 stderr 会写入独立运行日志,并可通过 API 增量读取。
|
||||
- HTTP Action 支持 GET/POST、Header、Body、超时和期望状态码。
|
||||
- 二进制进程 Action 使用 Job Object 执行,并支持工作目录、参数和环境变量。
|
||||
- 运行中任务可以通过 API 或前端日志抽屉取消。
|
||||
- `/api/v1/events` 使用 SSE 推送运行状态和日志增量,前端断线时自动回退到轮询。
|
||||
- 运行历史支持按状态和触发方式筛选,并支持下载完整日志。
|
||||
- 敏感环境变量和敏感 Header 的值不会写入系统日志,输出中的匹配值会替换为 `[REDACTED]`。
|
||||
- 日志支持保留期限和总容量清理,单次日志仍有 5 MiB 上限。
|
||||
- React 页面已接入真实任务列表、搜索筛选、分页、CRUD、启停、手动运行、运行历史和日志轮询。
|
||||
- 三类 Action 使用不同动态表单,并以类型化载荷提交,不再固定为脚本任务。
|
||||
- Playwright 已覆盖运行日志、编辑、停用、删除和窄屏布局主流程。
|
||||
- 后端只监听 `127.0.0.1`,拒绝非本地 Origin。
|
||||
- 浏览器写操作需要运行期随机管理令牌,进程重启后自动更换。
|
||||
- Axum 在生产模式托管 React 静态资源,并支持 SPA 路由回退。
|
||||
- 弹窗和日志抽屉支持初始焦点、Tab 循环、Escape 关闭和焦点恢复。
|
||||
- 提供 Windows Release 打包和一键启动脚本。
|
||||
- 前端与后端均具备基础测试和统一质量检查命令。
|
||||
|
||||
## 技术栈
|
||||
|
||||
### 前端
|
||||
|
||||
- React 19
|
||||
- TypeScript 5.9
|
||||
- Vite 8
|
||||
- React Router
|
||||
- TanStack Query
|
||||
- React Hook Form
|
||||
- Zod
|
||||
- Vitest + Testing Library
|
||||
- Playwright
|
||||
|
||||
### 后端
|
||||
|
||||
- Rust 1.95.0 MSVC
|
||||
- Axum
|
||||
- Tokio
|
||||
- Serde
|
||||
- Utoipa / OpenAPI
|
||||
- Croner
|
||||
- SQLx / SQLite
|
||||
- Tower HTTP
|
||||
|
||||
### 工具链
|
||||
|
||||
- Volta 2.0.2
|
||||
- Node.js 24.12.0
|
||||
- pnpm 11.20.0
|
||||
|
||||
Node.js 和 pnpm 版本固定在 `frontend/package.json` 的 `volta` 字段中,Rust 版本固定在根目录的 `rust-toolchain.toml` 中。
|
||||
|
||||
## 环境要求
|
||||
|
||||
- Windows 10/11
|
||||
- Volta
|
||||
- Rustup
|
||||
- Visual Studio C++ Build Tools,供 Rust MSVC 工具链使用
|
||||
|
||||
首次使用 Volta 时会自动切换到项目固定版本:
|
||||
|
||||
```powershell
|
||||
volta --version
|
||||
node --version
|
||||
pnpm --version
|
||||
rustc --version
|
||||
```
|
||||
|
||||
## 本地开发
|
||||
|
||||
首次拉取项目后安装前端依赖:
|
||||
|
||||
```powershell
|
||||
pnpm --dir frontend install
|
||||
```
|
||||
|
||||
终端一,启动 Rust API:
|
||||
|
||||
```powershell
|
||||
cargo run --manifest-path backend/Cargo.toml
|
||||
```
|
||||
|
||||
终端二,启动 React 前端:
|
||||
|
||||
```powershell
|
||||
pnpm --dir frontend dev
|
||||
```
|
||||
|
||||
访问 <http://127.0.0.1:5173>。Rust 服务默认监听 `127.0.0.1:3000`,可通过 `TASK_PILOT_PORT` 修改端口:
|
||||
|
||||
```powershell
|
||||
$env:TASK_PILOT_PORT = "3100"
|
||||
cargo run --manifest-path backend/Cargo.toml
|
||||
```
|
||||
|
||||
修改后端端口后,还需要同步调整 `frontend/vite.config.ts` 中的代理目标。
|
||||
|
||||
## Windows Release
|
||||
|
||||
构建前端生产资源、Rust Release 可执行文件和可分发目录:
|
||||
|
||||
```powershell
|
||||
.\scripts\build-release.ps1
|
||||
```
|
||||
|
||||
产物位于 `release/TaskPilot/`。启动打包版本:
|
||||
|
||||
```powershell
|
||||
.\release\TaskPilot\start.ps1
|
||||
```
|
||||
|
||||
访问 <http://127.0.0.1:3000>。打包使用同源 API,因此 Release 模式修改 `TASK_PILOT_PORT` 后无需重新构建前端。
|
||||
|
||||
打包目录中的 [RELEASE.md](docs/RELEASE.md) 会作为使用与故障排查说明一并复制。
|
||||
|
||||
SQLite 数据库默认创建在启动目录的 `data/task_pilot.db`。通过以下环境变量可以使用其他数据库路径:
|
||||
|
||||
```powershell
|
||||
$env:TASK_PILOT_DATABASE_URL = "sqlite://data/custom.db"
|
||||
cargo run --manifest-path backend/Cargo.toml
|
||||
```
|
||||
|
||||
数据库迁移会在后端启动时自动执行。
|
||||
|
||||
运行日志默认写入 `logs/{task_id}/{run_id}.log`,可通过以下环境变量配置:
|
||||
|
||||
```powershell
|
||||
$env:TASK_PILOT_LOG_DIR = "logs"
|
||||
$env:TASK_PILOT_LOG_RETENTION_DAYS = "30"
|
||||
$env:TASK_PILOT_LOG_MAX_TOTAL_MB = "512"
|
||||
```
|
||||
|
||||
单次运行日志达到 5 MiB 后会停止记录后续输出。程序启动和每次运行结束时会清理过期日志,并在总容量超限时优先删除最旧日志。
|
||||
|
||||
## API
|
||||
|
||||
| 方法 | 路径 | 用途 |
|
||||
| --- | --- | --- |
|
||||
| `GET` | `/api/v1/health` | 后端健康检查 |
|
||||
| `GET` | `/api/v1/session` | 获取当前进程的本地管理令牌 |
|
||||
| `GET` | `/api/v1/openapi.json` | 运行时 OpenAPI 文档 |
|
||||
| `GET` | `/api/v1/tasks` | 查询任务列表 |
|
||||
| `POST` | `/api/v1/tasks` | 创建任务 |
|
||||
| `GET` | `/api/v1/tasks/{id}` | 查询任务详情 |
|
||||
| `PUT` | `/api/v1/tasks/{id}` | 完整更新任务 |
|
||||
| `DELETE` | `/api/v1/tasks/{id}` | 删除任务 |
|
||||
| `POST` | `/api/v1/tasks/{id}/enable` | 启用任务并注册调度 |
|
||||
| `POST` | `/api/v1/tasks/{id}/disable` | 停用任务并取消调度 |
|
||||
| `POST` | `/api/v1/tasks/{id}/run` | 手动立即执行 |
|
||||
| `GET` | `/api/v1/tasks/{id}/runs` | 查询任务运行历史 |
|
||||
| `GET` | `/api/v1/runs/{run_id}` | 查询单次运行状态 |
|
||||
| `GET` | `/api/v1/runs/{run_id}/logs?offset=0` | 增量读取运行日志 |
|
||||
| `GET` | `/api/v1/runs/{run_id}/logs/download` | 下载完整运行日志 |
|
||||
| `POST` | `/api/v1/runs/{run_id}/cancel` | 取消正在运行的任务 |
|
||||
| `GET` | `/api/v1/events` | SSE 运行状态和日志事件 |
|
||||
|
||||
任务列表查询参数:
|
||||
|
||||
- `q`:按任务 ID 或名称搜索。
|
||||
- `enabled`:按启用状态筛选。
|
||||
- `action_type`:`script`、`http` 或 `process`。
|
||||
- `page`:页码,默认 `1`。
|
||||
- `page_size`:每页数量,默认 `20`,最大 `100`。
|
||||
|
||||
运行历史查询额外支持 `status` 和 `trigger` 过滤。
|
||||
|
||||
健康检查示例:
|
||||
|
||||
```json
|
||||
{
|
||||
"status": "ok",
|
||||
"service": "task-pilot-backend",
|
||||
"version": "0.1.0"
|
||||
}
|
||||
```
|
||||
|
||||
创建脚本任务示例:
|
||||
|
||||
```json
|
||||
{
|
||||
"id": "sync_data",
|
||||
"name": "数据同步",
|
||||
"cron": "0 0 2 * * *",
|
||||
"timezone": "Asia/Shanghai",
|
||||
"enabled": true,
|
||||
"timeout_secs": 300,
|
||||
"retry_policy": {
|
||||
"max_attempts": 0,
|
||||
"delay_secs": 0
|
||||
},
|
||||
"action": {
|
||||
"type": "script",
|
||||
"working_dir": "D:\\Projects\\sync",
|
||||
"interpreter": "python.exe",
|
||||
"script": "main.py",
|
||||
"args": ["--mode", "full"],
|
||||
"env": {
|
||||
"PYTHONUNBUFFERED": "1"
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
校验失败和资源不存在时统一返回结构化错误:
|
||||
|
||||
```json
|
||||
{
|
||||
"code": "VALIDATION_ERROR",
|
||||
"message": "Cron 表达式无效",
|
||||
"field": "cron",
|
||||
"details": {}
|
||||
}
|
||||
```
|
||||
|
||||
## 开发命令
|
||||
|
||||
### 完整检查
|
||||
|
||||
在项目根目录执行:
|
||||
|
||||
```powershell
|
||||
.\scripts\check.ps1
|
||||
```
|
||||
|
||||
该脚本依次运行:
|
||||
|
||||
- 前端 lint
|
||||
- TypeScript 类型检查
|
||||
- Vitest
|
||||
- 前端生产构建
|
||||
- Rust 格式检查
|
||||
- Rust Clippy
|
||||
- Rust 单元和集成测试
|
||||
- Playwright 浏览器端到端测试
|
||||
|
||||
### 浏览器端到端测试
|
||||
|
||||
```powershell
|
||||
pnpm --dir frontend test:e2e
|
||||
```
|
||||
|
||||
端到端测试会清理并重建 `.e2e-data/`,启动临时 Rust 服务和 Vite,使用本机 Chrome 完成真实任务执行和页面操作。
|
||||
|
||||
### 生成接口契约和类型
|
||||
|
||||
修改后端 API DTO 或路由后执行:
|
||||
|
||||
```powershell
|
||||
.\scripts\generate-api.ps1
|
||||
```
|
||||
|
||||
该命令会更新:
|
||||
|
||||
- `contracts/openapi.json`
|
||||
- `frontend/src/api/generated.ts`
|
||||
|
||||
生成文件不应手工修改。
|
||||
|
||||
### 单独检查
|
||||
|
||||
```powershell
|
||||
pnpm --dir frontend lint
|
||||
pnpm --dir frontend typecheck
|
||||
pnpm --dir frontend test
|
||||
pnpm --dir frontend build
|
||||
|
||||
cargo fmt --manifest-path backend/Cargo.toml -- --check
|
||||
cargo clippy --manifest-path backend/Cargo.toml --all-targets -- -D warnings
|
||||
cargo test --manifest-path backend/Cargo.toml
|
||||
```
|
||||
|
||||
## 项目结构
|
||||
|
||||
```text
|
||||
task_pilot/
|
||||
├── backend/
|
||||
│ ├── src/
|
||||
│ │ ├── api.rs
|
||||
│ │ ├── cron.rs
|
||||
│ │ ├── database.rs
|
||||
│ │ ├── error.rs
|
||||
│ │ ├── lib.rs
|
||||
│ │ ├── main.rs
|
||||
│ │ ├── process_job.rs
|
||||
│ │ ├── scheduler.rs
|
||||
│ │ ├── security.rs
|
||||
│ │ ├── state.rs
|
||||
│ │ ├── runs/
|
||||
│ │ └── tasks/
|
||||
│ ├── migrations/
|
||||
│ ├── tests/
|
||||
│ ├── Cargo.toml
|
||||
│ └── Cargo.lock
|
||||
├── contracts/
|
||||
│ └── openapi.json
|
||||
├── docs/
|
||||
│ └── decisions/
|
||||
├── frontend/
|
||||
│ ├── e2e/
|
||||
│ ├── src/
|
||||
│ │ ├── components/
|
||||
│ │ ├── lib/
|
||||
│ │ └── pages/
|
||||
│ ├── package.json
|
||||
│ ├── playwright.config.ts
|
||||
│ ├── vite.config.ts
|
||||
│ └── vitest.config.ts
|
||||
├── scripts/
|
||||
│ ├── check.ps1
|
||||
│ ├── build-release.ps1
|
||||
│ ├── e2e.ps1
|
||||
│ ├── generate-api.ps1
|
||||
│ └── start.ps1
|
||||
├── task_pilot.html
|
||||
├── PLAN.md
|
||||
├── README.md
|
||||
└── rust-toolchain.toml
|
||||
```
|
||||
|
||||
架构决策记录:
|
||||
|
||||
- [ADR-0001:Cron 解析与任务调度](docs/decisions/0001-cron-engine.md)
|
||||
- [ADR-0002:前后端开发与生产托管](docs/decisions/0002-static-hosting.md)
|
||||
|
||||
## 当前限制
|
||||
|
||||
- 重试策略字段已持久化,但自动重试尚未实现。
|
||||
- HTTP 任务执行器尚未提供响应 Header 转换等高级断言,只校验状态码。
|
||||
- 敏感值脱敏基于变量名关键词和值替换,不是系统凭据保险箱。
|
||||
- 当前版本是本地单用户工具,不支持多用户账户和远程部署。
|
||||
- 尚未安装为 Windows 系统服务,关闭启动脚本后调度器也会停止。
|
||||
- 旧 `task_pilot.html` 暂时保留在根目录作为设计参考,正式入口为 React 应用。
|
||||
|
||||
## 开发计划
|
||||
|
||||
完整架构、数据模型、API 草案、阶段任务和验收标准见 [PLAN.md](PLAN.md)。六个开发阶段均已完成,后续进入维护、功能反馈和版本迭代。
|
||||
Generated
+2927
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,33 @@
|
||||
[package]
|
||||
name = "task-pilot-backend"
|
||||
version = "0.1.0"
|
||||
edition = "2024"
|
||||
rust-version = "1.95"
|
||||
|
||||
[dependencies]
|
||||
axum = "0.8.9"
|
||||
chrono = { version = "0.4.45", features = ["serde"] }
|
||||
chrono-tz = "0.10.4"
|
||||
croner = "4.0.1"
|
||||
futures-util = { version = "0.3.34", features = ["std"] }
|
||||
reqwest = { version = "0.13.5", default-features = false, features = ["json", "rustls", "stream"] }
|
||||
serde = { version = "1.0.229", features = ["derive"] }
|
||||
serde_json = "1.0.154"
|
||||
sqlx = { version = "0.9.0", default-features = false, features = ["runtime-tokio", "sqlite", "sqlite-bundled", "migrate", "chrono", "derive", "macros"] }
|
||||
thiserror = "2.0.21"
|
||||
tokio = { version = "1.53.3", features = ["full"] }
|
||||
tokio-stream = { version = "0.1.19", features = ["sync"] }
|
||||
tokio-util = "0.7.20"
|
||||
tower-http = { version = "0.7.1", features = ["cors", "fs", "trace"] }
|
||||
tracing = "0.1.44"
|
||||
tracing-subscriber = { version = "0.3.23", features = ["env-filter"] }
|
||||
url = "2.5.8"
|
||||
utoipa = { version = "6.0.0", features = ["axum_extras", "chrono"] }
|
||||
utoipa-axum = "0.3.0"
|
||||
uuid = { version = "1.28.0", features = ["v4"] }
|
||||
windows-sys = { version = "0.61.2", features = ["Win32_Foundation", "Win32_Security", "Win32_System_JobObjects"] }
|
||||
|
||||
[dev-dependencies]
|
||||
http-body-util = "0.1.5"
|
||||
tempfile = "3.27.0"
|
||||
tower = { version = "0.5.3", features = ["util"] }
|
||||
@@ -0,0 +1,28 @@
|
||||
CREATE TABLE IF NOT EXISTS tasks (
|
||||
id TEXT PRIMARY KEY NOT NULL,
|
||||
name TEXT NOT NULL,
|
||||
cron TEXT NOT NULL,
|
||||
timezone TEXT NOT NULL,
|
||||
enabled INTEGER NOT NULL CHECK (enabled IN (0, 1)),
|
||||
timeout_secs INTEGER NOT NULL CHECK (timeout_secs > 0),
|
||||
concurrency_policy TEXT NOT NULL DEFAULT 'skip'
|
||||
CHECK (concurrency_policy = 'skip'),
|
||||
retry_max_attempts INTEGER NOT NULL DEFAULT 0
|
||||
CHECK (retry_max_attempts >= 0),
|
||||
retry_delay_secs INTEGER NOT NULL DEFAULT 0
|
||||
CHECK (retry_delay_secs >= 0),
|
||||
action_type TEXT NOT NULL
|
||||
CHECK (action_type IN ('script', 'http', 'process')),
|
||||
action_json TEXT NOT NULL,
|
||||
created_at TEXT NOT NULL,
|
||||
updated_at TEXT NOT NULL
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_tasks_updated_at
|
||||
ON tasks(updated_at DESC);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_tasks_enabled
|
||||
ON tasks(enabled);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_tasks_action_type
|
||||
ON tasks(action_type);
|
||||
@@ -0,0 +1,23 @@
|
||||
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);
|
||||
@@ -0,0 +1,201 @@
|
||||
use axum::{
|
||||
Json, Router,
|
||||
extract::State,
|
||||
http::{HeaderValue, Method, header},
|
||||
middleware,
|
||||
response::IntoResponse,
|
||||
routing::any,
|
||||
routing::get,
|
||||
};
|
||||
use serde::Serialize;
|
||||
use tower_http::cors::CorsLayer;
|
||||
use utoipa::{OpenApi, ToSchema};
|
||||
|
||||
use crate::{
|
||||
cron::{self, CronPreviewRequest, CronPreviewResponse},
|
||||
error::{ApiError, ApiJson, ApiResult, ErrorResponse},
|
||||
runs::{
|
||||
model::{LogChunkResponse, TaskRunListResponse, TaskRunResponse},
|
||||
routes::{
|
||||
__path_cancel_run, __path_download_run_logs, __path_execution_events, __path_get_run,
|
||||
__path_get_run_logs, __path_list_task_runs, __path_run_task,
|
||||
},
|
||||
},
|
||||
security,
|
||||
state::AppState,
|
||||
tasks::{
|
||||
model::{
|
||||
ConcurrencyPolicy, CreateTaskRequest, HttpMethod, RetryPolicy, TaskAction,
|
||||
TaskActionType, TaskListResponse, TaskResponse, UpdateTaskRequest,
|
||||
},
|
||||
routes::{
|
||||
__path_create_task, __path_delete_task, __path_disable_task, __path_enable_task,
|
||||
__path_get_task, __path_list_tasks, __path_update_task,
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
#[derive(Debug, Serialize, ToSchema)]
|
||||
pub struct HealthResponse {
|
||||
/// Current service status.
|
||||
pub status: String,
|
||||
/// Stable service identifier.
|
||||
pub service: String,
|
||||
/// Backend package version.
|
||||
pub version: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize, ToSchema)]
|
||||
pub struct SessionResponse {
|
||||
/// Random per-process token required for mutating browser requests.
|
||||
pub token: String,
|
||||
}
|
||||
|
||||
#[utoipa::path(
|
||||
get,
|
||||
path = "/api/v1/health",
|
||||
tag = "system",
|
||||
responses(
|
||||
(status = 200, description = "TaskPilot API is healthy", body = HealthResponse)
|
||||
)
|
||||
)]
|
||||
pub async fn health() -> Json<HealthResponse> {
|
||||
Json(HealthResponse {
|
||||
status: "ok".to_owned(),
|
||||
service: "task-pilot-backend".to_owned(),
|
||||
version: env!("CARGO_PKG_VERSION").to_owned(),
|
||||
})
|
||||
}
|
||||
|
||||
#[utoipa::path(
|
||||
get,
|
||||
path = "/api/v1/session",
|
||||
tag = "system",
|
||||
responses(
|
||||
(status = 200, description = "Local API session token", body = SessionResponse)
|
||||
)
|
||||
)]
|
||||
pub async fn session(State(state): State<AppState>) -> Json<SessionResponse> {
|
||||
Json(SessionResponse {
|
||||
token: state.auth_token().to_owned(),
|
||||
})
|
||||
}
|
||||
|
||||
#[utoipa::path(
|
||||
post,
|
||||
path = "/api/v1/cron/preview",
|
||||
tag = "system",
|
||||
request_body = CronPreviewRequest,
|
||||
responses(
|
||||
(status = 200, description = "Upcoming cron occurrences", body = CronPreviewResponse),
|
||||
(status = 400, description = "Invalid cron preview request", body = ErrorResponse)
|
||||
)
|
||||
)]
|
||||
pub async fn preview_cron(
|
||||
ApiJson(request): ApiJson<CronPreviewRequest>,
|
||||
) -> ApiResult<Json<CronPreviewResponse>> {
|
||||
if request.count == 0 || request.count > 10 {
|
||||
return Err(ApiError::validation("count", "预览次数必须在 1 到 10 之间"));
|
||||
}
|
||||
let preview = cron::preview_in_timezone(
|
||||
&request.expression,
|
||||
&request.timezone,
|
||||
chrono::Utc::now(),
|
||||
request.count,
|
||||
)
|
||||
.map_err(|error| ApiError::validation("expression", error.to_string()))?;
|
||||
Ok(Json(preview))
|
||||
}
|
||||
|
||||
#[derive(OpenApi)]
|
||||
#[openapi(
|
||||
info(
|
||||
title = "TaskPilot API",
|
||||
version = env!("CARGO_PKG_VERSION"),
|
||||
description = "Local task scheduling and process management API."
|
||||
),
|
||||
paths(
|
||||
health,
|
||||
session,
|
||||
preview_cron,
|
||||
list_tasks,
|
||||
create_task,
|
||||
get_task,
|
||||
update_task,
|
||||
delete_task,
|
||||
enable_task,
|
||||
disable_task,
|
||||
run_task,
|
||||
list_task_runs,
|
||||
get_run,
|
||||
get_run_logs,
|
||||
cancel_run,
|
||||
download_run_logs,
|
||||
execution_events
|
||||
),
|
||||
components(schemas(
|
||||
HealthResponse,
|
||||
SessionResponse,
|
||||
CronPreviewRequest,
|
||||
CronPreviewResponse,
|
||||
ErrorResponse,
|
||||
TaskActionType,
|
||||
HttpMethod,
|
||||
ConcurrencyPolicy,
|
||||
RetryPolicy,
|
||||
TaskAction,
|
||||
CreateTaskRequest,
|
||||
UpdateTaskRequest,
|
||||
TaskResponse,
|
||||
TaskListResponse,
|
||||
TaskRunResponse,
|
||||
TaskRunListResponse,
|
||||
LogChunkResponse
|
||||
))
|
||||
)]
|
||||
pub struct ApiDoc;
|
||||
|
||||
pub fn openapi() -> utoipa::openapi::OpenApi {
|
||||
ApiDoc::openapi()
|
||||
}
|
||||
|
||||
async fn openapi_json() -> Json<utoipa::openapi::OpenApi> {
|
||||
Json(openapi())
|
||||
}
|
||||
|
||||
pub fn router(state: AppState) -> Router {
|
||||
let allowed_origins = [
|
||||
HeaderValue::from_static("http://127.0.0.1:5173"),
|
||||
HeaderValue::from_static("http://localhost:5173"),
|
||||
];
|
||||
let cors = CorsLayer::new()
|
||||
.allow_origin(allowed_origins)
|
||||
.allow_methods([
|
||||
Method::GET,
|
||||
Method::POST,
|
||||
Method::PUT,
|
||||
Method::DELETE,
|
||||
Method::OPTIONS,
|
||||
])
|
||||
.allow_headers([header::ACCEPT, header::CONTENT_TYPE]);
|
||||
|
||||
let api = Router::new()
|
||||
.route("/api/v1/health", get(health))
|
||||
.route("/api/v1/session", get(session))
|
||||
.route("/api/v1/cron/preview", axum::routing::post(preview_cron))
|
||||
.route("/api/v1/openapi.json", get(openapi_json))
|
||||
.merge(crate::tasks::routes::router())
|
||||
.merge(crate::runs::routes::router())
|
||||
.route("/api/{*path}", any(api_not_found))
|
||||
.layer(cors)
|
||||
.route_layer(middleware::from_fn_with_state(
|
||||
state.clone(),
|
||||
security::api_guard,
|
||||
));
|
||||
|
||||
api.with_state(state)
|
||||
}
|
||||
|
||||
async fn api_not_found() -> impl IntoResponse {
|
||||
ApiError::route_not_found("API 路由不存在")
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
use std::{env, error::Error, fs, path::PathBuf};
|
||||
|
||||
fn main() -> Result<(), Box<dyn Error>> {
|
||||
let json = serde_json::to_string_pretty(&task_pilot_backend::api::openapi())?;
|
||||
|
||||
if let Some(path) = env::args().nth(1) {
|
||||
if let Some(parent) = PathBuf::from(&path).parent() {
|
||||
fs::create_dir_all(parent)?;
|
||||
}
|
||||
fs::write(path, format!("{json}\n"))?;
|
||||
} else {
|
||||
println!("{json}");
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
@@ -0,0 +1,184 @@
|
||||
use std::str::FromStr;
|
||||
|
||||
use chrono::{DateTime, Local, Utc};
|
||||
use chrono_tz::Tz;
|
||||
use croner::Cron;
|
||||
use serde::{Deserialize, Serialize};
|
||||
use thiserror::Error;
|
||||
use utoipa::ToSchema;
|
||||
|
||||
const MAX_PREVIEW_RUNS: usize = 100;
|
||||
|
||||
#[derive(Debug, Error)]
|
||||
pub enum CronPreviewError {
|
||||
#[error("invalid cron expression: {0}")]
|
||||
InvalidExpression(String),
|
||||
#[error("cron preview can return at most {MAX_PREVIEW_RUNS} runs")]
|
||||
TooManyRuns,
|
||||
#[error("cron expression has no future occurrence")]
|
||||
NoFutureOccurrence,
|
||||
#[error("invalid IANA timezone: {0}")]
|
||||
InvalidTimezone(String),
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize, ToSchema)]
|
||||
#[serde(deny_unknown_fields)]
|
||||
pub struct CronPreviewRequest {
|
||||
pub expression: String,
|
||||
pub timezone: String,
|
||||
pub count: usize,
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize, ToSchema)]
|
||||
pub struct CronPreviewResponse {
|
||||
pub timezone: String,
|
||||
pub runs: Vec<String>,
|
||||
}
|
||||
|
||||
/// Calculates the next occurrences of a cron expression in local time.
|
||||
pub fn next_occurrences(
|
||||
expression: &str,
|
||||
from: DateTime<Local>,
|
||||
count: usize,
|
||||
) -> Result<Vec<DateTime<Local>>, CronPreviewError> {
|
||||
if count > MAX_PREVIEW_RUNS {
|
||||
return Err(CronPreviewError::TooManyRuns);
|
||||
}
|
||||
|
||||
let cron = Cron::from_str(expression)
|
||||
.map_err(|error| CronPreviewError::InvalidExpression(error.to_string()))?;
|
||||
let mut runs = Vec::with_capacity(count);
|
||||
let mut cursor = from;
|
||||
|
||||
for _ in 0..count {
|
||||
let next = cron
|
||||
.find_next_occurrence(&cursor, false)
|
||||
.map_err(|error| CronPreviewError::InvalidExpression(error.to_string()))?;
|
||||
runs.push(next);
|
||||
cursor = next;
|
||||
}
|
||||
|
||||
Ok(runs)
|
||||
}
|
||||
|
||||
pub fn preview_in_timezone(
|
||||
expression: &str,
|
||||
timezone: &str,
|
||||
from: DateTime<Utc>,
|
||||
count: usize,
|
||||
) -> Result<CronPreviewResponse, CronPreviewError> {
|
||||
if count > MAX_PREVIEW_RUNS {
|
||||
return Err(CronPreviewError::TooManyRuns);
|
||||
}
|
||||
let timezone: Tz = timezone
|
||||
.parse()
|
||||
.map_err(|_| CronPreviewError::InvalidTimezone(timezone.to_owned()))?;
|
||||
let cron = Cron::from_str(expression)
|
||||
.map_err(|error| CronPreviewError::InvalidExpression(error.to_string()))?;
|
||||
let mut cursor = from.with_timezone(&timezone);
|
||||
let mut runs = Vec::with_capacity(count);
|
||||
|
||||
for _ in 0..count {
|
||||
let next = cron
|
||||
.find_next_occurrence(&cursor, false)
|
||||
.map_err(|error| CronPreviewError::InvalidExpression(error.to_string()))?;
|
||||
runs.push(next.format("%Y-%m-%d %H:%M:%S").to_string());
|
||||
cursor = next;
|
||||
}
|
||||
|
||||
Ok(CronPreviewResponse {
|
||||
timezone: timezone.name().to_owned(),
|
||||
runs,
|
||||
})
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use chrono::{Datelike, TimeZone, Weekday};
|
||||
|
||||
use super::*;
|
||||
|
||||
fn local_time(
|
||||
year: i32,
|
||||
month: u32,
|
||||
day: u32,
|
||||
hour: u32,
|
||||
minute: u32,
|
||||
second: u32,
|
||||
) -> DateTime<Local> {
|
||||
Local
|
||||
.with_ymd_and_hms(year, month, day, hour, minute, second)
|
||||
.single()
|
||||
.expect("test timestamp should be unambiguous")
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parses_five_field_expression() {
|
||||
let runs = next_occurrences("0 9 * * *", local_time(2026, 1, 1, 8, 59, 0), 1)
|
||||
.expect("five field expression should parse");
|
||||
|
||||
assert_eq!(runs, vec![local_time(2026, 1, 1, 9, 0, 0)]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parses_six_field_expression_with_seconds() {
|
||||
let runs = next_occurrences("*/15 * * * * *", local_time(2026, 1, 1, 12, 0, 1), 2)
|
||||
.expect("six field expression should parse");
|
||||
|
||||
assert_eq!(
|
||||
runs,
|
||||
vec![
|
||||
local_time(2026, 1, 1, 12, 0, 15),
|
||||
local_time(2026, 1, 1, 12, 0, 30)
|
||||
]
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn supports_hourly_alias() {
|
||||
let runs = next_occurrences("@hourly", local_time(2026, 1, 1, 12, 34, 56), 1)
|
||||
.expect("hourly alias should parse");
|
||||
|
||||
assert_eq!(runs, vec![local_time(2026, 1, 1, 13, 0, 0)]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rejects_zero_step() {
|
||||
let error = next_occurrences("*/0 * * * * *", local_time(2026, 1, 1, 0, 0, 0), 1)
|
||||
.expect_err("zero step should be rejected");
|
||||
|
||||
assert!(matches!(error, CronPreviewError::InvalidExpression(_)));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn uses_posix_or_semantics_for_day_of_month_and_weekday() {
|
||||
let runs = next_occurrences("0 0 0 1 * MON", local_time(2026, 1, 2, 0, 0, 0), 1)
|
||||
.expect("POSIX cron expression should parse");
|
||||
|
||||
assert_eq!(runs[0].day(), 5);
|
||||
assert_eq!(runs[0].weekday(), Weekday::Mon);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn supports_explicit_day_and_weekday_and_semantics() {
|
||||
let runs = next_occurrences("0 0 0 1 * +MON", local_time(2026, 1, 2, 0, 0, 0), 1)
|
||||
.expect("AND semantics should parse");
|
||||
|
||||
assert_eq!(runs[0], local_time(2026, 6, 1, 0, 0, 0));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn previews_runs_in_iana_timezone() {
|
||||
let from = Utc.with_ymd_and_hms(2026, 1, 1, 0, 0, 0).unwrap();
|
||||
let preview = preview_in_timezone("0 0 2 * * *", "Asia/Shanghai", from, 2).unwrap();
|
||||
|
||||
assert_eq!(preview.timezone, "Asia/Shanghai");
|
||||
assert_eq!(
|
||||
preview.runs,
|
||||
vec![
|
||||
"2026-01-02 02:00:00".to_owned(),
|
||||
"2026-01-03 02:00:00".to_owned()
|
||||
]
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,29 @@
|
||||
use std::{error::Error, str::FromStr};
|
||||
|
||||
use sqlx::{
|
||||
SqlitePool,
|
||||
migrate::MigrateError,
|
||||
sqlite::{SqliteConnectOptions, SqlitePoolOptions},
|
||||
};
|
||||
|
||||
pub async fn connect(database_url: &str) -> Result<SqlitePool, Box<dyn Error>> {
|
||||
let options = SqliteConnectOptions::from_str(database_url)?
|
||||
.create_if_missing(true)
|
||||
.foreign_keys(true);
|
||||
let max_connections = if database_url == "sqlite::memory:" {
|
||||
1
|
||||
} else {
|
||||
5
|
||||
};
|
||||
let pool = SqlitePoolOptions::new()
|
||||
.max_connections(max_connections)
|
||||
.connect_with(options)
|
||||
.await?;
|
||||
|
||||
migrate(&pool).await?;
|
||||
Ok(pool)
|
||||
}
|
||||
|
||||
pub async fn migrate(pool: &SqlitePool) -> Result<(), MigrateError> {
|
||||
sqlx::migrate!("./migrations").run(pool).await
|
||||
}
|
||||
@@ -0,0 +1,222 @@
|
||||
use std::collections::BTreeMap;
|
||||
|
||||
use axum::{
|
||||
Json,
|
||||
extract::{FromRequest, FromRequestParts, Query, Request, rejection::JsonRejection},
|
||||
http::StatusCode,
|
||||
response::{IntoResponse, Response},
|
||||
};
|
||||
use serde::{Serialize, de::DeserializeOwned};
|
||||
use utoipa::ToSchema;
|
||||
|
||||
pub type ApiResult<T> = Result<T, ApiError>;
|
||||
|
||||
pub struct ApiJson<T>(pub T);
|
||||
|
||||
impl<S, T> FromRequest<S> for ApiJson<T>
|
||||
where
|
||||
S: Send + Sync,
|
||||
T: DeserializeOwned,
|
||||
{
|
||||
type Rejection = ApiError;
|
||||
|
||||
async fn from_request(request: Request, state: &S) -> Result<Self, Self::Rejection> {
|
||||
Json::<T>::from_request(request, state)
|
||||
.await
|
||||
.map(|Json(value)| Self(value))
|
||||
.map_err(json_rejection)
|
||||
}
|
||||
}
|
||||
|
||||
pub struct ApiQuery<T>(pub T);
|
||||
|
||||
impl<S, T> FromRequestParts<S> for ApiQuery<T>
|
||||
where
|
||||
S: Send + Sync,
|
||||
T: DeserializeOwned,
|
||||
{
|
||||
type Rejection = ApiError;
|
||||
|
||||
async fn from_request_parts(
|
||||
parts: &mut axum::http::request::Parts,
|
||||
state: &S,
|
||||
) -> Result<Self, Self::Rejection> {
|
||||
Query::<T>::from_request_parts(parts, state)
|
||||
.await
|
||||
.map(|Query(value)| Self(value))
|
||||
.map_err(|error| {
|
||||
ApiError::bad_request("INVALID_QUERY", format!("查询参数无效: {error}"))
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug)]
|
||||
pub struct ApiError {
|
||||
status: StatusCode,
|
||||
code: &'static str,
|
||||
message: String,
|
||||
field: Option<String>,
|
||||
details: BTreeMap<String, String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize, ToSchema)]
|
||||
pub struct ErrorResponse {
|
||||
pub code: String,
|
||||
pub message: String,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub field: Option<String>,
|
||||
pub details: BTreeMap<String, String>,
|
||||
}
|
||||
|
||||
impl ApiError {
|
||||
pub fn bad_request(code: &'static str, message: impl Into<String>) -> Self {
|
||||
Self {
|
||||
status: StatusCode::BAD_REQUEST,
|
||||
code,
|
||||
message: message.into(),
|
||||
field: None,
|
||||
details: BTreeMap::new(),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn validation(field: impl Into<String>, message: impl Into<String>) -> Self {
|
||||
Self {
|
||||
status: StatusCode::BAD_REQUEST,
|
||||
code: "VALIDATION_ERROR",
|
||||
message: message.into(),
|
||||
field: Some(field.into()),
|
||||
details: BTreeMap::new(),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn not_found(message: impl Into<String>) -> Self {
|
||||
Self {
|
||||
status: StatusCode::NOT_FOUND,
|
||||
code: "TASK_NOT_FOUND",
|
||||
message: message.into(),
|
||||
field: None,
|
||||
details: BTreeMap::new(),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn run_not_found(message: impl Into<String>) -> Self {
|
||||
Self {
|
||||
status: StatusCode::NOT_FOUND,
|
||||
code: "RUN_NOT_FOUND",
|
||||
message: message.into(),
|
||||
field: None,
|
||||
details: BTreeMap::new(),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn route_not_found(message: impl Into<String>) -> Self {
|
||||
Self {
|
||||
status: StatusCode::NOT_FOUND,
|
||||
code: "NOT_FOUND",
|
||||
message: message.into(),
|
||||
field: None,
|
||||
details: BTreeMap::new(),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn run_not_cancellable(message: impl Into<String>) -> Self {
|
||||
Self {
|
||||
status: StatusCode::CONFLICT,
|
||||
code: "RUN_NOT_CANCELLABLE",
|
||||
message: message.into(),
|
||||
field: None,
|
||||
details: BTreeMap::new(),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn run_log_not_found(message: impl Into<String>) -> Self {
|
||||
Self {
|
||||
status: StatusCode::NOT_FOUND,
|
||||
code: "RUN_LOG_NOT_FOUND",
|
||||
message: message.into(),
|
||||
field: None,
|
||||
details: BTreeMap::new(),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn forbidden(message: impl Into<String>) -> Self {
|
||||
Self {
|
||||
status: StatusCode::FORBIDDEN,
|
||||
code: "FORBIDDEN",
|
||||
message: message.into(),
|
||||
field: None,
|
||||
details: BTreeMap::new(),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn conflict(message: impl Into<String>) -> Self {
|
||||
Self {
|
||||
status: StatusCode::CONFLICT,
|
||||
code: "TASK_ID_CONFLICT",
|
||||
message: message.into(),
|
||||
field: Some("id".to_owned()),
|
||||
details: BTreeMap::new(),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn unsupported(message: impl Into<String>) -> Self {
|
||||
Self {
|
||||
status: StatusCode::UNPROCESSABLE_ENTITY,
|
||||
code: "ACTION_NOT_IMPLEMENTED",
|
||||
message: message.into(),
|
||||
field: Some("action.type".to_owned()),
|
||||
details: BTreeMap::new(),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn internal(error: impl std::fmt::Display) -> Self {
|
||||
tracing::error!(error = %error, "internal API error");
|
||||
Self {
|
||||
status: StatusCode::INTERNAL_SERVER_ERROR,
|
||||
code: "INTERNAL_ERROR",
|
||||
message: "服务器处理请求时发生内部错误".to_owned(),
|
||||
field: None,
|
||||
details: BTreeMap::new(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn json_rejection(error: JsonRejection) -> ApiError {
|
||||
ApiError::bad_request(
|
||||
"INVALID_JSON",
|
||||
format!("请求体不是有效的任务 JSON: {error}"),
|
||||
)
|
||||
}
|
||||
|
||||
impl IntoResponse for ApiError {
|
||||
fn into_response(self) -> Response {
|
||||
let body = ErrorResponse {
|
||||
code: self.code.to_owned(),
|
||||
message: self.message,
|
||||
field: self.field,
|
||||
details: self.details,
|
||||
};
|
||||
|
||||
(self.status, Json(body)).into_response()
|
||||
}
|
||||
}
|
||||
|
||||
impl From<sqlx::Error> for ApiError {
|
||||
fn from(error: sqlx::Error) -> Self {
|
||||
Self::internal(error)
|
||||
}
|
||||
}
|
||||
|
||||
impl From<serde_json::Error> for ApiError {
|
||||
fn from(error: serde_json::Error) -> Self {
|
||||
Self::internal(error)
|
||||
}
|
||||
}
|
||||
|
||||
impl std::fmt::Display for ApiError {
|
||||
fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||
formatter.write_str(&self.message)
|
||||
}
|
||||
}
|
||||
|
||||
impl std::error::Error for ApiError {}
|
||||
@@ -0,0 +1,40 @@
|
||||
pub mod api;
|
||||
pub mod cron;
|
||||
pub mod database;
|
||||
pub mod error;
|
||||
pub mod process_job;
|
||||
pub mod runs;
|
||||
pub mod scheduler;
|
||||
pub mod security;
|
||||
pub mod state;
|
||||
pub mod tasks;
|
||||
|
||||
use axum::Router;
|
||||
use sqlx::SqlitePool;
|
||||
use tower_http::{
|
||||
services::{ServeDir, ServeFile},
|
||||
trace::TraceLayer,
|
||||
};
|
||||
|
||||
pub async fn app(
|
||||
pool: SqlitePool,
|
||||
logs_dir: std::path::PathBuf,
|
||||
) -> crate::error::ApiResult<Router> {
|
||||
let state = state::AppState::start(pool, logs_dir).await?;
|
||||
Ok(router(state))
|
||||
}
|
||||
|
||||
pub fn router(state: state::AppState) -> Router {
|
||||
api::router(state).layer(TraceLayer::new_for_http())
|
||||
}
|
||||
|
||||
pub fn router_with_static(state: state::AppState, static_dir: std::path::PathBuf) -> Router {
|
||||
let index = static_dir.join("index.html");
|
||||
let router = if index.is_file() {
|
||||
api::router(state)
|
||||
.fallback_service(ServeDir::new(&static_dir).fallback(ServeFile::new(index)))
|
||||
} else {
|
||||
api::router(state)
|
||||
};
|
||||
router.layer(TraceLayer::new_for_http())
|
||||
}
|
||||
@@ -0,0 +1,77 @@
|
||||
use std::{
|
||||
env,
|
||||
error::Error,
|
||||
fs,
|
||||
net::SocketAddr,
|
||||
path::{Path, PathBuf},
|
||||
};
|
||||
|
||||
use tokio::net::TcpListener;
|
||||
use tracing_subscriber::{EnvFilter, layer::SubscriberExt, util::SubscriberInitExt};
|
||||
|
||||
#[tokio::main]
|
||||
async fn main() -> Result<(), Box<dyn Error>> {
|
||||
init_tracing();
|
||||
|
||||
let port = env::var("TASK_PILOT_PORT")
|
||||
.ok()
|
||||
.and_then(|value| value.parse::<u16>().ok())
|
||||
.unwrap_or(3000);
|
||||
let address = SocketAddr::from(([127, 0, 0, 1], port));
|
||||
let database_url = database_url()?;
|
||||
let pool = task_pilot_backend::database::connect(&database_url).await?;
|
||||
let logs_dir = env::var("TASK_PILOT_LOG_DIR")
|
||||
.map(PathBuf::from)
|
||||
.unwrap_or_else(|_| Path::new("logs").to_path_buf());
|
||||
fs::create_dir_all(&logs_dir)?;
|
||||
let state = task_pilot_backend::state::AppState::start(pool.clone(), logs_dir).await?;
|
||||
let static_dir = env::var("TASK_PILOT_STATIC_DIR")
|
||||
.map(PathBuf::from)
|
||||
.unwrap_or_else(|_| Path::new("frontend/dist").to_path_buf());
|
||||
let listener = TcpListener::bind(address).await?;
|
||||
|
||||
tracing::info!(%address, database_url, "TaskPilot backend listening");
|
||||
|
||||
axum::serve(
|
||||
listener,
|
||||
task_pilot_backend::router_with_static(state.clone(), static_dir),
|
||||
)
|
||||
.with_graceful_shutdown(shutdown_signal())
|
||||
.await?;
|
||||
|
||||
state.shutdown();
|
||||
pool.close().await;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn database_url() -> Result<String, Box<dyn Error>> {
|
||||
if let Ok(url) = env::var("TASK_PILOT_DATABASE_URL") {
|
||||
return Ok(url);
|
||||
}
|
||||
|
||||
let data_dir = Path::new("data");
|
||||
fs::create_dir_all(data_dir)?;
|
||||
Ok(format!(
|
||||
"sqlite://{}",
|
||||
data_dir
|
||||
.join("task_pilot.db")
|
||||
.to_string_lossy()
|
||||
.replace('\\', "/")
|
||||
))
|
||||
}
|
||||
|
||||
fn init_tracing() {
|
||||
let filter = EnvFilter::try_from_default_env()
|
||||
.unwrap_or_else(|_| "task_pilot_backend=debug,tower_http=debug".into());
|
||||
|
||||
tracing_subscriber::registry()
|
||||
.with(filter)
|
||||
.with(tracing_subscriber::fmt::layer())
|
||||
.init();
|
||||
}
|
||||
|
||||
async fn shutdown_signal() {
|
||||
if let Err(error) = tokio::signal::ctrl_c().await {
|
||||
tracing::error!(%error, "failed to listen for shutdown signal");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,109 @@
|
||||
#[cfg(windows)]
|
||||
mod platform {
|
||||
use std::{
|
||||
ffi::c_void,
|
||||
io,
|
||||
mem::{size_of, zeroed},
|
||||
ptr,
|
||||
};
|
||||
|
||||
use tokio::process::Child;
|
||||
use windows_sys::Win32::{
|
||||
Foundation::{CloseHandle, HANDLE},
|
||||
System::JobObjects::{
|
||||
AssignProcessToJobObject, CreateJobObjectW, JOB_OBJECT_LIMIT_KILL_ON_JOB_CLOSE,
|
||||
JOBOBJECT_EXTENDED_LIMIT_INFORMATION, JobObjectExtendedLimitInformation,
|
||||
SetInformationJobObject, TerminateJobObject,
|
||||
},
|
||||
};
|
||||
|
||||
pub struct ProcessJob {
|
||||
handle: HANDLE,
|
||||
}
|
||||
|
||||
// Windows job handles can be used across threads. This wrapper owns exactly one
|
||||
// handle and closes it once in Drop.
|
||||
unsafe impl Send for ProcessJob {}
|
||||
unsafe impl Sync for ProcessJob {}
|
||||
|
||||
impl ProcessJob {
|
||||
pub fn new() -> io::Result<Self> {
|
||||
let handle = unsafe { CreateJobObjectW(ptr::null(), ptr::null()) };
|
||||
if handle.is_null() {
|
||||
return Err(io::Error::last_os_error());
|
||||
}
|
||||
let job = Self { handle };
|
||||
job.enable_kill_on_close()?;
|
||||
Ok(job)
|
||||
}
|
||||
|
||||
pub fn assign(&self, child: &Child) -> io::Result<()> {
|
||||
let process_handle = child
|
||||
.raw_handle()
|
||||
.ok_or_else(|| io::Error::other("process handle is unavailable"))?;
|
||||
let result = unsafe { AssignProcessToJobObject(self.handle, process_handle.cast()) };
|
||||
if result == 0 {
|
||||
return Err(io::Error::last_os_error());
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub fn terminate(&self) -> io::Result<()> {
|
||||
let result = unsafe { TerminateJobObject(self.handle, 1) };
|
||||
if result == 0 {
|
||||
return Err(io::Error::last_os_error());
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn enable_kill_on_close(&self) -> io::Result<()> {
|
||||
let mut information: JOBOBJECT_EXTENDED_LIMIT_INFORMATION = unsafe { zeroed() };
|
||||
information.BasicLimitInformation.LimitFlags = JOB_OBJECT_LIMIT_KILL_ON_JOB_CLOSE;
|
||||
let result = unsafe {
|
||||
SetInformationJobObject(
|
||||
self.handle,
|
||||
JobObjectExtendedLimitInformation,
|
||||
(&information as *const JOBOBJECT_EXTENDED_LIMIT_INFORMATION).cast::<c_void>(),
|
||||
size_of::<JOBOBJECT_EXTENDED_LIMIT_INFORMATION>() as u32,
|
||||
)
|
||||
};
|
||||
if result == 0 {
|
||||
return Err(io::Error::last_os_error());
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
impl Drop for ProcessJob {
|
||||
fn drop(&mut self) {
|
||||
unsafe {
|
||||
CloseHandle(self.handle);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(not(windows))]
|
||||
mod platform {
|
||||
use std::io;
|
||||
|
||||
use tokio::process::Child;
|
||||
|
||||
pub struct ProcessJob;
|
||||
|
||||
impl ProcessJob {
|
||||
pub fn new() -> io::Result<Self> {
|
||||
Ok(Self)
|
||||
}
|
||||
|
||||
pub fn assign(&self, _child: &Child) -> io::Result<()> {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub fn terminate(&self) -> io::Result<()> {
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub use platform::ProcessJob;
|
||||
@@ -0,0 +1,962 @@
|
||||
use std::{
|
||||
collections::HashMap,
|
||||
path::{Path, PathBuf},
|
||||
process::Stdio,
|
||||
sync::{
|
||||
Arc, Mutex,
|
||||
atomic::{AtomicBool, AtomicU64, Ordering},
|
||||
},
|
||||
time::{Instant, SystemTime},
|
||||
};
|
||||
|
||||
use chrono::Utc;
|
||||
use futures_util::StreamExt;
|
||||
use reqwest::Method;
|
||||
use serde::Serialize;
|
||||
use sqlx::SqlitePool;
|
||||
use tokio::{
|
||||
io::{AsyncBufReadExt, AsyncRead, AsyncWriteExt, BufReader},
|
||||
process::{Child, Command},
|
||||
sync::{Mutex as AsyncMutex, broadcast},
|
||||
time::{Duration, sleep, timeout},
|
||||
};
|
||||
use tokio_util::sync::CancellationToken;
|
||||
|
||||
use crate::{
|
||||
error::ApiResult,
|
||||
process_job::ProcessJob,
|
||||
runs::{
|
||||
model::{RunStatus, RunTrigger, TaskRunResponse},
|
||||
repository,
|
||||
},
|
||||
tasks::model::{HttpMethod, TaskAction, TaskResponse},
|
||||
};
|
||||
|
||||
const MAX_LOG_BYTES: u64 = 5 * 1024 * 1024;
|
||||
const MAX_HTTP_LOG_BODY_BYTES: usize = 64 * 1024;
|
||||
const TERMINATION_WAIT_SECS: u64 = 5;
|
||||
const CREATE_NO_WINDOW: u32 = 0x0800_0000;
|
||||
const DEFAULT_RETENTION_DAYS: u64 = 30;
|
||||
const DEFAULT_MAX_TOTAL_LOG_BYTES: u64 = 512 * 1024 * 1024;
|
||||
|
||||
#[derive(Clone, Debug)]
|
||||
pub struct LogRetentionConfig {
|
||||
pub retention_days: u64,
|
||||
pub max_total_bytes: u64,
|
||||
}
|
||||
|
||||
impl Default for LogRetentionConfig {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
retention_days: DEFAULT_RETENTION_DAYS,
|
||||
max_total_bytes: DEFAULT_MAX_TOTAL_LOG_BYTES,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Serialize)]
|
||||
#[serde(tag = "event", content = "data", rename_all = "snake_case")]
|
||||
pub enum ExecutionEvent {
|
||||
Run {
|
||||
run: TaskRunResponse,
|
||||
},
|
||||
Log {
|
||||
run_id: i64,
|
||||
offset: u64,
|
||||
next_offset: u64,
|
||||
content: String,
|
||||
},
|
||||
}
|
||||
|
||||
impl ExecutionEvent {
|
||||
pub const fn event_name(&self) -> &'static str {
|
||||
match self {
|
||||
Self::Run { .. } => "run",
|
||||
Self::Log { .. } => "log",
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Clone)]
|
||||
pub struct TaskExecutor {
|
||||
inner: Arc<ExecutorInner>,
|
||||
}
|
||||
|
||||
struct ExecutorInner {
|
||||
pool: SqlitePool,
|
||||
logs_dir: PathBuf,
|
||||
shutdown: CancellationToken,
|
||||
running: Mutex<HashMap<String, RunningHandle>>,
|
||||
events: broadcast::Sender<ExecutionEvent>,
|
||||
http: reqwest::Client,
|
||||
log_config: LogRetentionConfig,
|
||||
cleanup_lock: AsyncMutex<()>,
|
||||
}
|
||||
|
||||
struct RunningHandle {
|
||||
run_id: Option<i64>,
|
||||
cancellation: CancellationToken,
|
||||
}
|
||||
|
||||
#[derive(Clone)]
|
||||
struct LogSink {
|
||||
file: Arc<AsyncMutex<tokio::fs::File>>,
|
||||
size: Arc<AtomicU64>,
|
||||
truncated: Arc<AtomicBool>,
|
||||
run_id: i64,
|
||||
events: broadcast::Sender<ExecutionEvent>,
|
||||
secrets: Arc<Vec<String>>,
|
||||
}
|
||||
|
||||
struct ScriptOutcome {
|
||||
status: RunStatus,
|
||||
exit_code: Option<i32>,
|
||||
error_message: Option<String>,
|
||||
}
|
||||
|
||||
enum ProcessEnd {
|
||||
Exited(std::process::ExitStatus),
|
||||
TimedOut,
|
||||
Cancelled,
|
||||
}
|
||||
|
||||
impl TaskExecutor {
|
||||
pub fn new(pool: SqlitePool, logs_dir: PathBuf, log_config: LogRetentionConfig) -> Self {
|
||||
let (events, _) = broadcast::channel(512);
|
||||
Self {
|
||||
inner: Arc::new(ExecutorInner {
|
||||
pool,
|
||||
logs_dir,
|
||||
shutdown: CancellationToken::new(),
|
||||
running: Mutex::new(HashMap::new()),
|
||||
events,
|
||||
http: reqwest::Client::builder()
|
||||
.user_agent("TaskPilot/0.1")
|
||||
.redirect(reqwest::redirect::Policy::limited(10))
|
||||
.build()
|
||||
.unwrap_or_default(),
|
||||
log_config,
|
||||
cleanup_lock: AsyncMutex::new(()),
|
||||
}),
|
||||
}
|
||||
}
|
||||
|
||||
pub async fn trigger(
|
||||
&self,
|
||||
task: &TaskResponse,
|
||||
trigger: RunTrigger,
|
||||
) -> ApiResult<TaskRunResponse> {
|
||||
let cancellation = CancellationToken::new();
|
||||
let already_running = {
|
||||
let mut running = lock_running(&self.inner.running);
|
||||
if running.contains_key(&task.id) {
|
||||
true
|
||||
} else {
|
||||
running.insert(
|
||||
task.id.clone(),
|
||||
RunningHandle {
|
||||
run_id: None,
|
||||
cancellation: cancellation.clone(),
|
||||
},
|
||||
);
|
||||
false
|
||||
}
|
||||
};
|
||||
if already_running {
|
||||
let run = repository::create_skipped(
|
||||
&self.inner.pool,
|
||||
&task.id,
|
||||
trigger,
|
||||
"同一任务已有运行实例,本次触发已跳过",
|
||||
)
|
||||
.await?;
|
||||
self.emit_run(&run);
|
||||
return Ok(run);
|
||||
}
|
||||
|
||||
let run = match repository::create_queued(&self.inner.pool, &task.id, trigger).await {
|
||||
Ok(run) => run,
|
||||
Err(error) => {
|
||||
lock_running(&self.inner.running).remove(&task.id);
|
||||
return Err(error);
|
||||
}
|
||||
};
|
||||
if let Some(handle) = lock_running(&self.inner.running).get_mut(&task.id) {
|
||||
handle.run_id = Some(run.id);
|
||||
}
|
||||
self.emit_run(&run);
|
||||
|
||||
let executor = self.clone();
|
||||
let task = task.clone();
|
||||
tokio::spawn(async move {
|
||||
executor.execute_run(task, run.id, cancellation).await;
|
||||
});
|
||||
|
||||
Ok(run)
|
||||
}
|
||||
|
||||
pub fn cancel_task(&self, task_id: &str) -> bool {
|
||||
let mut running = lock_running(&self.inner.running);
|
||||
if let Some(handle) = running.remove(task_id) {
|
||||
handle.cancellation.cancel();
|
||||
true
|
||||
} else {
|
||||
false
|
||||
}
|
||||
}
|
||||
|
||||
pub fn cancel_run(&self, run_id: i64) -> bool {
|
||||
let mut running = lock_running(&self.inner.running);
|
||||
let task_id = running
|
||||
.iter()
|
||||
.find_map(|(task_id, handle)| (handle.run_id == Some(run_id)).then(|| task_id.clone()));
|
||||
if let Some(task_id) = task_id
|
||||
&& let Some(handle) = running.remove(&task_id)
|
||||
{
|
||||
handle.cancellation.cancel();
|
||||
return true;
|
||||
}
|
||||
false
|
||||
}
|
||||
|
||||
pub fn subscribe(&self) -> broadcast::Receiver<ExecutionEvent> {
|
||||
self.inner.events.subscribe()
|
||||
}
|
||||
|
||||
pub fn log_path(&self, task_id: &str, run_id: i64) -> PathBuf {
|
||||
self.inner
|
||||
.logs_dir
|
||||
.join(task_id)
|
||||
.join(format!("{run_id}.log"))
|
||||
}
|
||||
|
||||
pub fn shutdown(&self) {
|
||||
self.inner.shutdown.cancel();
|
||||
let running = lock_running(&self.inner.running);
|
||||
for handle in running.values() {
|
||||
handle.cancellation.cancel();
|
||||
}
|
||||
}
|
||||
|
||||
pub async fn cleanup_logs(&self) -> std::io::Result<()> {
|
||||
let _guard = self.inner.cleanup_lock.lock().await;
|
||||
let logs_dir = self.inner.logs_dir.clone();
|
||||
let config = self.inner.log_config.clone();
|
||||
tokio::task::spawn_blocking(move || cleanup_logs_sync(&logs_dir, &config))
|
||||
.await
|
||||
.map_err(std::io::Error::other)?
|
||||
}
|
||||
|
||||
async fn execute_run(&self, task: TaskResponse, run_id: i64, cancellation: CancellationToken) {
|
||||
let started_at = Utc::now();
|
||||
let started = Instant::now();
|
||||
if let Err(error) = repository::mark_running(&self.inner.pool, run_id, started_at).await {
|
||||
tracing::error!(run_id, error = %error, "failed to mark task run as running");
|
||||
lock_running(&self.inner.running).remove(&task.id);
|
||||
return;
|
||||
}
|
||||
self.emit_run_by_id(run_id).await;
|
||||
|
||||
let outcome = self.run_action(&task, run_id, &cancellation).await;
|
||||
let finished_at = Utc::now();
|
||||
let duration_ms = u64::try_from(started.elapsed().as_millis()).unwrap_or(u64::MAX);
|
||||
|
||||
if let Err(error) = repository::complete(
|
||||
&self.inner.pool,
|
||||
run_id,
|
||||
outcome.status,
|
||||
finished_at,
|
||||
duration_ms,
|
||||
outcome.exit_code,
|
||||
outcome.error_message.as_deref(),
|
||||
)
|
||||
.await
|
||||
{
|
||||
tracing::error!(run_id, error = %error, "failed to complete task run");
|
||||
}
|
||||
|
||||
self.emit_run_by_id(run_id).await;
|
||||
lock_running(&self.inner.running).remove(&task.id);
|
||||
if let Err(error) = self.cleanup_logs().await {
|
||||
tracing::warn!(%error, "failed to clean task logs");
|
||||
}
|
||||
}
|
||||
|
||||
async fn run_action(
|
||||
&self,
|
||||
task: &TaskResponse,
|
||||
run_id: i64,
|
||||
cancellation: &CancellationToken,
|
||||
) -> ScriptOutcome {
|
||||
match &task.action {
|
||||
TaskAction::Script {
|
||||
working_dir,
|
||||
interpreter,
|
||||
script,
|
||||
args,
|
||||
env,
|
||||
} => {
|
||||
let mut command = Command::new(interpreter);
|
||||
command
|
||||
.arg(script)
|
||||
.args(args)
|
||||
.envs(env)
|
||||
.current_dir(working_dir);
|
||||
self.run_command(task, run_id, cancellation, command).await
|
||||
}
|
||||
TaskAction::Process {
|
||||
executable,
|
||||
working_dir,
|
||||
args,
|
||||
env,
|
||||
} => {
|
||||
let mut command = Command::new(executable);
|
||||
command.args(args).envs(env);
|
||||
if let Some(working_dir) = working_dir {
|
||||
command.current_dir(working_dir);
|
||||
}
|
||||
self.run_command(task, run_id, cancellation, command).await
|
||||
}
|
||||
TaskAction::Http { .. } => self.run_http(task, run_id, cancellation).await,
|
||||
}
|
||||
}
|
||||
|
||||
async fn run_command(
|
||||
&self,
|
||||
task: &TaskResponse,
|
||||
run_id: i64,
|
||||
cancellation: &CancellationToken,
|
||||
mut command: Command,
|
||||
) -> ScriptOutcome {
|
||||
let log_path = self.log_path(&task.id, run_id);
|
||||
let secrets = sensitive_values(&task.action);
|
||||
let sink = match LogSink::create(
|
||||
&log_path,
|
||||
task,
|
||||
run_id,
|
||||
self.inner.events.clone(),
|
||||
secrets,
|
||||
)
|
||||
.await
|
||||
{
|
||||
Ok(sink) => sink,
|
||||
Err(error) => return failed_outcome(format!("无法创建运行日志: {error}")),
|
||||
};
|
||||
|
||||
let job = match ProcessJob::new() {
|
||||
Ok(job) => job,
|
||||
Err(error) => {
|
||||
sink.system(&format!("无法创建 Windows Job Object: {error}"))
|
||||
.await;
|
||||
return failed_outcome(format!("无法创建 Windows Job Object: {error}"));
|
||||
}
|
||||
};
|
||||
|
||||
command
|
||||
.stdin(Stdio::null())
|
||||
.stdout(Stdio::piped())
|
||||
.stderr(Stdio::piped())
|
||||
.kill_on_drop(true);
|
||||
#[cfg(windows)]
|
||||
{
|
||||
command.creation_flags(CREATE_NO_WINDOW);
|
||||
}
|
||||
|
||||
let mut child = match command.spawn() {
|
||||
Ok(child) => child,
|
||||
Err(error) => {
|
||||
sink.system(&format!("启动进程失败: {error}")).await;
|
||||
return failed_outcome(format!("启动进程失败: {error}"));
|
||||
}
|
||||
};
|
||||
|
||||
if let Err(error) = job.assign(&child) {
|
||||
let _ = child.kill().await;
|
||||
sink.system(&format!("关联 Job Object 失败: {error}")).await;
|
||||
return failed_outcome(format!("关联 Windows Job Object 失败: {error}"));
|
||||
}
|
||||
|
||||
let stdout_task = child
|
||||
.stdout
|
||||
.take()
|
||||
.map(|stdout| spawn_log_reader(stdout, "stdout", sink.clone()));
|
||||
let stderr_task = child
|
||||
.stderr
|
||||
.take()
|
||||
.map(|stderr| spawn_log_reader(stderr, "stderr", sink.clone()));
|
||||
|
||||
let process_end = tokio::select! {
|
||||
status = child.wait() => {
|
||||
match status {
|
||||
Ok(status) => ProcessEnd::Exited(status),
|
||||
Err(error) => {
|
||||
sink.system(&format!("等待进程失败: {error}")).await;
|
||||
return failed_outcome(format!("等待进程失败: {error}"));
|
||||
}
|
||||
}
|
||||
}
|
||||
_ = sleep(Duration::from_secs(task.timeout_secs)) => {
|
||||
if let Err(error) = job.terminate() {
|
||||
tracing::error!(task_id = %task.id, run_id, %error, "failed to terminate timed out job");
|
||||
}
|
||||
wait_after_termination(&mut child).await;
|
||||
ProcessEnd::TimedOut
|
||||
}
|
||||
_ = cancellation.cancelled() => {
|
||||
if let Err(error) = job.terminate() {
|
||||
tracing::error!(task_id = %task.id, run_id, %error, "failed to terminate cancelled job");
|
||||
}
|
||||
wait_after_termination(&mut child).await;
|
||||
ProcessEnd::Cancelled
|
||||
}
|
||||
_ = self.inner.shutdown.cancelled() => {
|
||||
if let Err(error) = job.terminate() {
|
||||
tracing::error!(task_id = %task.id, run_id, %error, "failed to terminate job during shutdown");
|
||||
}
|
||||
wait_after_termination(&mut child).await;
|
||||
ProcessEnd::Cancelled
|
||||
}
|
||||
};
|
||||
|
||||
if let Some(stdout_task) = stdout_task
|
||||
&& let Err(error) = stdout_task.await
|
||||
{
|
||||
tracing::warn!(task_id = %task.id, run_id, %error, "stdout capture task failed");
|
||||
}
|
||||
if let Some(stderr_task) = stderr_task
|
||||
&& let Err(error) = stderr_task.await
|
||||
{
|
||||
tracing::warn!(task_id = %task.id, run_id, %error, "stderr capture task failed");
|
||||
}
|
||||
|
||||
match process_end {
|
||||
ProcessEnd::Exited(status) => {
|
||||
let exit_code = status.code();
|
||||
if status.success() {
|
||||
sink.system("任务执行成功").await;
|
||||
ScriptOutcome {
|
||||
status: RunStatus::Succeeded,
|
||||
exit_code,
|
||||
error_message: None,
|
||||
}
|
||||
} else {
|
||||
let message = format!(
|
||||
"进程退出码为 {}",
|
||||
exit_code.map_or_else(|| "unknown".to_owned(), |code| code.to_string())
|
||||
);
|
||||
sink.system(&message).await;
|
||||
ScriptOutcome {
|
||||
status: RunStatus::Failed,
|
||||
exit_code,
|
||||
error_message: Some(message),
|
||||
}
|
||||
}
|
||||
}
|
||||
ProcessEnd::TimedOut => {
|
||||
let message = format!("任务执行超过 {} 秒,已终止进程树", task.timeout_secs);
|
||||
sink.system(&message).await;
|
||||
ScriptOutcome {
|
||||
status: RunStatus::TimedOut,
|
||||
exit_code: None,
|
||||
error_message: Some(message),
|
||||
}
|
||||
}
|
||||
ProcessEnd::Cancelled => {
|
||||
let message = "任务已取消".to_owned();
|
||||
sink.system(&message).await;
|
||||
ScriptOutcome {
|
||||
status: RunStatus::Cancelled,
|
||||
exit_code: None,
|
||||
error_message: Some(message),
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async fn run_http(
|
||||
&self,
|
||||
task: &TaskResponse,
|
||||
run_id: i64,
|
||||
cancellation: &CancellationToken,
|
||||
) -> ScriptOutcome {
|
||||
let TaskAction::Http {
|
||||
url,
|
||||
method,
|
||||
headers,
|
||||
body,
|
||||
expected_status,
|
||||
} = &task.action
|
||||
else {
|
||||
return failed_outcome("invalid HTTP action payload");
|
||||
};
|
||||
|
||||
let log_path = self.log_path(&task.id, run_id);
|
||||
let sink = match LogSink::create(
|
||||
&log_path,
|
||||
task,
|
||||
run_id,
|
||||
self.inner.events.clone(),
|
||||
sensitive_values(&task.action),
|
||||
)
|
||||
.await
|
||||
{
|
||||
Ok(sink) => sink,
|
||||
Err(error) => return failed_outcome(format!("无法创建运行日志: {error}")),
|
||||
};
|
||||
|
||||
let method = match method {
|
||||
HttpMethod::Get => Method::GET,
|
||||
HttpMethod::Post => Method::POST,
|
||||
};
|
||||
let mut request = self
|
||||
.inner
|
||||
.http
|
||||
.request(method.clone(), url)
|
||||
.timeout(Duration::from_secs(task.timeout_secs));
|
||||
for (name, value) in headers {
|
||||
request = request.header(name, value);
|
||||
}
|
||||
if let Some(body) = body {
|
||||
request = request.body(body.clone());
|
||||
}
|
||||
|
||||
sink.http(&format!("请求 {method} {url}")).await;
|
||||
let response = tokio::select! {
|
||||
response = request.send() => {
|
||||
match response {
|
||||
Ok(response) => response,
|
||||
Err(error) => {
|
||||
let message = if error.is_timeout() {
|
||||
format!("HTTP 请求超过 {} 秒", task.timeout_secs)
|
||||
} else {
|
||||
format!("HTTP 请求失败: {error}")
|
||||
};
|
||||
sink.system(&message).await;
|
||||
return ScriptOutcome {
|
||||
status: if error.is_timeout() {
|
||||
RunStatus::TimedOut
|
||||
} else {
|
||||
RunStatus::Failed
|
||||
},
|
||||
exit_code: None,
|
||||
error_message: Some(message),
|
||||
};
|
||||
}
|
||||
}
|
||||
}
|
||||
_ = cancellation.cancelled() => {
|
||||
sink.system("HTTP 请求已取消").await;
|
||||
return ScriptOutcome {
|
||||
status: RunStatus::Cancelled,
|
||||
exit_code: None,
|
||||
error_message: Some("HTTP 请求已取消".to_owned()),
|
||||
};
|
||||
}
|
||||
_ = self.inner.shutdown.cancelled() => {
|
||||
sink.system("HTTP 请求因服务关闭而取消").await;
|
||||
return ScriptOutcome {
|
||||
status: RunStatus::Cancelled,
|
||||
exit_code: None,
|
||||
error_message: Some("HTTP 请求因服务关闭而取消".to_owned()),
|
||||
};
|
||||
}
|
||||
};
|
||||
|
||||
let http_status = response.status();
|
||||
sink.http(&format!("响应状态 {http_status}")).await;
|
||||
let mut response_stream = response.bytes_stream();
|
||||
let mut response_body = Vec::new();
|
||||
let mut body_truncated = false;
|
||||
loop {
|
||||
let next = tokio::select! {
|
||||
chunk = response_stream.next() => chunk,
|
||||
_ = cancellation.cancelled() => {
|
||||
sink.system("读取 HTTP 响应已取消").await;
|
||||
return ScriptOutcome {
|
||||
status: RunStatus::Cancelled,
|
||||
exit_code: None,
|
||||
error_message: Some("读取 HTTP 响应已取消".to_owned()),
|
||||
};
|
||||
}
|
||||
_ = self.inner.shutdown.cancelled() => {
|
||||
sink.system("HTTP 响应读取因服务关闭而取消").await;
|
||||
return ScriptOutcome {
|
||||
status: RunStatus::Cancelled,
|
||||
exit_code: None,
|
||||
error_message: Some("HTTP 响应读取因服务关闭而取消".to_owned()),
|
||||
};
|
||||
}
|
||||
};
|
||||
|
||||
match next {
|
||||
Some(Ok(chunk)) => {
|
||||
let remaining = MAX_HTTP_LOG_BODY_BYTES.saturating_sub(response_body.len());
|
||||
let copy_length = remaining.min(chunk.len());
|
||||
response_body.extend_from_slice(&chunk[..copy_length]);
|
||||
if chunk.len() > copy_length {
|
||||
body_truncated = true;
|
||||
break;
|
||||
}
|
||||
}
|
||||
Some(Err(error)) => {
|
||||
let message = if error.is_timeout() {
|
||||
format!("读取 HTTP 响应超过 {} 秒", task.timeout_secs)
|
||||
} else {
|
||||
format!("读取 HTTP 响应失败: {error}")
|
||||
};
|
||||
sink.system(&message).await;
|
||||
return ScriptOutcome {
|
||||
status: if error.is_timeout() {
|
||||
RunStatus::TimedOut
|
||||
} else {
|
||||
RunStatus::Failed
|
||||
},
|
||||
exit_code: None,
|
||||
error_message: Some(message),
|
||||
};
|
||||
}
|
||||
None => break,
|
||||
}
|
||||
}
|
||||
let body_text = String::from_utf8_lossy(&response_body);
|
||||
if !body_text.is_empty() {
|
||||
sink.http(&body_text).await;
|
||||
}
|
||||
if body_truncated {
|
||||
sink.system(&format!(
|
||||
"HTTP 响应体超过 {MAX_HTTP_LOG_BODY_BYTES} 字节,日志仅保留前部内容"
|
||||
))
|
||||
.await;
|
||||
}
|
||||
|
||||
let expected = if expected_status.is_empty() {
|
||||
http_status.is_success()
|
||||
} else {
|
||||
expected_status.contains(&http_status.as_u16())
|
||||
};
|
||||
if expected {
|
||||
sink.system("HTTP 任务执行成功").await;
|
||||
ScriptOutcome {
|
||||
status: RunStatus::Succeeded,
|
||||
exit_code: Some(i32::from(http_status.as_u16())),
|
||||
error_message: None,
|
||||
}
|
||||
} else {
|
||||
let message = format!("HTTP 状态码 {http_status} 不在期望范围内");
|
||||
sink.system(&message).await;
|
||||
ScriptOutcome {
|
||||
status: RunStatus::Failed,
|
||||
exit_code: Some(i32::from(http_status.as_u16())),
|
||||
error_message: Some(message),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn emit_run(&self, run: &TaskRunResponse) {
|
||||
let _ = self
|
||||
.inner
|
||||
.events
|
||||
.send(ExecutionEvent::Run { run: run.clone() });
|
||||
}
|
||||
|
||||
async fn emit_run_by_id(&self, run_id: i64) {
|
||||
match repository::get(&self.inner.pool, run_id).await {
|
||||
Ok(Some(run)) => self.emit_run(&run),
|
||||
Ok(None) => {}
|
||||
Err(error) => {
|
||||
tracing::warn!(run_id, error = %error, "failed to load run for SSE event");
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl LogSink {
|
||||
async fn create(
|
||||
path: &Path,
|
||||
task: &TaskResponse,
|
||||
run_id: i64,
|
||||
events: broadcast::Sender<ExecutionEvent>,
|
||||
secrets: Vec<String>,
|
||||
) -> std::io::Result<Self> {
|
||||
if let Some(parent) = path.parent() {
|
||||
tokio::fs::create_dir_all(parent).await?;
|
||||
}
|
||||
let header = format!(
|
||||
"[system] TaskPilot run {run_id} for task {} started at {}\n",
|
||||
task.id,
|
||||
Utc::now().to_rfc3339()
|
||||
);
|
||||
tokio::fs::write(path, header.as_bytes()).await?;
|
||||
let file = tokio::fs::OpenOptions::new()
|
||||
.append(true)
|
||||
.open(path)
|
||||
.await?;
|
||||
let mut secrets = secrets;
|
||||
secrets.sort_by_key(|value| std::cmp::Reverse(value.len()));
|
||||
|
||||
Ok(Self {
|
||||
file: Arc::new(AsyncMutex::new(file)),
|
||||
size: Arc::new(AtomicU64::new(header.len() as u64)),
|
||||
truncated: Arc::new(AtomicBool::new(false)),
|
||||
run_id,
|
||||
events,
|
||||
secrets: Arc::new(secrets),
|
||||
})
|
||||
}
|
||||
|
||||
async fn append(&self, text: &str) {
|
||||
let redacted = redact_text(text, &self.secrets);
|
||||
let bytes = redacted.as_bytes();
|
||||
let current = self.size.load(Ordering::Relaxed);
|
||||
if current + bytes.len() as u64 > MAX_LOG_BYTES {
|
||||
if !self.truncated.swap(true, Ordering::Relaxed) {
|
||||
let notice = format!("[system] 日志超过 {MAX_LOG_BYTES} 字节,后续输出已丢弃\n");
|
||||
let mut file = self.file.lock().await;
|
||||
let _ = file.write_all(notice.as_bytes()).await;
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
let mut file = self.file.lock().await;
|
||||
if file.write_all(bytes).await.is_err() {
|
||||
return;
|
||||
}
|
||||
let next = self.size.fetch_add(bytes.len() as u64, Ordering::Relaxed) + bytes.len() as u64;
|
||||
let _ = self.events.send(ExecutionEvent::Log {
|
||||
run_id: self.run_id,
|
||||
offset: current,
|
||||
next_offset: next,
|
||||
content: redacted,
|
||||
});
|
||||
}
|
||||
|
||||
async fn system(&self, message: &str) {
|
||||
self.append(&format!("[system] {message}\n")).await;
|
||||
}
|
||||
|
||||
async fn http(&self, message: &str) {
|
||||
self.append(&format!("[http] {message}\n")).await;
|
||||
}
|
||||
}
|
||||
|
||||
fn lock_running(
|
||||
running: &Mutex<HashMap<String, RunningHandle>>,
|
||||
) -> std::sync::MutexGuard<'_, HashMap<String, RunningHandle>> {
|
||||
running
|
||||
.lock()
|
||||
.unwrap_or_else(|poisoned| poisoned.into_inner())
|
||||
}
|
||||
|
||||
fn spawn_log_reader<R>(
|
||||
reader: R,
|
||||
stream: &'static str,
|
||||
sink: LogSink,
|
||||
) -> tokio::task::JoinHandle<()>
|
||||
where
|
||||
R: AsyncRead + Unpin + Send + 'static,
|
||||
{
|
||||
tokio::spawn(async move {
|
||||
let mut reader = BufReader::new(reader);
|
||||
let mut line = Vec::new();
|
||||
loop {
|
||||
line.clear();
|
||||
match reader.read_until(b'\n', &mut line).await {
|
||||
Ok(0) => break,
|
||||
Ok(_) => {
|
||||
let text = String::from_utf8_lossy(&line);
|
||||
sink.append(&format!("[{stream}] {text}")).await;
|
||||
}
|
||||
Err(error) => {
|
||||
tracing::warn!(stream, %error, "failed to read task output");
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
async fn wait_after_termination(child: &mut Child) {
|
||||
if timeout(Duration::from_secs(TERMINATION_WAIT_SECS), child.wait())
|
||||
.await
|
||||
.is_err()
|
||||
{
|
||||
let _ = child.kill().await;
|
||||
}
|
||||
}
|
||||
|
||||
fn sensitive_values(action: &TaskAction) -> Vec<String> {
|
||||
match action {
|
||||
TaskAction::Script { env, .. } | TaskAction::Process { env, .. } => env
|
||||
.iter()
|
||||
.filter(|(key, value)| is_sensitive_name(key) && !value.is_empty())
|
||||
.map(|(_, value)| value.clone())
|
||||
.collect(),
|
||||
TaskAction::Http { headers, .. } => headers
|
||||
.iter()
|
||||
.filter(|(key, value)| is_sensitive_name(key) && !value.is_empty())
|
||||
.map(|(_, value)| value.clone())
|
||||
.collect(),
|
||||
}
|
||||
}
|
||||
|
||||
fn is_sensitive_name(name: &str) -> bool {
|
||||
let name = name.to_ascii_uppercase();
|
||||
[
|
||||
"SECRET",
|
||||
"TOKEN",
|
||||
"PASSWORD",
|
||||
"PASS",
|
||||
"API_KEY",
|
||||
"PRIVATE_KEY",
|
||||
"CREDENTIAL",
|
||||
"AUTHORIZATION",
|
||||
"AUTH",
|
||||
]
|
||||
.iter()
|
||||
.any(|marker| name.contains(marker))
|
||||
}
|
||||
|
||||
fn redact_text(text: &str, secrets: &[String]) -> String {
|
||||
secrets.iter().fold(text.to_owned(), |current, secret| {
|
||||
current.replace(secret, "[REDACTED]")
|
||||
})
|
||||
}
|
||||
|
||||
fn failed_outcome(message: impl Into<String>) -> ScriptOutcome {
|
||||
ScriptOutcome {
|
||||
status: RunStatus::Failed,
|
||||
exit_code: None,
|
||||
error_message: Some(message.into()),
|
||||
}
|
||||
}
|
||||
|
||||
fn cleanup_logs_sync(root: &Path, config: &LogRetentionConfig) -> std::io::Result<()> {
|
||||
if !root.exists() {
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
let mut files = Vec::new();
|
||||
collect_log_files(root, &mut files)?;
|
||||
let retention = Duration::from_secs(config.retention_days.saturating_mul(86_400));
|
||||
let cutoff = SystemTime::now().checked_sub(retention);
|
||||
|
||||
if let Some(cutoff) = cutoff {
|
||||
files.retain(|(path, metadata)| {
|
||||
let expired = metadata
|
||||
.modified()
|
||||
.map(|modified| modified < cutoff)
|
||||
.unwrap_or(false);
|
||||
if expired {
|
||||
let _ = std::fs::remove_file(path);
|
||||
false
|
||||
} else {
|
||||
true
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
files.sort_by_key(|(_, metadata)| metadata.modified().unwrap_or(SystemTime::UNIX_EPOCH));
|
||||
let mut total = files
|
||||
.iter()
|
||||
.map(|(_, metadata)| metadata.len())
|
||||
.sum::<u64>();
|
||||
for (path, metadata) in files {
|
||||
if total <= config.max_total_bytes {
|
||||
break;
|
||||
}
|
||||
if std::fs::remove_file(&path).is_ok() {
|
||||
total = total.saturating_sub(metadata.len());
|
||||
}
|
||||
}
|
||||
|
||||
remove_empty_directories(root)?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn collect_log_files(
|
||||
directory: &Path,
|
||||
files: &mut Vec<(PathBuf, std::fs::Metadata)>,
|
||||
) -> std::io::Result<()> {
|
||||
for entry in std::fs::read_dir(directory)? {
|
||||
let entry = entry?;
|
||||
let file_type = entry.file_type()?;
|
||||
if file_type.is_symlink() {
|
||||
continue;
|
||||
}
|
||||
if file_type.is_dir() {
|
||||
collect_log_files(&entry.path(), files)?;
|
||||
} else if file_type.is_file() {
|
||||
files.push((entry.path(), entry.metadata()?));
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn remove_empty_directories(root: &Path) -> std::io::Result<()> {
|
||||
for entry in std::fs::read_dir(root)? {
|
||||
let entry = entry?;
|
||||
if entry.file_type()?.is_dir() {
|
||||
let path = entry.path();
|
||||
remove_empty_directories(&path)?;
|
||||
if std::fs::read_dir(&path)?.next().is_none() {
|
||||
let _ = std::fs::remove_dir(path);
|
||||
}
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn terminal_statuses_are_identified() {
|
||||
assert!(!RunStatus::Queued.is_terminal());
|
||||
assert!(!RunStatus::Running.is_terminal());
|
||||
assert!(RunStatus::Succeeded.is_terminal());
|
||||
assert!(RunStatus::Failed.is_terminal());
|
||||
assert!(RunStatus::TimedOut.is_terminal());
|
||||
assert!(RunStatus::Cancelled.is_terminal());
|
||||
assert!(RunStatus::Skipped.is_terminal());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn redacts_sensitive_values() {
|
||||
let secrets = vec!["super-secret".to_owned(), "token-value".to_owned()];
|
||||
let output = redact_text("found super-secret and token-value", &secrets);
|
||||
|
||||
assert_eq!(output, "found [REDACTED] and [REDACTED]");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn identifies_sensitive_environment_names() {
|
||||
assert!(is_sensitive_name("API_TOKEN"));
|
||||
assert!(is_sensitive_name("DB_PASSWORD"));
|
||||
assert!(is_sensitive_name("Authorization"));
|
||||
assert!(!is_sensitive_name("PYTHONUNBUFFERED"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn cleanup_enforces_total_log_capacity() {
|
||||
let temp = tempfile::TempDir::new().unwrap();
|
||||
for index in 0..6 {
|
||||
std::fs::write(temp.path().join(format!("run-{index}.log")), vec![b'x'; 4]).unwrap();
|
||||
std::thread::sleep(std::time::Duration::from_millis(5));
|
||||
}
|
||||
|
||||
cleanup_logs_sync(
|
||||
temp.path(),
|
||||
&LogRetentionConfig {
|
||||
retention_days: 30,
|
||||
max_total_bytes: 10,
|
||||
},
|
||||
)
|
||||
.unwrap();
|
||||
let total = std::fs::read_dir(temp.path())
|
||||
.unwrap()
|
||||
.map(|entry| entry.unwrap().metadata().unwrap().len())
|
||||
.sum::<u64>();
|
||||
assert!(total <= 10);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,4 @@
|
||||
pub mod executor;
|
||||
pub mod model;
|
||||
pub mod repository;
|
||||
pub mod routes;
|
||||
@@ -0,0 +1,98 @@
|
||||
use chrono::{DateTime, Utc};
|
||||
use serde::{Deserialize, Serialize};
|
||||
use utoipa::{IntoParams, ToSchema};
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, ToSchema)]
|
||||
#[serde(rename_all = "snake_case")]
|
||||
pub enum RunTrigger {
|
||||
Schedule,
|
||||
Manual,
|
||||
Retry,
|
||||
}
|
||||
|
||||
impl RunTrigger {
|
||||
pub const fn as_str(self) -> &'static str {
|
||||
match self {
|
||||
Self::Schedule => "schedule",
|
||||
Self::Manual => "manual",
|
||||
Self::Retry => "retry",
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, ToSchema)]
|
||||
#[serde(rename_all = "snake_case")]
|
||||
pub enum RunStatus {
|
||||
Queued,
|
||||
Running,
|
||||
Succeeded,
|
||||
Failed,
|
||||
TimedOut,
|
||||
Cancelled,
|
||||
Skipped,
|
||||
}
|
||||
|
||||
impl RunStatus {
|
||||
pub const fn as_str(self) -> &'static str {
|
||||
match self {
|
||||
Self::Queued => "queued",
|
||||
Self::Running => "running",
|
||||
Self::Succeeded => "succeeded",
|
||||
Self::Failed => "failed",
|
||||
Self::TimedOut => "timed_out",
|
||||
Self::Cancelled => "cancelled",
|
||||
Self::Skipped => "skipped",
|
||||
}
|
||||
}
|
||||
|
||||
pub const fn is_terminal(self) -> bool {
|
||||
matches!(
|
||||
self,
|
||||
Self::Succeeded | Self::Failed | Self::TimedOut | Self::Cancelled | Self::Skipped
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, ToSchema)]
|
||||
pub struct TaskRunResponse {
|
||||
pub id: i64,
|
||||
pub task_id: String,
|
||||
pub trigger: RunTrigger,
|
||||
pub status: RunStatus,
|
||||
pub started_at: Option<DateTime<Utc>>,
|
||||
pub finished_at: Option<DateTime<Utc>>,
|
||||
pub duration_ms: Option<u64>,
|
||||
pub exit_code: Option<i32>,
|
||||
pub error_message: Option<String>,
|
||||
pub created_at: DateTime<Utc>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize, ToSchema)]
|
||||
pub struct TaskRunListResponse {
|
||||
pub items: Vec<TaskRunResponse>,
|
||||
pub page: u32,
|
||||
pub page_size: u32,
|
||||
pub total: u64,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize, IntoParams)]
|
||||
#[into_params(parameter_in = Query)]
|
||||
pub struct RunListQuery {
|
||||
pub page: Option<u32>,
|
||||
pub page_size: Option<u32>,
|
||||
pub status: Option<RunStatus>,
|
||||
pub trigger: Option<RunTrigger>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize, IntoParams)]
|
||||
#[into_params(parameter_in = Query)]
|
||||
pub struct LogChunkQuery {
|
||||
pub offset: Option<u64>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize, ToSchema)]
|
||||
pub struct LogChunkResponse {
|
||||
pub content: String,
|
||||
pub next_offset: u64,
|
||||
pub complete: bool,
|
||||
}
|
||||
@@ -0,0 +1,257 @@
|
||||
use chrono::{DateTime, Utc};
|
||||
use sqlx::{FromRow, SqlitePool};
|
||||
|
||||
use crate::{
|
||||
error::{ApiError, ApiResult},
|
||||
runs::model::{RunListQuery, RunStatus, RunTrigger, TaskRunListResponse, TaskRunResponse},
|
||||
};
|
||||
|
||||
const DEFAULT_PAGE_SIZE: u32 = 20;
|
||||
const MAX_PAGE_SIZE: u32 = 100;
|
||||
|
||||
#[derive(Debug, FromRow)]
|
||||
struct TaskRunRow {
|
||||
id: i64,
|
||||
task_id: String,
|
||||
trigger_type: String,
|
||||
status: String,
|
||||
started_at: Option<DateTime<Utc>>,
|
||||
finished_at: Option<DateTime<Utc>>,
|
||||
duration_ms: Option<i64>,
|
||||
exit_code: Option<i64>,
|
||||
error_message: Option<String>,
|
||||
created_at: DateTime<Utc>,
|
||||
}
|
||||
|
||||
impl TryFrom<TaskRunRow> for TaskRunResponse {
|
||||
type Error = ApiError;
|
||||
|
||||
fn try_from(row: TaskRunRow) -> Result<Self, Self::Error> {
|
||||
Ok(Self {
|
||||
id: row.id,
|
||||
task_id: row.task_id,
|
||||
trigger: parse_trigger(&row.trigger_type)?,
|
||||
status: parse_status(&row.status)?,
|
||||
started_at: row.started_at,
|
||||
finished_at: row.finished_at,
|
||||
duration_ms: row
|
||||
.duration_ms
|
||||
.map(u64::try_from)
|
||||
.transpose()
|
||||
.map_err(ApiError::internal)?,
|
||||
exit_code: row
|
||||
.exit_code
|
||||
.map(i32::try_from)
|
||||
.transpose()
|
||||
.map_err(ApiError::internal)?,
|
||||
error_message: row.error_message,
|
||||
created_at: row.created_at,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
pub async fn create_queued(
|
||||
pool: &SqlitePool,
|
||||
task_id: &str,
|
||||
trigger: RunTrigger,
|
||||
) -> ApiResult<TaskRunResponse> {
|
||||
let now = Utc::now();
|
||||
let result = sqlx::query(
|
||||
"INSERT INTO task_runs (
|
||||
task_id, trigger_type, status, created_at
|
||||
) VALUES (?, ?, ?, ?)",
|
||||
)
|
||||
.bind(task_id)
|
||||
.bind(trigger.as_str())
|
||||
.bind(RunStatus::Queued.as_str())
|
||||
.bind(now)
|
||||
.execute(pool)
|
||||
.await?;
|
||||
|
||||
Ok(TaskRunResponse {
|
||||
id: result.last_insert_rowid(),
|
||||
task_id: task_id.to_owned(),
|
||||
trigger,
|
||||
status: RunStatus::Queued,
|
||||
started_at: None,
|
||||
finished_at: None,
|
||||
duration_ms: None,
|
||||
exit_code: None,
|
||||
error_message: None,
|
||||
created_at: now,
|
||||
})
|
||||
}
|
||||
|
||||
pub async fn create_skipped(
|
||||
pool: &SqlitePool,
|
||||
task_id: &str,
|
||||
trigger: RunTrigger,
|
||||
message: &str,
|
||||
) -> ApiResult<TaskRunResponse> {
|
||||
let now = Utc::now();
|
||||
let result = sqlx::query(
|
||||
"INSERT INTO task_runs (
|
||||
task_id, trigger_type, status, started_at, finished_at,
|
||||
duration_ms, error_message, created_at
|
||||
) VALUES (?, ?, ?, ?, ?, 0, ?, ?)",
|
||||
)
|
||||
.bind(task_id)
|
||||
.bind(trigger.as_str())
|
||||
.bind(RunStatus::Skipped.as_str())
|
||||
.bind(now)
|
||||
.bind(now)
|
||||
.bind(message)
|
||||
.bind(now)
|
||||
.execute(pool)
|
||||
.await?;
|
||||
|
||||
Ok(TaskRunResponse {
|
||||
id: result.last_insert_rowid(),
|
||||
task_id: task_id.to_owned(),
|
||||
trigger,
|
||||
status: RunStatus::Skipped,
|
||||
started_at: Some(now),
|
||||
finished_at: Some(now),
|
||||
duration_ms: Some(0),
|
||||
exit_code: None,
|
||||
error_message: Some(message.to_owned()),
|
||||
created_at: now,
|
||||
})
|
||||
}
|
||||
|
||||
pub async fn mark_running(
|
||||
pool: &SqlitePool,
|
||||
run_id: i64,
|
||||
started_at: DateTime<Utc>,
|
||||
) -> ApiResult<()> {
|
||||
sqlx::query("UPDATE task_runs SET status = ?, started_at = ? WHERE id = ?")
|
||||
.bind(RunStatus::Running.as_str())
|
||||
.bind(started_at)
|
||||
.bind(run_id)
|
||||
.execute(pool)
|
||||
.await?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub async fn complete(
|
||||
pool: &SqlitePool,
|
||||
run_id: i64,
|
||||
status: RunStatus,
|
||||
finished_at: DateTime<Utc>,
|
||||
duration_ms: u64,
|
||||
exit_code: Option<i32>,
|
||||
error_message: Option<&str>,
|
||||
) -> ApiResult<()> {
|
||||
sqlx::query(
|
||||
"UPDATE task_runs SET
|
||||
status = ?, finished_at = ?, duration_ms = ?,
|
||||
exit_code = ?, error_message = ?
|
||||
WHERE id = ?",
|
||||
)
|
||||
.bind(status.as_str())
|
||||
.bind(finished_at)
|
||||
.bind(duration_ms as i64)
|
||||
.bind(exit_code.map(i64::from))
|
||||
.bind(error_message)
|
||||
.bind(run_id)
|
||||
.execute(pool)
|
||||
.await?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub async fn get(pool: &SqlitePool, run_id: i64) -> ApiResult<Option<TaskRunResponse>> {
|
||||
let row = sqlx::query_as::<_, TaskRunRow>(
|
||||
"SELECT
|
||||
id, task_id, trigger_type, status, started_at, finished_at,
|
||||
duration_ms, exit_code, error_message, created_at
|
||||
FROM task_runs
|
||||
WHERE id = ?",
|
||||
)
|
||||
.bind(run_id)
|
||||
.fetch_optional(pool)
|
||||
.await?;
|
||||
|
||||
row.map(TaskRunResponse::try_from).transpose()
|
||||
}
|
||||
|
||||
pub async fn list_for_task(
|
||||
pool: &SqlitePool,
|
||||
task_id: &str,
|
||||
query: RunListQuery,
|
||||
) -> ApiResult<TaskRunListResponse> {
|
||||
let page = query.page.unwrap_or(1).max(1);
|
||||
let page_size = query
|
||||
.page_size
|
||||
.unwrap_or(DEFAULT_PAGE_SIZE)
|
||||
.clamp(1, MAX_PAGE_SIZE);
|
||||
let offset = i64::from(page - 1) * i64::from(page_size);
|
||||
let status = query.status.map(RunStatus::as_str);
|
||||
let trigger = query.trigger.map(RunTrigger::as_str);
|
||||
|
||||
let total: i64 = sqlx::query_scalar(
|
||||
"SELECT COUNT(*) FROM task_runs
|
||||
WHERE task_id = ?
|
||||
AND (? IS NULL OR status = ?)
|
||||
AND (? IS NULL OR trigger_type = ?)",
|
||||
)
|
||||
.bind(task_id)
|
||||
.bind(status)
|
||||
.bind(status)
|
||||
.bind(trigger)
|
||||
.bind(trigger)
|
||||
.fetch_one(pool)
|
||||
.await?;
|
||||
let rows = sqlx::query_as::<_, TaskRunRow>(
|
||||
"SELECT
|
||||
id, task_id, trigger_type, status, started_at, finished_at,
|
||||
duration_ms, exit_code, error_message, created_at
|
||||
FROM task_runs
|
||||
WHERE task_id = ?
|
||||
AND (? IS NULL OR status = ?)
|
||||
AND (? IS NULL OR trigger_type = ?)
|
||||
ORDER BY created_at DESC, id DESC
|
||||
LIMIT ? OFFSET ?",
|
||||
)
|
||||
.bind(task_id)
|
||||
.bind(status)
|
||||
.bind(status)
|
||||
.bind(trigger)
|
||||
.bind(trigger)
|
||||
.bind(i64::from(page_size))
|
||||
.bind(offset)
|
||||
.fetch_all(pool)
|
||||
.await?;
|
||||
let items = rows
|
||||
.into_iter()
|
||||
.map(TaskRunResponse::try_from)
|
||||
.collect::<ApiResult<Vec<_>>>()?;
|
||||
|
||||
Ok(TaskRunListResponse {
|
||||
items,
|
||||
page,
|
||||
page_size,
|
||||
total: u64::try_from(total).map_err(ApiError::internal)?,
|
||||
})
|
||||
}
|
||||
|
||||
fn parse_trigger(value: &str) -> ApiResult<RunTrigger> {
|
||||
match value {
|
||||
"schedule" => Ok(RunTrigger::Schedule),
|
||||
"manual" => Ok(RunTrigger::Manual),
|
||||
"retry" => Ok(RunTrigger::Retry),
|
||||
_ => Err(ApiError::internal(format!("unknown run trigger: {value}"))),
|
||||
}
|
||||
}
|
||||
|
||||
fn parse_status(value: &str) -> ApiResult<RunStatus> {
|
||||
match value {
|
||||
"queued" => Ok(RunStatus::Queued),
|
||||
"running" => Ok(RunStatus::Running),
|
||||
"succeeded" => Ok(RunStatus::Succeeded),
|
||||
"failed" => Ok(RunStatus::Failed),
|
||||
"timed_out" => Ok(RunStatus::TimedOut),
|
||||
"cancelled" => Ok(RunStatus::Cancelled),
|
||||
"skipped" => Ok(RunStatus::Skipped),
|
||||
_ => Err(ApiError::internal(format!("unknown run status: {value}"))),
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,265 @@
|
||||
use axum::{
|
||||
Json, Router,
|
||||
extract::{Path, State},
|
||||
http::{HeaderValue, StatusCode, header},
|
||||
response::{
|
||||
IntoResponse, Response,
|
||||
sse::{Event, KeepAlive, Sse},
|
||||
},
|
||||
routing::{get, post},
|
||||
};
|
||||
use futures_util::{StreamExt, stream};
|
||||
use tokio::io::{AsyncReadExt, AsyncSeekExt};
|
||||
use tokio_stream::wrappers::BroadcastStream;
|
||||
|
||||
use crate::{
|
||||
error::{ApiError, ApiQuery, ApiResult},
|
||||
runs::{
|
||||
model::{
|
||||
LogChunkQuery, LogChunkResponse, RunListQuery, RunTrigger, TaskRunListResponse,
|
||||
TaskRunResponse,
|
||||
},
|
||||
repository,
|
||||
},
|
||||
state::AppState,
|
||||
tasks::service,
|
||||
};
|
||||
|
||||
const MAX_LOG_CHUNK_BYTES: u64 = 64 * 1024;
|
||||
|
||||
pub fn router() -> Router<AppState> {
|
||||
Router::new()
|
||||
.route("/api/v1/tasks/{id}/run", post(run_task))
|
||||
.route("/api/v1/tasks/{id}/runs", get(list_task_runs))
|
||||
.route("/api/v1/runs/{run_id}", get(get_run))
|
||||
.route("/api/v1/runs/{run_id}/logs", get(get_run_logs))
|
||||
.route(
|
||||
"/api/v1/runs/{run_id}/logs/download",
|
||||
get(download_run_logs),
|
||||
)
|
||||
.route("/api/v1/runs/{run_id}/cancel", post(cancel_run))
|
||||
.route("/api/v1/events", get(execution_events))
|
||||
}
|
||||
|
||||
#[utoipa::path(
|
||||
post,
|
||||
path = "/api/v1/tasks/{id}/run",
|
||||
tag = "runs",
|
||||
params(("id" = String, Path, description = "Stable task identifier")),
|
||||
responses(
|
||||
(status = 202, description = "Manual run accepted", body = TaskRunResponse),
|
||||
(status = 404, description = "Task not found", body = crate::error::ErrorResponse),
|
||||
(status = 422, description = "Action executor not implemented", body = crate::error::ErrorResponse)
|
||||
)
|
||||
)]
|
||||
pub async fn run_task(
|
||||
State(state): State<AppState>,
|
||||
Path(id): Path<String>,
|
||||
) -> ApiResult<(StatusCode, Json<TaskRunResponse>)> {
|
||||
let task = service::get(&state.pool, &id).await?;
|
||||
let run = state.executor.trigger(&task, RunTrigger::Manual).await?;
|
||||
Ok((StatusCode::ACCEPTED, Json(run)))
|
||||
}
|
||||
|
||||
#[utoipa::path(
|
||||
get,
|
||||
path = "/api/v1/tasks/{id}/runs",
|
||||
tag = "runs",
|
||||
params(
|
||||
("id" = String, Path, description = "Stable task identifier"),
|
||||
RunListQuery
|
||||
),
|
||||
responses(
|
||||
(status = 200, description = "Task run history", body = TaskRunListResponse)
|
||||
)
|
||||
)]
|
||||
pub async fn list_task_runs(
|
||||
State(state): State<AppState>,
|
||||
Path(id): Path<String>,
|
||||
ApiQuery(query): ApiQuery<RunListQuery>,
|
||||
) -> ApiResult<Json<TaskRunListResponse>> {
|
||||
Ok(Json(
|
||||
repository::list_for_task(&state.pool, &id, query).await?,
|
||||
))
|
||||
}
|
||||
|
||||
#[utoipa::path(
|
||||
get,
|
||||
path = "/api/v1/runs/{run_id}",
|
||||
tag = "runs",
|
||||
params(("run_id" = i64, Path, description = "Run identifier")),
|
||||
responses(
|
||||
(status = 200, description = "Task run detail", body = TaskRunResponse),
|
||||
(status = 404, description = "Run not found", body = crate::error::ErrorResponse)
|
||||
)
|
||||
)]
|
||||
pub async fn get_run(
|
||||
State(state): State<AppState>,
|
||||
Path(run_id): Path<i64>,
|
||||
) -> ApiResult<Json<TaskRunResponse>> {
|
||||
let run = repository::get(&state.pool, run_id)
|
||||
.await?
|
||||
.ok_or_else(|| ApiError::run_not_found(format!("运行记录 {run_id} 不存在")))?;
|
||||
Ok(Json(run))
|
||||
}
|
||||
|
||||
#[utoipa::path(
|
||||
get,
|
||||
path = "/api/v1/runs/{run_id}/logs",
|
||||
tag = "runs",
|
||||
params(
|
||||
("run_id" = i64, Path, description = "Run identifier"),
|
||||
LogChunkQuery
|
||||
),
|
||||
responses(
|
||||
(status = 200, description = "Incremental task run log", body = LogChunkResponse),
|
||||
(status = 404, description = "Run not found", body = crate::error::ErrorResponse)
|
||||
)
|
||||
)]
|
||||
pub async fn get_run_logs(
|
||||
State(state): State<AppState>,
|
||||
Path(run_id): Path<i64>,
|
||||
ApiQuery(query): ApiQuery<LogChunkQuery>,
|
||||
) -> ApiResult<Json<LogChunkResponse>> {
|
||||
let run = repository::get(&state.pool, run_id)
|
||||
.await?
|
||||
.ok_or_else(|| ApiError::run_not_found(format!("运行记录 {run_id} 不存在")))?;
|
||||
let offset = query.offset.unwrap_or(0);
|
||||
let path = state.executor.log_path(&run.task_id, run.id);
|
||||
|
||||
let mut file = match tokio::fs::File::open(&path).await {
|
||||
Ok(file) => file,
|
||||
Err(error) if error.kind() == std::io::ErrorKind::NotFound => {
|
||||
return Ok(Json(LogChunkResponse {
|
||||
content: String::new(),
|
||||
next_offset: offset,
|
||||
complete: run.status.is_terminal(),
|
||||
}));
|
||||
}
|
||||
Err(error) => return Err(ApiError::internal(error)),
|
||||
};
|
||||
|
||||
let length = file.metadata().await.map_err(ApiError::internal)?.len();
|
||||
if offset > length {
|
||||
return Err(ApiError::validation(
|
||||
"offset",
|
||||
"日志读取偏移不能大于当前日志长度",
|
||||
));
|
||||
}
|
||||
file.seek(std::io::SeekFrom::Start(offset))
|
||||
.await
|
||||
.map_err(ApiError::internal)?;
|
||||
let mut bytes = Vec::new();
|
||||
file.take(MAX_LOG_CHUNK_BYTES)
|
||||
.read_to_end(&mut bytes)
|
||||
.await
|
||||
.map_err(ApiError::internal)?;
|
||||
|
||||
Ok(Json(LogChunkResponse {
|
||||
content: String::from_utf8_lossy(&bytes).into_owned(),
|
||||
next_offset: offset + bytes.len() as u64,
|
||||
complete: run.status.is_terminal(),
|
||||
}))
|
||||
}
|
||||
|
||||
#[utoipa::path(
|
||||
post,
|
||||
path = "/api/v1/runs/{run_id}/cancel",
|
||||
tag = "runs",
|
||||
params(("run_id" = i64, Path, description = "Run identifier")),
|
||||
responses(
|
||||
(status = 202, description = "Cancellation requested", body = TaskRunResponse),
|
||||
(status = 404, description = "Run not found", body = crate::error::ErrorResponse),
|
||||
(status = 409, description = "Run is not cancellable", body = crate::error::ErrorResponse)
|
||||
)
|
||||
)]
|
||||
pub async fn cancel_run(
|
||||
State(state): State<AppState>,
|
||||
Path(run_id): Path<i64>,
|
||||
) -> ApiResult<(StatusCode, Json<TaskRunResponse>)> {
|
||||
let run = repository::get(&state.pool, run_id)
|
||||
.await?
|
||||
.ok_or_else(|| ApiError::run_not_found(format!("运行记录 {run_id} 不存在")))?;
|
||||
if run.status.is_terminal() {
|
||||
return Err(ApiError::run_not_cancellable("运行已经结束,无法取消"));
|
||||
}
|
||||
if !state.executor.cancel_run(run_id) {
|
||||
return Err(ApiError::run_not_cancellable(
|
||||
"运行不在当前实例的可取消状态中",
|
||||
));
|
||||
}
|
||||
Ok((StatusCode::ACCEPTED, Json(run)))
|
||||
}
|
||||
|
||||
#[utoipa::path(
|
||||
get,
|
||||
path = "/api/v1/runs/{run_id}/logs/download",
|
||||
tag = "runs",
|
||||
params(("run_id" = i64, Path, description = "Run identifier")),
|
||||
responses(
|
||||
(status = 200, description = "Plain text task log", body = String, content_type = "text/plain"),
|
||||
(status = 404, description = "Run or log not found", body = crate::error::ErrorResponse)
|
||||
)
|
||||
)]
|
||||
pub async fn download_run_logs(
|
||||
State(state): State<AppState>,
|
||||
Path(run_id): Path<i64>,
|
||||
) -> ApiResult<Response> {
|
||||
let run = repository::get(&state.pool, run_id)
|
||||
.await?
|
||||
.ok_or_else(|| ApiError::run_not_found(format!("运行记录 {run_id} 不存在")))?;
|
||||
let path = state.executor.log_path(&run.task_id, run.id);
|
||||
let content = tokio::fs::read(&path).await.map_err(|error| {
|
||||
if error.kind() == std::io::ErrorKind::NotFound {
|
||||
ApiError::run_log_not_found(format!("运行记录 {run_id} 还没有日志文件"))
|
||||
} else {
|
||||
ApiError::internal(error)
|
||||
}
|
||||
})?;
|
||||
let mut response = content.into_response();
|
||||
response.headers_mut().insert(
|
||||
header::CONTENT_TYPE,
|
||||
HeaderValue::from_static("text/plain; charset=utf-8"),
|
||||
);
|
||||
response.headers_mut().insert(
|
||||
header::CONTENT_DISPOSITION,
|
||||
format!("attachment; filename=\"run-{run_id}.log\"")
|
||||
.parse::<HeaderValue>()
|
||||
.map_err(ApiError::internal)?,
|
||||
);
|
||||
Ok(response)
|
||||
}
|
||||
|
||||
#[utoipa::path(
|
||||
get,
|
||||
path = "/api/v1/events",
|
||||
tag = "runs",
|
||||
responses(
|
||||
(status = 200, description = "Run status and log events", body = String, content_type = "text/event-stream")
|
||||
)
|
||||
)]
|
||||
pub async fn execution_events(
|
||||
State(state): State<AppState>,
|
||||
) -> Sse<impl futures_util::Stream<Item = Result<Event, std::convert::Infallible>>> {
|
||||
let receiver = state.executor.subscribe();
|
||||
let events = BroadcastStream::new(receiver).filter_map(|message| async move {
|
||||
match message {
|
||||
Ok(event) => {
|
||||
let event_name = event.event_name();
|
||||
let data = serde_json::to_string(&event).ok()?;
|
||||
Some(Ok(Event::default().event(event_name).data(data)))
|
||||
}
|
||||
Err(error) => {
|
||||
tracing::warn!(%error, "SSE subscriber lagged");
|
||||
Some(Ok(Event::default()
|
||||
.event("lagged")
|
||||
.data("{\"reason\":\"event stream lagged\"}")))
|
||||
}
|
||||
}
|
||||
});
|
||||
let events =
|
||||
stream::once(async { Ok(Event::default().event("ready").data("{\"status\":\"ok\"}")) })
|
||||
.chain(events);
|
||||
|
||||
Sse::new(events).keep_alive(KeepAlive::new().interval(std::time::Duration::from_secs(15)))
|
||||
}
|
||||
@@ -0,0 +1,205 @@
|
||||
use std::{
|
||||
collections::HashMap,
|
||||
str::FromStr,
|
||||
sync::{Arc, Mutex, MutexGuard},
|
||||
};
|
||||
|
||||
use chrono::{DateTime, Utc};
|
||||
use chrono_tz::Tz;
|
||||
use croner::Cron;
|
||||
use sqlx::SqlitePool;
|
||||
use tokio::task::JoinHandle;
|
||||
use tokio_util::sync::CancellationToken;
|
||||
|
||||
use crate::{
|
||||
error::ApiResult,
|
||||
runs::{executor::TaskExecutor, model::RunTrigger},
|
||||
tasks::{model::TaskAction, model::TaskResponse, repository},
|
||||
};
|
||||
|
||||
struct ScheduledTask {
|
||||
cancellation: CancellationToken,
|
||||
handle: JoinHandle<()>,
|
||||
}
|
||||
|
||||
struct SchedulerInner {
|
||||
executor: TaskExecutor,
|
||||
shutdown: CancellationToken,
|
||||
jobs: Mutex<HashMap<String, ScheduledTask>>,
|
||||
}
|
||||
|
||||
#[derive(Clone)]
|
||||
pub struct Scheduler {
|
||||
inner: Arc<SchedulerInner>,
|
||||
}
|
||||
|
||||
impl Scheduler {
|
||||
pub async fn start(pool: SqlitePool, executor: TaskExecutor) -> ApiResult<Self> {
|
||||
let scheduler = Self {
|
||||
inner: Arc::new(SchedulerInner {
|
||||
executor,
|
||||
shutdown: CancellationToken::new(),
|
||||
jobs: Mutex::new(HashMap::new()),
|
||||
}),
|
||||
};
|
||||
|
||||
for task in repository::list_enabled_script_tasks(&pool).await? {
|
||||
scheduler.refresh(task);
|
||||
}
|
||||
|
||||
Ok(scheduler)
|
||||
}
|
||||
|
||||
pub fn refresh(&self, task: TaskResponse) {
|
||||
self.cancel(&task.id);
|
||||
if !task.enabled || !matches!(task.action, TaskAction::Script { .. }) {
|
||||
return;
|
||||
}
|
||||
|
||||
let cancellation = CancellationToken::new();
|
||||
let task_for_loop = task.clone();
|
||||
let executor = self.inner.executor.clone();
|
||||
let shutdown = self.inner.shutdown.clone();
|
||||
let cancellation_for_loop = cancellation.clone();
|
||||
let handle = tokio::spawn(async move {
|
||||
schedule_loop(task_for_loop, executor, cancellation_for_loop, shutdown).await;
|
||||
});
|
||||
|
||||
lock_jobs(&self.inner.jobs).insert(
|
||||
task.id,
|
||||
ScheduledTask {
|
||||
cancellation,
|
||||
handle,
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
pub fn cancel(&self, task_id: &str) {
|
||||
if let Some(job) = lock_jobs(&self.inner.jobs).remove(task_id) {
|
||||
job.cancellation.cancel();
|
||||
job.handle.abort();
|
||||
}
|
||||
}
|
||||
|
||||
pub fn is_scheduled(&self, task_id: &str) -> bool {
|
||||
lock_jobs(&self.inner.jobs).contains_key(task_id)
|
||||
}
|
||||
|
||||
pub fn shutdown(&self) {
|
||||
self.inner.shutdown.cancel();
|
||||
let mut jobs = lock_jobs(&self.inner.jobs);
|
||||
for job in jobs.values() {
|
||||
job.cancellation.cancel();
|
||||
job.handle.abort();
|
||||
}
|
||||
jobs.clear();
|
||||
}
|
||||
}
|
||||
|
||||
impl Drop for SchedulerInner {
|
||||
fn drop(&mut self) {
|
||||
self.shutdown.cancel();
|
||||
let jobs = self
|
||||
.jobs
|
||||
.get_mut()
|
||||
.unwrap_or_else(|poisoned| poisoned.into_inner());
|
||||
for job in jobs.values() {
|
||||
job.cancellation.cancel();
|
||||
job.handle.abort();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async fn schedule_loop(
|
||||
task: TaskResponse,
|
||||
executor: TaskExecutor,
|
||||
cancellation: CancellationToken,
|
||||
shutdown: CancellationToken,
|
||||
) {
|
||||
loop {
|
||||
let now = Utc::now();
|
||||
let next = match next_occurrence(&task, now) {
|
||||
Ok(next) => next,
|
||||
Err(error) => {
|
||||
tracing::error!(task_id = %task.id, %error, "cron schedule calculation failed");
|
||||
return;
|
||||
}
|
||||
};
|
||||
let delay = (next - now).to_std().unwrap_or_default();
|
||||
|
||||
tokio::select! {
|
||||
_ = cancellation.cancelled() => return,
|
||||
_ = shutdown.cancelled() => return,
|
||||
_ = tokio::time::sleep(delay) => {
|
||||
if let Err(error) = executor.trigger(&task, RunTrigger::Schedule).await {
|
||||
tracing::error!(task_id = %task.id, %error, "scheduled task trigger failed");
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn next_occurrence(task: &TaskResponse, now: DateTime<Utc>) -> Result<DateTime<Utc>, String> {
|
||||
let timezone = Tz::from_str(&task.timezone)
|
||||
.map_err(|_| format!("invalid task timezone: {}", task.timezone))?;
|
||||
let cron = Cron::from_str(&task.cron).map_err(|error| format!("invalid task cron: {error}"))?;
|
||||
let local_now = now.with_timezone(&timezone);
|
||||
let next = cron
|
||||
.find_next_occurrence(&local_now, false)
|
||||
.map_err(|error| format!("failed to calculate next occurrence: {error}"))?;
|
||||
Ok(next.with_timezone(&Utc))
|
||||
}
|
||||
|
||||
fn lock_jobs(
|
||||
jobs: &Mutex<HashMap<String, ScheduledTask>>,
|
||||
) -> MutexGuard<'_, HashMap<String, ScheduledTask>> {
|
||||
jobs.lock().unwrap_or_else(|poisoned| poisoned.into_inner())
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use chrono::{TimeZone, Timelike};
|
||||
|
||||
use crate::tasks::model::{ConcurrencyPolicy, RetryPolicy};
|
||||
|
||||
use super::*;
|
||||
|
||||
fn task(cron: &str, timezone: &str) -> TaskResponse {
|
||||
TaskResponse {
|
||||
id: "schedule_test".to_owned(),
|
||||
name: "Schedule test".to_owned(),
|
||||
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(),
|
||||
interpreter: "cmd.exe".to_owned(),
|
||||
script: "/C".to_owned(),
|
||||
args: vec!["exit".to_owned(), "0".to_owned()],
|
||||
env: Default::default(),
|
||||
},
|
||||
created_at: Utc::now(),
|
||||
updated_at: Utc::now(),
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn calculates_next_occurrence_in_task_timezone() {
|
||||
let now = Utc.with_ymd_and_hms(2026, 1, 1, 0, 0, 0).unwrap();
|
||||
let next = next_occurrence(&task("0 0 9 * * *", "Asia/Shanghai"), now).unwrap();
|
||||
|
||||
assert_eq!(next, Utc.with_ymd_and_hms(2026, 1, 1, 1, 0, 0).unwrap());
|
||||
assert_eq!(next.hour(), 1);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rejects_unknown_timezone() {
|
||||
let now = Utc::now();
|
||||
let error = next_occurrence(&task("0 * * * * *", "Mars/Olympus"), now).unwrap_err();
|
||||
|
||||
assert!(error.contains("timezone"));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,75 @@
|
||||
use axum::{
|
||||
extract::{Request, State},
|
||||
http::{HeaderMap, Method, header},
|
||||
middleware::Next,
|
||||
response::Response,
|
||||
};
|
||||
|
||||
use crate::{
|
||||
error::{ApiError, ApiResult},
|
||||
state::AppState,
|
||||
};
|
||||
|
||||
pub const AUTH_HEADER: &str = "x-taskpilot-token";
|
||||
|
||||
pub async fn api_guard(
|
||||
State(state): State<AppState>,
|
||||
request: Request,
|
||||
next: Next,
|
||||
) -> ApiResult<Response> {
|
||||
let headers = request.headers();
|
||||
if let Some(origin) = headers.get(header::ORIGIN) {
|
||||
let origin = origin.to_str().unwrap_or_default();
|
||||
if !is_local_origin(origin) {
|
||||
return Err(ApiError::forbidden("请求 Origin 不在本地允许列表中"));
|
||||
}
|
||||
}
|
||||
|
||||
if !matches!(
|
||||
*request.method(),
|
||||
Method::GET | Method::HEAD | Method::OPTIONS
|
||||
) && !has_valid_token(headers, state.auth_token())
|
||||
{
|
||||
return Err(ApiError::forbidden("缺少有效的本地管理令牌"));
|
||||
}
|
||||
|
||||
Ok(next.run(request).await)
|
||||
}
|
||||
|
||||
fn has_valid_token(headers: &HeaderMap, expected: &str) -> bool {
|
||||
headers
|
||||
.get(AUTH_HEADER)
|
||||
.and_then(|value| value.to_str().ok())
|
||||
.is_some_and(|value| constant_time_eq(value.as_bytes(), expected.as_bytes()))
|
||||
}
|
||||
|
||||
fn constant_time_eq(left: &[u8], right: &[u8]) -> bool {
|
||||
if left.len() != right.len() {
|
||||
return false;
|
||||
}
|
||||
left.iter()
|
||||
.zip(right)
|
||||
.fold(0_u8, |difference, (left, right)| {
|
||||
difference | (left ^ right)
|
||||
})
|
||||
== 0
|
||||
}
|
||||
|
||||
fn is_local_origin(origin: &str) -> bool {
|
||||
url::Url::parse(origin).is_ok_and(|url| {
|
||||
url.scheme() == "http" && matches!(url.host_str(), Some("127.0.0.1" | "localhost"))
|
||||
})
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn accepts_local_origins_only() {
|
||||
assert!(is_local_origin("http://127.0.0.1:5173"));
|
||||
assert!(is_local_origin("http://localhost:3000"));
|
||||
assert!(!is_local_origin("http://127.0.0.1.example.com"));
|
||||
assert!(!is_local_origin("https://example.com"));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,55 @@
|
||||
use std::{env, path::PathBuf};
|
||||
|
||||
use sqlx::SqlitePool;
|
||||
|
||||
use crate::{
|
||||
error::{ApiError, ApiResult},
|
||||
runs::executor::{LogRetentionConfig, TaskExecutor},
|
||||
scheduler::Scheduler,
|
||||
};
|
||||
|
||||
#[derive(Clone)]
|
||||
pub struct AppState {
|
||||
pub pool: SqlitePool,
|
||||
pub scheduler: Scheduler,
|
||||
pub executor: TaskExecutor,
|
||||
auth_token: String,
|
||||
}
|
||||
|
||||
impl AppState {
|
||||
pub async fn start(pool: SqlitePool, logs_dir: PathBuf) -> ApiResult<Self> {
|
||||
tokio::fs::create_dir_all(&logs_dir)
|
||||
.await
|
||||
.map_err(ApiError::internal)?;
|
||||
let mut log_config = LogRetentionConfig::default();
|
||||
if let Ok(value) = env::var("TASK_PILOT_LOG_RETENTION_DAYS")
|
||||
&& let Ok(days) = value.parse::<u64>()
|
||||
{
|
||||
log_config.retention_days = days;
|
||||
}
|
||||
if let Ok(value) = env::var("TASK_PILOT_LOG_MAX_TOTAL_MB")
|
||||
&& let Ok(megabytes) = value.parse::<u64>()
|
||||
{
|
||||
log_config.max_total_bytes = megabytes.saturating_mul(1024 * 1024);
|
||||
}
|
||||
let executor = TaskExecutor::new(pool.clone(), logs_dir, log_config);
|
||||
executor.cleanup_logs().await.map_err(ApiError::internal)?;
|
||||
let scheduler = Scheduler::start(pool.clone(), executor.clone()).await?;
|
||||
|
||||
Ok(Self {
|
||||
pool,
|
||||
scheduler,
|
||||
executor,
|
||||
auth_token: uuid::Uuid::new_v4().simple().to_string(),
|
||||
})
|
||||
}
|
||||
|
||||
pub fn shutdown(&self) {
|
||||
self.scheduler.shutdown();
|
||||
self.executor.shutdown();
|
||||
}
|
||||
|
||||
pub fn auth_token(&self) -> &str {
|
||||
&self.auth_token
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,4 @@
|
||||
pub mod model;
|
||||
pub(crate) mod repository;
|
||||
pub mod routes;
|
||||
pub mod service;
|
||||
@@ -0,0 +1,176 @@
|
||||
use std::collections::BTreeMap;
|
||||
|
||||
use chrono::{DateTime, Utc};
|
||||
use serde::{Deserialize, Serialize};
|
||||
use utoipa::IntoParams;
|
||||
use utoipa::ToSchema;
|
||||
|
||||
pub const DEFAULT_TIMEZONE: &str = "Asia/Shanghai";
|
||||
pub const DEFAULT_TIMEOUT_SECS: u64 = 300;
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, ToSchema)]
|
||||
#[serde(rename_all = "snake_case")]
|
||||
pub enum TaskActionType {
|
||||
Script,
|
||||
Http,
|
||||
Process,
|
||||
}
|
||||
|
||||
impl TaskActionType {
|
||||
pub const fn as_str(self) -> &'static str {
|
||||
match self {
|
||||
Self::Script => "script",
|
||||
Self::Http => "http",
|
||||
Self::Process => "process",
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, ToSchema)]
|
||||
pub enum HttpMethod {
|
||||
#[serde(rename = "GET")]
|
||||
Get,
|
||||
#[serde(rename = "POST")]
|
||||
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 {
|
||||
Script {
|
||||
working_dir: String,
|
||||
interpreter: String,
|
||||
script: String,
|
||||
#[serde(default)]
|
||||
args: Vec<String>,
|
||||
#[serde(default)]
|
||||
env: BTreeMap<String, String>,
|
||||
},
|
||||
Http {
|
||||
url: String,
|
||||
method: HttpMethod,
|
||||
#[serde(default)]
|
||||
headers: BTreeMap<String, String>,
|
||||
#[serde(default)]
|
||||
body: Option<String>,
|
||||
#[serde(default)]
|
||||
expected_status: Vec<u16>,
|
||||
},
|
||||
Process {
|
||||
executable: String,
|
||||
#[serde(default)]
|
||||
working_dir: Option<String>,
|
||||
#[serde(default)]
|
||||
args: Vec<String>,
|
||||
#[serde(default)]
|
||||
env: BTreeMap<String, String>,
|
||||
},
|
||||
}
|
||||
|
||||
impl TaskAction {
|
||||
pub const fn action_type(&self) -> TaskActionType {
|
||||
match self {
|
||||
Self::Script { .. } => TaskActionType::Script,
|
||||
Self::Http { .. } => TaskActionType::Http,
|
||||
Self::Process { .. } => TaskActionType::Process,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn default_timezone() -> String {
|
||||
DEFAULT_TIMEZONE.to_owned()
|
||||
}
|
||||
|
||||
const fn default_enabled() -> bool {
|
||||
true
|
||||
}
|
||||
|
||||
const fn default_timeout_secs() -> u64 {
|
||||
DEFAULT_TIMEOUT_SECS
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize, ToSchema)]
|
||||
#[serde(deny_unknown_fields)]
|
||||
pub struct CreateTaskRequest {
|
||||
pub id: String,
|
||||
pub name: String,
|
||||
pub cron: String,
|
||||
#[serde(default = "default_timezone")]
|
||||
pub timezone: String,
|
||||
#[serde(default = "default_enabled")]
|
||||
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,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize, ToSchema)]
|
||||
#[serde(deny_unknown_fields)]
|
||||
pub struct UpdateTaskRequest {
|
||||
pub name: String,
|
||||
pub cron: String,
|
||||
#[serde(default = "default_timezone")]
|
||||
pub timezone: String,
|
||||
#[serde(default = "default_enabled")]
|
||||
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,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, ToSchema)]
|
||||
pub struct TaskResponse {
|
||||
pub id: String,
|
||||
pub name: String,
|
||||
pub cron: String,
|
||||
pub timezone: String,
|
||||
pub enabled: bool,
|
||||
pub timeout_secs: u64,
|
||||
pub concurrency_policy: ConcurrencyPolicy,
|
||||
pub retry_policy: RetryPolicy,
|
||||
pub action: TaskAction,
|
||||
pub created_at: DateTime<Utc>,
|
||||
pub updated_at: DateTime<Utc>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize, ToSchema)]
|
||||
pub struct TaskListResponse {
|
||||
pub items: Vec<TaskResponse>,
|
||||
pub page: u32,
|
||||
pub page_size: u32,
|
||||
pub total: u64,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize, IntoParams)]
|
||||
#[into_params(parameter_in = Query)]
|
||||
pub struct ListTasksQuery {
|
||||
pub q: Option<String>,
|
||||
pub enabled: Option<bool>,
|
||||
pub action_type: Option<TaskActionType>,
|
||||
pub page: Option<u32>,
|
||||
pub page_size: Option<u32>,
|
||||
}
|
||||
@@ -0,0 +1,284 @@
|
||||
use chrono::{DateTime, Utc};
|
||||
use sqlx::{FromRow, QueryBuilder, Sqlite, SqlitePool};
|
||||
|
||||
use crate::{
|
||||
error::{ApiError, ApiResult},
|
||||
tasks::model::{
|
||||
ConcurrencyPolicy, CreateTaskRequest, RetryPolicy, 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
|
||||
";
|
||||
|
||||
pub struct ListFilters {
|
||||
pub q: Option<String>,
|
||||
pub enabled: Option<bool>,
|
||||
pub action_type: Option<TaskActionType>,
|
||||
pub page: u32,
|
||||
pub page_size: u32,
|
||||
}
|
||||
|
||||
#[derive(Debug, FromRow)]
|
||||
struct TaskRow {
|
||||
id: String,
|
||||
name: String,
|
||||
cron: String,
|
||||
timezone: String,
|
||||
enabled: bool,
|
||||
timeout_secs: i64,
|
||||
concurrency_policy: String,
|
||||
retry_max_attempts: i64,
|
||||
retry_delay_secs: i64,
|
||||
action_type: String,
|
||||
action_json: String,
|
||||
created_at: DateTime<Utc>,
|
||||
updated_at: DateTime<Utc>,
|
||||
}
|
||||
|
||||
impl TryFrom<TaskRow> for TaskResponse {
|
||||
type Error = ApiError;
|
||||
|
||||
fn try_from(row: TaskRow) -> Result<Self, Self::Error> {
|
||||
if row.concurrency_policy != "skip" {
|
||||
return Err(ApiError::internal(format!(
|
||||
"unknown concurrency policy: {}",
|
||||
row.concurrency_policy
|
||||
)));
|
||||
}
|
||||
|
||||
let action: TaskAction = serde_json::from_str(&row.action_json)?;
|
||||
if row.action_type != action.action_type().as_str() {
|
||||
return Err(ApiError::internal(format!(
|
||||
"task {} action type does not match action payload",
|
||||
row.id
|
||||
)));
|
||||
}
|
||||
|
||||
Ok(Self {
|
||||
id: row.id,
|
||||
name: row.name,
|
||||
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,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
pub async fn create(pool: &SqlitePool, request: &CreateTaskRequest) -> ApiResult<TaskResponse> {
|
||||
let now = Utc::now();
|
||||
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 (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)",
|
||||
)
|
||||
.bind(&request.id)
|
||||
.bind(&request.name)
|
||||
.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)
|
||||
.bind(now)
|
||||
.execute(pool)
|
||||
.await;
|
||||
|
||||
if let Err(error) = result {
|
||||
if is_unique_violation(&error) {
|
||||
return Err(ApiError::conflict(format!(
|
||||
"任务标识 {} 已存在",
|
||||
request.id
|
||||
)));
|
||||
}
|
||||
return Err(error.into());
|
||||
}
|
||||
|
||||
Ok(TaskResponse {
|
||||
id: request.id.clone(),
|
||||
name: request.name.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,
|
||||
})
|
||||
}
|
||||
|
||||
pub async fn get(pool: &SqlitePool, id: &str) -> ApiResult<Option<TaskResponse>> {
|
||||
let row = sqlx::query_as::<_, TaskRow>(
|
||||
"SELECT
|
||||
id, name, cron, timezone, enabled, timeout_secs, concurrency_policy,
|
||||
retry_max_attempts, retry_delay_secs, action_type, action_json,
|
||||
created_at, updated_at
|
||||
FROM tasks
|
||||
WHERE id = ?",
|
||||
)
|
||||
.bind(id)
|
||||
.fetch_optional(pool)
|
||||
.await?;
|
||||
|
||||
row.map(TaskResponse::try_from).transpose()
|
||||
}
|
||||
|
||||
pub async fn update(
|
||||
pool: &SqlitePool,
|
||||
id: &str,
|
||||
request: &UpdateTaskRequest,
|
||||
) -> ApiResult<TaskResponse> {
|
||||
let now = Utc::now();
|
||||
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 = ?,
|
||||
action_type = ?, action_json = ?, updated_at = ?
|
||||
WHERE id = ?",
|
||||
)
|
||||
.bind(&request.name)
|
||||
.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)
|
||||
.bind(id)
|
||||
.execute(pool)
|
||||
.await?;
|
||||
|
||||
if result.rows_affected() == 0 {
|
||||
return Err(ApiError::not_found(format!("任务 {id} 不存在")));
|
||||
}
|
||||
|
||||
get(pool, id)
|
||||
.await?
|
||||
.ok_or_else(|| ApiError::not_found(format!("任务 {id} 不存在")))
|
||||
}
|
||||
|
||||
pub async fn delete(pool: &SqlitePool, id: &str) -> ApiResult<bool> {
|
||||
let result = sqlx::query("DELETE FROM tasks WHERE id = ?")
|
||||
.bind(id)
|
||||
.execute(pool)
|
||||
.await?;
|
||||
|
||||
Ok(result.rows_affected() > 0)
|
||||
}
|
||||
|
||||
pub async fn set_enabled(pool: &SqlitePool, id: &str, enabled: bool) -> ApiResult<bool> {
|
||||
let result = sqlx::query("UPDATE tasks SET enabled = ?, updated_at = ? WHERE id = ?")
|
||||
.bind(enabled)
|
||||
.bind(Utc::now())
|
||||
.bind(id)
|
||||
.execute(pool)
|
||||
.await?;
|
||||
Ok(result.rows_affected() > 0)
|
||||
}
|
||||
|
||||
pub async fn list(pool: &SqlitePool, filters: &ListFilters) -> ApiResult<TaskListResponse> {
|
||||
let mut count_builder = QueryBuilder::<Sqlite>::new("SELECT COUNT(*) FROM tasks");
|
||||
apply_filters(&mut count_builder, filters);
|
||||
let total: i64 = count_builder.build_query_scalar().fetch_one(pool).await?;
|
||||
|
||||
let offset = i64::from(filters.page.saturating_sub(1)) * i64::from(filters.page_size);
|
||||
let mut list_builder = QueryBuilder::<Sqlite>::new(format!("SELECT {TASK_COLUMNS} FROM tasks"));
|
||||
apply_filters(&mut list_builder, filters);
|
||||
list_builder
|
||||
.push(" ORDER BY updated_at DESC, id ASC LIMIT ")
|
||||
.push_bind(i64::from(filters.page_size))
|
||||
.push(" OFFSET ")
|
||||
.push_bind(offset);
|
||||
|
||||
let rows = list_builder
|
||||
.build_query_as::<TaskRow>()
|
||||
.fetch_all(pool)
|
||||
.await?;
|
||||
let items = rows
|
||||
.into_iter()
|
||||
.map(TaskResponse::try_from)
|
||||
.collect::<ApiResult<Vec<_>>>()?;
|
||||
|
||||
Ok(TaskListResponse {
|
||||
items,
|
||||
page: filters.page,
|
||||
page_size: filters.page_size,
|
||||
total: u64::try_from(total).map_err(ApiError::internal)?,
|
||||
})
|
||||
}
|
||||
|
||||
pub async fn list_enabled_script_tasks(pool: &SqlitePool) -> ApiResult<Vec<TaskResponse>> {
|
||||
let rows = sqlx::query_as::<_, TaskRow>(
|
||||
"SELECT
|
||||
id, name, cron, timezone, enabled, timeout_secs, concurrency_policy,
|
||||
retry_max_attempts, retry_delay_secs, action_type, action_json,
|
||||
created_at, updated_at
|
||||
FROM tasks
|
||||
WHERE enabled = 1 AND action_type = 'script'
|
||||
ORDER BY updated_at DESC, id ASC",
|
||||
)
|
||||
.fetch_all(pool)
|
||||
.await?;
|
||||
|
||||
rows.into_iter()
|
||||
.map(TaskResponse::try_from)
|
||||
.collect::<ApiResult<Vec<_>>>()
|
||||
}
|
||||
|
||||
fn apply_filters(builder: &mut QueryBuilder<Sqlite>, filters: &ListFilters) {
|
||||
builder.push(" WHERE 1 = 1");
|
||||
|
||||
if let Some(search) = &filters.q {
|
||||
let pattern = format!(
|
||||
"%{}%",
|
||||
search
|
||||
.replace('\\', "\\\\")
|
||||
.replace('%', "\\%")
|
||||
.replace('_', "\\_")
|
||||
);
|
||||
builder
|
||||
.push(" AND (id LIKE ")
|
||||
.push_bind(pattern.clone())
|
||||
.push(" ESCAPE '\\' OR name LIKE ")
|
||||
.push_bind(pattern)
|
||||
.push(" ESCAPE '\\')");
|
||||
}
|
||||
if let Some(enabled) = filters.enabled {
|
||||
builder.push(" AND enabled = ").push_bind(enabled);
|
||||
}
|
||||
if let Some(action_type) = filters.action_type {
|
||||
builder
|
||||
.push(" AND action_type = ")
|
||||
.push_bind(action_type.as_str());
|
||||
}
|
||||
}
|
||||
|
||||
fn is_unique_violation(error: &sqlx::Error) -> bool {
|
||||
matches!(error, sqlx::Error::Database(database_error) if database_error.is_unique_violation())
|
||||
}
|
||||
@@ -0,0 +1,167 @@
|
||||
use axum::{
|
||||
Json, Router,
|
||||
extract::{Path, State},
|
||||
http::StatusCode,
|
||||
routing::get,
|
||||
};
|
||||
|
||||
use crate::{
|
||||
error::{ApiJson, ApiQuery, ApiResult},
|
||||
state::AppState,
|
||||
tasks::{
|
||||
model::{
|
||||
CreateTaskRequest, ListTasksQuery, TaskListResponse, TaskResponse, UpdateTaskRequest,
|
||||
},
|
||||
service,
|
||||
},
|
||||
};
|
||||
|
||||
pub fn router() -> Router<AppState> {
|
||||
Router::new()
|
||||
.route("/api/v1/tasks", get(list_tasks).post(create_task))
|
||||
.route(
|
||||
"/api/v1/tasks/{id}",
|
||||
get(get_task).put(update_task).delete(delete_task),
|
||||
)
|
||||
.route(
|
||||
"/api/v1/tasks/{id}/enable",
|
||||
axum::routing::post(enable_task),
|
||||
)
|
||||
.route(
|
||||
"/api/v1/tasks/{id}/disable",
|
||||
axum::routing::post(disable_task),
|
||||
)
|
||||
}
|
||||
|
||||
#[utoipa::path(
|
||||
get,
|
||||
path = "/api/v1/tasks",
|
||||
tag = "tasks",
|
||||
params(ListTasksQuery),
|
||||
responses(
|
||||
(status = 200, description = "Paginated task list", body = TaskListResponse)
|
||||
)
|
||||
)]
|
||||
pub async fn list_tasks(
|
||||
State(state): State<AppState>,
|
||||
ApiQuery(query): ApiQuery<ListTasksQuery>,
|
||||
) -> ApiResult<Json<TaskListResponse>> {
|
||||
Ok(Json(service::list(&state.pool, query).await?))
|
||||
}
|
||||
|
||||
#[utoipa::path(
|
||||
post,
|
||||
path = "/api/v1/tasks",
|
||||
tag = "tasks",
|
||||
request_body = CreateTaskRequest,
|
||||
responses(
|
||||
(status = 201, description = "Task created", body = TaskResponse),
|
||||
(status = 400, description = "Validation failed", body = crate::error::ErrorResponse),
|
||||
(status = 409, description = "Task ID already exists", body = crate::error::ErrorResponse)
|
||||
)
|
||||
)]
|
||||
pub async fn create_task(
|
||||
State(state): State<AppState>,
|
||||
ApiJson(request): ApiJson<CreateTaskRequest>,
|
||||
) -> ApiResult<(StatusCode, Json<TaskResponse>)> {
|
||||
let task = service::create(&state.pool, request).await?;
|
||||
state.scheduler.refresh(task.clone());
|
||||
Ok((StatusCode::CREATED, Json(task)))
|
||||
}
|
||||
|
||||
#[utoipa::path(
|
||||
get,
|
||||
path = "/api/v1/tasks/{id}",
|
||||
tag = "tasks",
|
||||
params(("id" = String, Path, description = "Stable task identifier")),
|
||||
responses(
|
||||
(status = 200, description = "Task detail", body = TaskResponse),
|
||||
(status = 404, description = "Task not found", body = crate::error::ErrorResponse)
|
||||
)
|
||||
)]
|
||||
pub async fn get_task(
|
||||
State(state): State<AppState>,
|
||||
Path(id): Path<String>,
|
||||
) -> ApiResult<Json<TaskResponse>> {
|
||||
Ok(Json(service::get(&state.pool, &id).await?))
|
||||
}
|
||||
|
||||
#[utoipa::path(
|
||||
put,
|
||||
path = "/api/v1/tasks/{id}",
|
||||
tag = "tasks",
|
||||
params(("id" = String, Path, description = "Stable task identifier")),
|
||||
request_body = UpdateTaskRequest,
|
||||
responses(
|
||||
(status = 200, description = "Task updated", body = TaskResponse),
|
||||
(status = 400, description = "Validation failed", body = crate::error::ErrorResponse),
|
||||
(status = 404, description = "Task not found", body = crate::error::ErrorResponse)
|
||||
)
|
||||
)]
|
||||
pub async fn update_task(
|
||||
State(state): State<AppState>,
|
||||
Path(id): Path<String>,
|
||||
ApiJson(request): ApiJson<UpdateTaskRequest>,
|
||||
) -> ApiResult<Json<TaskResponse>> {
|
||||
let task = service::update(&state.pool, &id, request).await?;
|
||||
state.scheduler.refresh(task.clone());
|
||||
Ok(Json(task))
|
||||
}
|
||||
|
||||
#[utoipa::path(
|
||||
delete,
|
||||
path = "/api/v1/tasks/{id}",
|
||||
tag = "tasks",
|
||||
params(("id" = String, Path, description = "Stable task identifier")),
|
||||
responses(
|
||||
(status = 204, description = "Task deleted"),
|
||||
(status = 404, description = "Task not found", body = crate::error::ErrorResponse)
|
||||
)
|
||||
)]
|
||||
pub async fn delete_task(
|
||||
State(state): State<AppState>,
|
||||
Path(id): Path<String>,
|
||||
) -> ApiResult<StatusCode> {
|
||||
state.scheduler.cancel(&id);
|
||||
state.executor.cancel_task(&id);
|
||||
service::delete(&state.pool, &id).await?;
|
||||
Ok(StatusCode::NO_CONTENT)
|
||||
}
|
||||
|
||||
#[utoipa::path(
|
||||
post,
|
||||
path = "/api/v1/tasks/{id}/enable",
|
||||
tag = "tasks",
|
||||
params(("id" = String, Path, description = "Stable task identifier")),
|
||||
responses(
|
||||
(status = 200, description = "Task enabled", body = TaskResponse),
|
||||
(status = 404, description = "Task not found", body = crate::error::ErrorResponse)
|
||||
)
|
||||
)]
|
||||
pub async fn enable_task(
|
||||
State(state): State<AppState>,
|
||||
Path(id): Path<String>,
|
||||
) -> ApiResult<Json<TaskResponse>> {
|
||||
let task = service::set_enabled(&state.pool, &id, true).await?;
|
||||
state.scheduler.refresh(task.clone());
|
||||
Ok(Json(task))
|
||||
}
|
||||
|
||||
#[utoipa::path(
|
||||
post,
|
||||
path = "/api/v1/tasks/{id}/disable",
|
||||
tag = "tasks",
|
||||
params(("id" = String, Path, description = "Stable task identifier")),
|
||||
responses(
|
||||
(status = 200, description = "Task disabled", body = TaskResponse),
|
||||
(status = 404, description = "Task not found", body = crate::error::ErrorResponse)
|
||||
)
|
||||
)]
|
||||
pub async fn disable_task(
|
||||
State(state): State<AppState>,
|
||||
Path(id): Path<String>,
|
||||
) -> ApiResult<Json<TaskResponse>> {
|
||||
let task = service::set_enabled(&state.pool, &id, false).await?;
|
||||
state.scheduler.cancel(&id);
|
||||
Ok(Json(task))
|
||||
}
|
||||
@@ -0,0 +1,369 @@
|
||||
use std::{collections::BTreeMap, str::FromStr};
|
||||
|
||||
use axum::http::{HeaderName, HeaderValue};
|
||||
use chrono::Utc;
|
||||
use chrono_tz::Tz;
|
||||
use croner::Cron;
|
||||
use sqlx::SqlitePool;
|
||||
use url::Url;
|
||||
|
||||
use crate::{error::ApiResult, tasks::model::*};
|
||||
|
||||
use super::repository::{self, ListFilters};
|
||||
|
||||
const MAX_TASK_ID_LEN: usize = 64;
|
||||
const MAX_TASK_NAME_LEN: usize = 120;
|
||||
const MAX_TIMEOUT_SECS: u64 = 86_400;
|
||||
const MAX_ARGS: usize = 256;
|
||||
const MAX_ARG_LEN: usize = 2_048;
|
||||
const MAX_ENV_ENTRIES: usize = 128;
|
||||
const MAX_ENV_KEY_LEN: usize = 128;
|
||||
const MAX_ENV_VALUE_LEN: usize = 8_192;
|
||||
const MAX_HTTP_BODY_LEN: usize = 1_048_576;
|
||||
const DEFAULT_PAGE_SIZE: u32 = 20;
|
||||
const MAX_PAGE_SIZE: u32 = 100;
|
||||
|
||||
pub async fn create(pool: &SqlitePool, mut request: CreateTaskRequest) -> ApiResult<TaskResponse> {
|
||||
request.name = request.name.trim().to_owned();
|
||||
request.cron = request.cron.trim().to_owned();
|
||||
request.timezone = request.timezone.trim().to_owned();
|
||||
|
||||
validate_task_id(&request.id)?;
|
||||
validate_common(
|
||||
&request.name,
|
||||
&request.cron,
|
||||
&request.timezone,
|
||||
request.timeout_secs,
|
||||
&request.retry_policy,
|
||||
)?;
|
||||
validate_action(&request.action)?;
|
||||
|
||||
repository::create(pool, &request).await
|
||||
}
|
||||
|
||||
pub async fn get(pool: &SqlitePool, id: &str) -> ApiResult<TaskResponse> {
|
||||
repository::get(pool, id)
|
||||
.await?
|
||||
.ok_or_else(|| crate::error::ApiError::not_found(format!("任务 {id} 不存在")))
|
||||
}
|
||||
|
||||
pub async fn update(
|
||||
pool: &SqlitePool,
|
||||
id: &str,
|
||||
mut request: UpdateTaskRequest,
|
||||
) -> ApiResult<TaskResponse> {
|
||||
validate_task_id(id)?;
|
||||
request.name = request.name.trim().to_owned();
|
||||
request.cron = request.cron.trim().to_owned();
|
||||
request.timezone = request.timezone.trim().to_owned();
|
||||
|
||||
validate_common(
|
||||
&request.name,
|
||||
&request.cron,
|
||||
&request.timezone,
|
||||
request.timeout_secs,
|
||||
&request.retry_policy,
|
||||
)?;
|
||||
validate_action(&request.action)?;
|
||||
|
||||
repository::update(pool, id, &request).await
|
||||
}
|
||||
|
||||
pub async fn delete(pool: &SqlitePool, id: &str) -> ApiResult<()> {
|
||||
if repository::delete(pool, id).await? {
|
||||
Ok(())
|
||||
} else {
|
||||
Err(crate::error::ApiError::not_found(format!(
|
||||
"任务 {id} 不存在"
|
||||
)))
|
||||
}
|
||||
}
|
||||
|
||||
pub async fn set_enabled(pool: &SqlitePool, id: &str, enabled: bool) -> ApiResult<TaskResponse> {
|
||||
if !repository::set_enabled(pool, id, enabled).await? {
|
||||
return Err(crate::error::ApiError::not_found(format!(
|
||||
"任务 {id} 不存在"
|
||||
)));
|
||||
}
|
||||
get(pool, id).await
|
||||
}
|
||||
|
||||
pub async fn list(pool: &SqlitePool, query: ListTasksQuery) -> ApiResult<TaskListResponse> {
|
||||
let page = query.page.unwrap_or(1).max(1);
|
||||
let page_size = query
|
||||
.page_size
|
||||
.unwrap_or(DEFAULT_PAGE_SIZE)
|
||||
.clamp(1, MAX_PAGE_SIZE);
|
||||
let filters = ListFilters {
|
||||
q: query
|
||||
.q
|
||||
.map(|value| value.trim().to_owned())
|
||||
.filter(|value| !value.is_empty()),
|
||||
enabled: query.enabled,
|
||||
action_type: query.action_type,
|
||||
page,
|
||||
page_size,
|
||||
};
|
||||
|
||||
repository::list(pool, &filters).await
|
||||
}
|
||||
|
||||
fn validate_task_id(id: &str) -> ApiResult<()> {
|
||||
if id.is_empty() || id.len() > MAX_TASK_ID_LEN {
|
||||
return Err(crate::error::ApiError::validation(
|
||||
"id",
|
||||
format!("任务标识长度必须在 1 到 {MAX_TASK_ID_LEN} 个字符之间"),
|
||||
));
|
||||
}
|
||||
if !id
|
||||
.chars()
|
||||
.all(|character| character.is_ascii_alphanumeric() || matches!(character, '_' | '-'))
|
||||
{
|
||||
return Err(crate::error::ApiError::validation(
|
||||
"id",
|
||||
"任务标识只能包含 ASCII 字母、数字、下划线和连字符",
|
||||
));
|
||||
}
|
||||
|
||||
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(
|
||||
"name",
|
||||
format!("任务名称长度必须在 1 到 {MAX_TASK_NAME_LEN} 个字符之间"),
|
||||
));
|
||||
}
|
||||
if timeout_secs == 0 || timeout_secs > MAX_TIMEOUT_SECS {
|
||||
return Err(crate::error::ApiError::validation(
|
||||
"timeout_secs",
|
||||
format!("超时时间必须在 1 到 {MAX_TIMEOUT_SECS} 秒之间"),
|
||||
));
|
||||
}
|
||||
if retry_policy.max_attempts > 10 {
|
||||
return Err(crate::error::ApiError::validation(
|
||||
"retry_policy.max_attempts",
|
||||
"最大重试次数不能超过 10",
|
||||
));
|
||||
}
|
||||
if retry_policy.delay_secs > 3_600 {
|
||||
return Err(crate::error::ApiError::validation(
|
||||
"retry_policy.delay_secs",
|
||||
"重试间隔不能超过 3600 秒",
|
||||
));
|
||||
}
|
||||
|
||||
Tz::from_str(timezone).map_err(|_| {
|
||||
crate::error::ApiError::validation("timezone", "时区必须是有效的 IANA 时区名称")
|
||||
})?;
|
||||
|
||||
let cron = Cron::from_str(cron_expression).map_err(|error| {
|
||||
crate::error::ApiError::validation("cron", format!("Cron 表达式无效: {error}"))
|
||||
})?;
|
||||
cron.find_next_occurrence(&Utc::now(), false)
|
||||
.map_err(|error| {
|
||||
crate::error::ApiError::validation(
|
||||
"cron",
|
||||
format!("Cron 表达式没有可计算的未来执行时间: {error}"),
|
||||
)
|
||||
})?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn validate_action(action: &TaskAction) -> ApiResult<()> {
|
||||
match action {
|
||||
TaskAction::Script {
|
||||
working_dir,
|
||||
interpreter,
|
||||
script,
|
||||
args,
|
||||
env,
|
||||
} => {
|
||||
validate_required("action.working_dir", working_dir)?;
|
||||
validate_required("action.interpreter", interpreter)?;
|
||||
validate_required("action.script", script)?;
|
||||
validate_args(args)?;
|
||||
validate_env(env)?;
|
||||
}
|
||||
TaskAction::Http {
|
||||
url,
|
||||
headers,
|
||||
body,
|
||||
expected_status,
|
||||
..
|
||||
} => {
|
||||
let parsed_url = Url::parse(url).map_err(|error| {
|
||||
crate::error::ApiError::validation("action.url", format!("URL 无效: {error}"))
|
||||
})?;
|
||||
if !matches!(parsed_url.scheme(), "http" | "https") || parsed_url.host_str().is_none() {
|
||||
return Err(crate::error::ApiError::validation(
|
||||
"action.url",
|
||||
"URL 必须使用 http 或 https 协议并包含主机名",
|
||||
));
|
||||
}
|
||||
validate_headers(headers)?;
|
||||
if body
|
||||
.as_ref()
|
||||
.is_some_and(|value| value.len() > MAX_HTTP_BODY_LEN)
|
||||
{
|
||||
return Err(crate::error::ApiError::validation(
|
||||
"action.body",
|
||||
"HTTP Body 不能超过 1 MiB",
|
||||
));
|
||||
}
|
||||
if expected_status
|
||||
.iter()
|
||||
.any(|status| !(100..=599).contains(status))
|
||||
{
|
||||
return Err(crate::error::ApiError::validation(
|
||||
"action.expected_status",
|
||||
"期望状态码必须在 100 到 599 之间",
|
||||
));
|
||||
}
|
||||
}
|
||||
TaskAction::Process {
|
||||
executable,
|
||||
working_dir,
|
||||
args,
|
||||
env,
|
||||
} => {
|
||||
validate_required("action.executable", executable)?;
|
||||
if let Some(working_dir) = working_dir {
|
||||
validate_required("action.working_dir", working_dir)?;
|
||||
}
|
||||
validate_args(args)?;
|
||||
validate_env(env)?;
|
||||
}
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn validate_required(field: &str, value: &str) -> ApiResult<()> {
|
||||
if value.trim().is_empty() {
|
||||
Err(crate::error::ApiError::validation(field, "该字段不能为空"))
|
||||
} else {
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
fn validate_args(args: &[String]) -> ApiResult<()> {
|
||||
if args.len() > MAX_ARGS {
|
||||
return Err(crate::error::ApiError::validation(
|
||||
"action.args",
|
||||
format!("参数数量不能超过 {MAX_ARGS} 个"),
|
||||
));
|
||||
}
|
||||
if args.iter().any(|argument| argument.len() > MAX_ARG_LEN) {
|
||||
return Err(crate::error::ApiError::validation(
|
||||
"action.args",
|
||||
format!("单个参数不能超过 {MAX_ARG_LEN} 个字符"),
|
||||
));
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn validate_env(env: &BTreeMap<String, String>) -> ApiResult<()> {
|
||||
if env.len() > MAX_ENV_ENTRIES {
|
||||
return Err(crate::error::ApiError::validation(
|
||||
"action.env",
|
||||
format!("环境变量数量不能超过 {MAX_ENV_ENTRIES} 个"),
|
||||
));
|
||||
}
|
||||
|
||||
for (key, value) in env {
|
||||
if key.is_empty()
|
||||
|| key.len() > MAX_ENV_KEY_LEN
|
||||
|| key.contains(['=', '\0'])
|
||||
|| value.len() > MAX_ENV_VALUE_LEN
|
||||
|| value.contains('\0')
|
||||
{
|
||||
return Err(crate::error::ApiError::validation(
|
||||
"action.env",
|
||||
"环境变量名称或值无效",
|
||||
));
|
||||
}
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn validate_headers(headers: &BTreeMap<String, String>) -> ApiResult<()> {
|
||||
for (name, value) in headers {
|
||||
HeaderName::from_bytes(name.as_bytes()).map_err(|_| {
|
||||
crate::error::ApiError::validation("action.headers", "HTTP Header 名称无效")
|
||||
})?;
|
||||
HeaderValue::from_str(value).map_err(|_| {
|
||||
crate::error::ApiError::validation("action.headers", "HTTP Header 值无效")
|
||||
})?;
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use axum::response::IntoResponse;
|
||||
|
||||
use super::*;
|
||||
|
||||
fn script_action() -> TaskAction {
|
||||
TaskAction::Script {
|
||||
working_dir: "D:\\Projects\\sync".to_owned(),
|
||||
interpreter: "python.exe".to_owned(),
|
||||
script: "main.py".to_owned(),
|
||||
args: vec!["--mode".to_owned(), "full".to_owned()],
|
||||
env: BTreeMap::new(),
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn accepts_valid_task_fields() {
|
||||
validate_task_id("db_backup_daily").expect("task id should be valid");
|
||||
validate_common(
|
||||
"每日数据库备份",
|
||||
"0 0 2 * * *",
|
||||
"Asia/Shanghai",
|
||||
300,
|
||||
&RetryPolicy::default(),
|
||||
)
|
||||
.expect("task fields should be valid");
|
||||
validate_action(&script_action()).expect("script action should be valid");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rejects_invalid_cron() {
|
||||
let error = validate_common(
|
||||
"Invalid cron",
|
||||
"*/0 * * * * *",
|
||||
"Asia/Shanghai",
|
||||
300,
|
||||
&RetryPolicy::default(),
|
||||
)
|
||||
.expect_err("invalid cron should fail");
|
||||
|
||||
assert_eq!(error.into_response().status(), 400);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rejects_empty_script_path() {
|
||||
let action = TaskAction::Script {
|
||||
working_dir: "D:\\Projects\\sync".to_owned(),
|
||||
interpreter: "python.exe".to_owned(),
|
||||
script: " ".to_owned(),
|
||||
args: Vec::new(),
|
||||
env: BTreeMap::new(),
|
||||
};
|
||||
|
||||
validate_action(&action).expect_err("empty script path should fail");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,74 @@
|
||||
use axum::{
|
||||
body::Body,
|
||||
http::{Request, StatusCode},
|
||||
};
|
||||
use http_body_util::BodyExt;
|
||||
use serde_json::Value;
|
||||
use tempfile::TempDir;
|
||||
use tower::ServiceExt;
|
||||
|
||||
async fn test_app() -> (axum::Router, TempDir) {
|
||||
let temp_dir = TempDir::new().expect("temporary directory should be created");
|
||||
let pool = task_pilot_backend::database::connect("sqlite::memory:")
|
||||
.await
|
||||
.expect("test database should initialize");
|
||||
let app = task_pilot_backend::app(pool, temp_dir.path().join("logs"))
|
||||
.await
|
||||
.expect("test app should start");
|
||||
(app, temp_dir)
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn health_endpoint_returns_service_metadata() {
|
||||
let (app, _temp_dir) = test_app().await;
|
||||
let response = app
|
||||
.oneshot(
|
||||
Request::builder()
|
||||
.uri("/api/v1/health")
|
||||
.body(Body::empty())
|
||||
.expect("request should build"),
|
||||
)
|
||||
.await
|
||||
.expect("router should respond");
|
||||
|
||||
assert_eq!(response.status(), StatusCode::OK);
|
||||
|
||||
let body = response
|
||||
.into_body()
|
||||
.collect()
|
||||
.await
|
||||
.expect("response body should be readable")
|
||||
.to_bytes();
|
||||
let json: Value = serde_json::from_slice(&body).expect("response should be JSON");
|
||||
|
||||
assert_eq!(json["status"], "ok");
|
||||
assert_eq!(json["service"], "task-pilot-backend");
|
||||
assert_eq!(json["version"], env!("CARGO_PKG_VERSION"));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn openapi_endpoint_describes_health_api() {
|
||||
let (app, _temp_dir) = test_app().await;
|
||||
let response = app
|
||||
.oneshot(
|
||||
Request::builder()
|
||||
.uri("/api/v1/openapi.json")
|
||||
.body(Body::empty())
|
||||
.expect("request should build"),
|
||||
)
|
||||
.await
|
||||
.expect("router should respond");
|
||||
|
||||
assert_eq!(response.status(), StatusCode::OK);
|
||||
|
||||
let body = response
|
||||
.into_body()
|
||||
.collect()
|
||||
.await
|
||||
.expect("response body should be readable")
|
||||
.to_bytes();
|
||||
let json: Value = serde_json::from_slice(&body).expect("response should be JSON");
|
||||
|
||||
assert_eq!(json["info"]["title"], "TaskPilot API");
|
||||
assert!(json["paths"]["/api/v1/health"]["get"].is_object());
|
||||
}
|
||||
@@ -0,0 +1,650 @@
|
||||
use std::time::Duration;
|
||||
|
||||
use axum::{
|
||||
Json, Router,
|
||||
body::Body,
|
||||
http::{HeaderMap, Method, Request, StatusCode, header},
|
||||
response::Response,
|
||||
routing::{get, post},
|
||||
};
|
||||
use http_body_util::BodyExt;
|
||||
use serde_json::{Value, json};
|
||||
use sqlx::{
|
||||
SqlitePool,
|
||||
sqlite::{SqliteConnectOptions, SqlitePoolOptions},
|
||||
};
|
||||
use task_pilot_backend::database;
|
||||
use task_pilot_backend::state::AppState;
|
||||
use tempfile::TempDir;
|
||||
use tokio::time::sleep;
|
||||
use tokio::{net::TcpListener, task::JoinHandle};
|
||||
use tower::ServiceExt;
|
||||
|
||||
use task_pilot_backend::runs::executor::ExecutionEvent;
|
||||
|
||||
struct TestApp {
|
||||
router: Router,
|
||||
state: AppState,
|
||||
_temp_dir: TempDir,
|
||||
}
|
||||
|
||||
async fn test_app() -> TestApp {
|
||||
let temp_dir = TempDir::new().expect("temporary directory should be created");
|
||||
let pool = task_pilot_backend::database::connect("sqlite::memory:")
|
||||
.await
|
||||
.expect("test database should initialize");
|
||||
let state = AppState::start(pool, temp_dir.path().join("logs"))
|
||||
.await
|
||||
.expect("app state should start");
|
||||
let router = task_pilot_backend::router(state.clone());
|
||||
TestApp {
|
||||
router,
|
||||
state,
|
||||
_temp_dir: temp_dir,
|
||||
}
|
||||
}
|
||||
|
||||
async fn send_json(router: &Router, method: Method, uri: &str, body: Option<Value>) -> Response {
|
||||
let token = if matches!(method, Method::GET | Method::HEAD | Method::OPTIONS) {
|
||||
None
|
||||
} else {
|
||||
Some(fetch_api_token(router).await)
|
||||
};
|
||||
let body = body
|
||||
.map(|value| Body::from(serde_json::to_vec(&value).expect("JSON should serialize")))
|
||||
.unwrap_or_else(Body::empty);
|
||||
let mut builder = Request::builder()
|
||||
.method(method)
|
||||
.uri(uri)
|
||||
.header(header::CONTENT_TYPE, "application/json");
|
||||
if let Some(token) = token {
|
||||
builder = builder.header("x-taskpilot-token", token);
|
||||
}
|
||||
let request = builder.body(body).expect("request should build");
|
||||
|
||||
router
|
||||
.clone()
|
||||
.oneshot(request)
|
||||
.await
|
||||
.expect("router should respond")
|
||||
}
|
||||
|
||||
async fn fetch_api_token(router: &Router) -> String {
|
||||
let request = Request::builder()
|
||||
.method(Method::GET)
|
||||
.uri("/api/v1/session")
|
||||
.body(Body::empty())
|
||||
.expect("session request should build");
|
||||
let response = router
|
||||
.clone()
|
||||
.oneshot(request)
|
||||
.await
|
||||
.expect("session endpoint should respond");
|
||||
assert_eq!(response.status(), StatusCode::OK);
|
||||
response_json(response).await["token"]
|
||||
.as_str()
|
||||
.expect("session token should be a string")
|
||||
.to_owned()
|
||||
}
|
||||
|
||||
async fn response_json(response: Response) -> Value {
|
||||
let body = response
|
||||
.into_body()
|
||||
.collect()
|
||||
.await
|
||||
.expect("response body should be readable")
|
||||
.to_bytes();
|
||||
serde_json::from_slice(&body).expect("response should be JSON")
|
||||
}
|
||||
|
||||
async fn connect_file(path: &std::path::Path) -> SqlitePool {
|
||||
let options = SqliteConnectOptions::new()
|
||||
.filename(path)
|
||||
.create_if_missing(true)
|
||||
.foreign_keys(true);
|
||||
let pool = SqlitePoolOptions::new()
|
||||
.max_connections(1)
|
||||
.connect_with(options)
|
||||
.await
|
||||
.expect("file database should open");
|
||||
database::migrate(&pool)
|
||||
.await
|
||||
.expect("migrations should run");
|
||||
pool
|
||||
}
|
||||
|
||||
fn script_task(
|
||||
id: &str,
|
||||
working_dir: &str,
|
||||
interpreter: &str,
|
||||
script: &str,
|
||||
args: Vec<&str>,
|
||||
cron: &str,
|
||||
timeout_secs: u64,
|
||||
) -> Value {
|
||||
json!({
|
||||
"id": id,
|
||||
"name": id,
|
||||
"cron": cron,
|
||||
"timeout_secs": timeout_secs,
|
||||
"action": {
|
||||
"type": "script",
|
||||
"working_dir": working_dir,
|
||||
"interpreter": interpreter,
|
||||
"script": script,
|
||||
"args": args,
|
||||
"env": {}
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
fn http_task(url: &str) -> Value {
|
||||
json!({
|
||||
"id": "http_task",
|
||||
"name": "HTTP task",
|
||||
"cron": "0 0 2 * * *",
|
||||
"timeout_secs": 5,
|
||||
"action": {
|
||||
"type": "http",
|
||||
"url": url,
|
||||
"method": "POST",
|
||||
"headers": {
|
||||
"X-Test": "yes"
|
||||
},
|
||||
"body": "{\"ping\":true}",
|
||||
"expected_status": [202]
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
async fn spawn_http_target() -> (String, JoinHandle<()>) {
|
||||
async fn check(headers: HeaderMap, body: String) -> StatusCode {
|
||||
if headers.get("x-test").and_then(|value| value.to_str().ok()) == Some("yes")
|
||||
&& body.contains("\"ping\":true")
|
||||
{
|
||||
StatusCode::ACCEPTED
|
||||
} else {
|
||||
StatusCode::BAD_REQUEST
|
||||
}
|
||||
}
|
||||
|
||||
let app = Router::new()
|
||||
.route("/check", post(check))
|
||||
.route("/health", get(|| async { Json(json!({"status":"ok"})) }));
|
||||
let listener = TcpListener::bind(("127.0.0.1", 0))
|
||||
.await
|
||||
.expect("HTTP test listener should bind");
|
||||
let address = listener
|
||||
.local_addr()
|
||||
.expect("listener address should exist");
|
||||
let handle = tokio::spawn(async move {
|
||||
let _ = axum::serve(listener, app).await;
|
||||
});
|
||||
(format!("http://{address}"), handle)
|
||||
}
|
||||
|
||||
async fn wait_for_run(router: &Router, run_id: i64) -> Value {
|
||||
for _ in 0..100 {
|
||||
let response =
|
||||
send_json(router, Method::GET, &format!("/api/v1/runs/{run_id}"), None).await;
|
||||
assert_eq!(response.status(), StatusCode::OK);
|
||||
let run = response_json(response).await;
|
||||
if matches!(
|
||||
run["status"].as_str(),
|
||||
Some("succeeded" | "failed" | "timed_out" | "cancelled" | "skipped")
|
||||
) {
|
||||
return run;
|
||||
}
|
||||
sleep(Duration::from_millis(100)).await;
|
||||
}
|
||||
|
||||
panic!("run {run_id} did not reach a terminal state");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn manual_script_run_captures_exit_code_and_logs() {
|
||||
let app = test_app().await;
|
||||
let working_dir = app._temp_dir.path().to_string_lossy().into_owned();
|
||||
let task = script_task(
|
||||
"echo_task",
|
||||
&working_dir,
|
||||
"cmd.exe",
|
||||
"/C",
|
||||
vec!["echo", "hello-from-task"],
|
||||
"0 0 2 * * *",
|
||||
5,
|
||||
);
|
||||
let create_response = send_json(&app.router, Method::POST, "/api/v1/tasks", Some(task)).await;
|
||||
assert_eq!(create_response.status(), StatusCode::CREATED);
|
||||
|
||||
let run_response = send_json(
|
||||
&app.router,
|
||||
Method::POST,
|
||||
"/api/v1/tasks/echo_task/run",
|
||||
None,
|
||||
)
|
||||
.await;
|
||||
assert_eq!(run_response.status(), StatusCode::ACCEPTED);
|
||||
let run = response_json(run_response).await;
|
||||
let run_id = run["id"].as_i64().expect("run id should be an integer");
|
||||
assert_eq!(run["status"], "queued");
|
||||
|
||||
let completed = wait_for_run(&app.router, run_id).await;
|
||||
assert_eq!(completed["status"], "succeeded");
|
||||
assert_eq!(completed["exit_code"], 0);
|
||||
assert_eq!(completed["trigger"], "manual");
|
||||
|
||||
let log_response = send_json(
|
||||
&app.router,
|
||||
Method::GET,
|
||||
&format!("/api/v1/runs/{run_id}/logs"),
|
||||
None,
|
||||
)
|
||||
.await;
|
||||
assert_eq!(log_response.status(), StatusCode::OK);
|
||||
let log = response_json(log_response).await;
|
||||
assert_eq!(log["complete"], true);
|
||||
assert!(log["content"].as_str().unwrap().contains("hello-from-task"));
|
||||
|
||||
let filtered_response = send_json(
|
||||
&app.router,
|
||||
Method::GET,
|
||||
"/api/v1/tasks/echo_task/runs?status=succeeded&trigger=manual",
|
||||
None,
|
||||
)
|
||||
.await;
|
||||
let filtered = response_json(filtered_response).await;
|
||||
assert_eq!(filtered["total"], 1);
|
||||
|
||||
let download_response = send_json(
|
||||
&app.router,
|
||||
Method::GET,
|
||||
&format!("/api/v1/runs/{run_id}/logs/download"),
|
||||
None,
|
||||
)
|
||||
.await;
|
||||
assert_eq!(download_response.status(), StatusCode::OK);
|
||||
assert!(
|
||||
download_response
|
||||
.headers()
|
||||
.get(header::CONTENT_DISPOSITION)
|
||||
.and_then(|value| value.to_str().ok())
|
||||
.unwrap_or_default()
|
||||
.contains("attachment")
|
||||
);
|
||||
app.state.shutdown();
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn http_and_process_executors_run_through_the_api() {
|
||||
let app = test_app().await;
|
||||
let (http_base, http_handle) = spawn_http_target().await;
|
||||
let create_http = send_json(
|
||||
&app.router,
|
||||
Method::POST,
|
||||
"/api/v1/tasks",
|
||||
Some(http_task(&format!("{http_base}/check"))),
|
||||
)
|
||||
.await;
|
||||
assert_eq!(create_http.status(), StatusCode::CREATED);
|
||||
|
||||
let http_run = send_json(
|
||||
&app.router,
|
||||
Method::POST,
|
||||
"/api/v1/tasks/http_task/run",
|
||||
None,
|
||||
)
|
||||
.await;
|
||||
assert_eq!(http_run.status(), StatusCode::ACCEPTED);
|
||||
let http_run_id = response_json(http_run).await["id"].as_i64().unwrap();
|
||||
assert_eq!(
|
||||
wait_for_run(&app.router, http_run_id).await["status"],
|
||||
"succeeded"
|
||||
);
|
||||
|
||||
let working_dir = app._temp_dir.path().to_string_lossy().into_owned();
|
||||
let process_task = json!({
|
||||
"id": "process_task",
|
||||
"name": "Process task",
|
||||
"cron": "0 0 2 * * *",
|
||||
"timeout_secs": 5,
|
||||
"action": {
|
||||
"type": "process",
|
||||
"executable": "cmd.exe",
|
||||
"working_dir": working_dir,
|
||||
"args": ["/C", "echo", "process-output"],
|
||||
"env": {}
|
||||
}
|
||||
});
|
||||
let create_process = send_json(
|
||||
&app.router,
|
||||
Method::POST,
|
||||
"/api/v1/tasks",
|
||||
Some(process_task),
|
||||
)
|
||||
.await;
|
||||
assert_eq!(create_process.status(), StatusCode::CREATED);
|
||||
let process_run = send_json(
|
||||
&app.router,
|
||||
Method::POST,
|
||||
"/api/v1/tasks/process_task/run",
|
||||
None,
|
||||
)
|
||||
.await;
|
||||
let process_run_id = response_json(process_run).await["id"].as_i64().unwrap();
|
||||
assert_eq!(
|
||||
wait_for_run(&app.router, process_run_id).await["status"],
|
||||
"succeeded"
|
||||
);
|
||||
let process_log = response_json(
|
||||
send_json(
|
||||
&app.router,
|
||||
Method::GET,
|
||||
&format!("/api/v1/runs/{process_run_id}/logs"),
|
||||
None,
|
||||
)
|
||||
.await,
|
||||
)
|
||||
.await;
|
||||
assert!(
|
||||
process_log["content"]
|
||||
.as_str()
|
||||
.unwrap()
|
||||
.contains("process-output")
|
||||
);
|
||||
|
||||
http_handle.abort();
|
||||
app.state.shutdown();
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn running_task_can_be_cancelled_and_sensitive_output_is_redacted() {
|
||||
let app = test_app().await;
|
||||
let working_dir = app._temp_dir.path().to_string_lossy().into_owned();
|
||||
let task = json!({
|
||||
"id": "cancel_task",
|
||||
"name": "Cancel task",
|
||||
"cron": "0 0 2 * * *",
|
||||
"timeout_secs": 30,
|
||||
"action": {
|
||||
"type": "script",
|
||||
"working_dir": working_dir,
|
||||
"interpreter": "cmd.exe",
|
||||
"script": "/C",
|
||||
"args": ["ping", "-t", "127.0.0.1"],
|
||||
"env": {
|
||||
"API_TOKEN": "super-secret-token"
|
||||
}
|
||||
}
|
||||
});
|
||||
let create_response = send_json(&app.router, Method::POST, "/api/v1/tasks", Some(task)).await;
|
||||
assert_eq!(create_response.status(), StatusCode::CREATED);
|
||||
|
||||
let mut events = app.state.executor.subscribe();
|
||||
let run_response = send_json(
|
||||
&app.router,
|
||||
Method::POST,
|
||||
"/api/v1/tasks/cancel_task/run",
|
||||
None,
|
||||
)
|
||||
.await;
|
||||
let run_id = response_json(run_response).await["id"].as_i64().unwrap();
|
||||
|
||||
let cancel_response = send_json(
|
||||
&app.router,
|
||||
Method::POST,
|
||||
&format!("/api/v1/runs/{run_id}/cancel"),
|
||||
None,
|
||||
)
|
||||
.await;
|
||||
assert_eq!(cancel_response.status(), StatusCode::ACCEPTED);
|
||||
assert_eq!(
|
||||
wait_for_run(&app.router, run_id).await["status"],
|
||||
"cancelled"
|
||||
);
|
||||
|
||||
let mut saw_run_event = false;
|
||||
let mut saw_log_event = false;
|
||||
for _ in 0..20 {
|
||||
let event = tokio::time::timeout(Duration::from_secs(1), events.recv())
|
||||
.await
|
||||
.expect("SSE event should arrive")
|
||||
.expect("SSE channel should stay open");
|
||||
match event {
|
||||
ExecutionEvent::Run { run } if run.id == run_id => saw_run_event = true,
|
||||
ExecutionEvent::Log {
|
||||
run_id: event_run_id,
|
||||
..
|
||||
} if event_run_id == run_id => saw_log_event = true,
|
||||
_ => {}
|
||||
}
|
||||
if saw_run_event && saw_log_event {
|
||||
break;
|
||||
}
|
||||
}
|
||||
assert!(saw_run_event);
|
||||
assert!(saw_log_event);
|
||||
|
||||
let terminal_cancel = send_json(
|
||||
&app.router,
|
||||
Method::POST,
|
||||
&format!("/api/v1/runs/{run_id}/cancel"),
|
||||
None,
|
||||
)
|
||||
.await;
|
||||
assert_eq!(terminal_cancel.status(), StatusCode::CONFLICT);
|
||||
|
||||
let output_task = json!({
|
||||
"id": "redaction_task",
|
||||
"name": "Redaction task",
|
||||
"cron": "0 0 2 * * *",
|
||||
"timeout_secs": 5,
|
||||
"action": {
|
||||
"type": "script",
|
||||
"working_dir": app._temp_dir.path().to_string_lossy(),
|
||||
"interpreter": "cmd.exe",
|
||||
"script": "/C",
|
||||
"args": ["echo", "%API_TOKEN%"],
|
||||
"env": {
|
||||
"API_TOKEN": "super-secret-token"
|
||||
}
|
||||
}
|
||||
});
|
||||
let create_output = send_json(
|
||||
&app.router,
|
||||
Method::POST,
|
||||
"/api/v1/tasks",
|
||||
Some(output_task),
|
||||
)
|
||||
.await;
|
||||
assert_eq!(create_output.status(), StatusCode::CREATED);
|
||||
let output_run = send_json(
|
||||
&app.router,
|
||||
Method::POST,
|
||||
"/api/v1/tasks/redaction_task/run",
|
||||
None,
|
||||
)
|
||||
.await;
|
||||
let output_run_id = response_json(output_run).await["id"].as_i64().unwrap();
|
||||
wait_for_run(&app.router, output_run_id).await;
|
||||
let output_log = response_json(
|
||||
send_json(
|
||||
&app.router,
|
||||
Method::GET,
|
||||
&format!("/api/v1/runs/{output_run_id}/logs"),
|
||||
None,
|
||||
)
|
||||
.await,
|
||||
)
|
||||
.await;
|
||||
let content = output_log["content"].as_str().unwrap();
|
||||
assert!(content.contains("[REDACTED]"));
|
||||
assert!(!content.contains("super-secret-token"));
|
||||
app.state.shutdown();
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn overlapping_trigger_is_skipped_and_timeout_kills_process_tree() {
|
||||
let app = test_app().await;
|
||||
let marker_path = app._temp_dir.path().join("child-survived.txt");
|
||||
let marker = marker_path.to_string_lossy().replace('\'', "''");
|
||||
let script_path = app._temp_dir.path().join("timeout-test.ps1");
|
||||
let script = format!(
|
||||
"$marker = '{marker}'\n\
|
||||
$child = \"Start-Sleep -Seconds 3; Set-Content -LiteralPath '{marker}' -Value 'alive'\"\n\
|
||||
Start-Process -FilePath 'powershell.exe' -ArgumentList '-NoProfile', '-Command', $child -WindowStyle Hidden\n\
|
||||
Start-Sleep -Seconds 30\n"
|
||||
);
|
||||
std::fs::write(&script_path, script).expect("test script should be written");
|
||||
let working_dir = app._temp_dir.path().to_string_lossy().into_owned();
|
||||
let task = script_task(
|
||||
"timeout_task",
|
||||
&working_dir,
|
||||
"powershell.exe",
|
||||
"-NoProfile",
|
||||
vec!["-File", &script_path.to_string_lossy()],
|
||||
"0 0 2 * * *",
|
||||
1,
|
||||
);
|
||||
let create_response = send_json(&app.router, Method::POST, "/api/v1/tasks", Some(task)).await;
|
||||
assert_eq!(create_response.status(), StatusCode::CREATED);
|
||||
|
||||
let first_response = send_json(
|
||||
&app.router,
|
||||
Method::POST,
|
||||
"/api/v1/tasks/timeout_task/run",
|
||||
None,
|
||||
)
|
||||
.await;
|
||||
assert_eq!(first_response.status(), StatusCode::ACCEPTED);
|
||||
let first_run = response_json(first_response).await;
|
||||
let first_run_id = first_run["id"].as_i64().unwrap();
|
||||
|
||||
let second_response = send_json(
|
||||
&app.router,
|
||||
Method::POST,
|
||||
"/api/v1/tasks/timeout_task/run",
|
||||
None,
|
||||
)
|
||||
.await;
|
||||
assert_eq!(second_response.status(), StatusCode::ACCEPTED);
|
||||
let second_run = response_json(second_response).await;
|
||||
assert_eq!(second_run["status"], "skipped");
|
||||
|
||||
let completed = wait_for_run(&app.router, first_run_id).await;
|
||||
assert_eq!(completed["status"], "timed_out");
|
||||
assert!(
|
||||
completed["error_message"]
|
||||
.as_str()
|
||||
.unwrap()
|
||||
.contains("已终止进程树")
|
||||
);
|
||||
|
||||
sleep(Duration::from_secs(3)).await;
|
||||
assert!(
|
||||
!marker_path.exists(),
|
||||
"child process survived after job termination"
|
||||
);
|
||||
app.state.shutdown();
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn scheduler_runs_tasks_and_tracks_enable_state() {
|
||||
let app = test_app().await;
|
||||
let working_dir = app._temp_dir.path().to_string_lossy().into_owned();
|
||||
let task = script_task(
|
||||
"scheduled_task",
|
||||
&working_dir,
|
||||
"cmd.exe",
|
||||
"/C",
|
||||
vec!["exit", "0"],
|
||||
"*/1 * * * * *",
|
||||
5,
|
||||
);
|
||||
let create_response = send_json(&app.router, Method::POST, "/api/v1/tasks", Some(task)).await;
|
||||
assert_eq!(create_response.status(), StatusCode::CREATED);
|
||||
assert!(app.state.scheduler.is_scheduled("scheduled_task"));
|
||||
|
||||
let mut scheduled_run = None;
|
||||
for _ in 0..50 {
|
||||
let response = send_json(
|
||||
&app.router,
|
||||
Method::GET,
|
||||
"/api/v1/tasks/scheduled_task/runs",
|
||||
None,
|
||||
)
|
||||
.await;
|
||||
let runs = response_json(response).await;
|
||||
if let Some(item) = runs["items"]
|
||||
.as_array()
|
||||
.and_then(|items| items.first())
|
||||
.filter(|item| item["trigger"] == "schedule")
|
||||
{
|
||||
scheduled_run = Some(item.clone());
|
||||
break;
|
||||
}
|
||||
sleep(Duration::from_millis(100)).await;
|
||||
}
|
||||
let scheduled_run = scheduled_run.expect("scheduler should trigger a run");
|
||||
let run_id = scheduled_run["id"].as_i64().unwrap();
|
||||
assert_eq!(
|
||||
wait_for_run(&app.router, run_id).await["status"],
|
||||
"succeeded"
|
||||
);
|
||||
|
||||
let disable_response = send_json(
|
||||
&app.router,
|
||||
Method::POST,
|
||||
"/api/v1/tasks/scheduled_task/disable",
|
||||
None,
|
||||
)
|
||||
.await;
|
||||
assert_eq!(disable_response.status(), StatusCode::OK);
|
||||
assert!(!app.state.scheduler.is_scheduled("scheduled_task"));
|
||||
|
||||
let enable_response = send_json(
|
||||
&app.router,
|
||||
Method::POST,
|
||||
"/api/v1/tasks/scheduled_task/enable",
|
||||
None,
|
||||
)
|
||||
.await;
|
||||
assert_eq!(enable_response.status(), StatusCode::OK);
|
||||
assert!(app.state.scheduler.is_scheduled("scheduled_task"));
|
||||
app.state.shutdown();
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn scheduler_restores_enabled_tasks_after_restart() {
|
||||
let temp_dir = TempDir::new().expect("temporary directory should be created");
|
||||
let database_path = temp_dir.path().join("restart.db");
|
||||
let first_pool = connect_file(&database_path).await;
|
||||
let first_state = AppState::start(first_pool.clone(), temp_dir.path().join("logs-first"))
|
||||
.await
|
||||
.expect("first app state should start");
|
||||
let first_router = task_pilot_backend::router(first_state.clone());
|
||||
let working_dir = temp_dir.path().to_string_lossy().into_owned();
|
||||
let task = script_task(
|
||||
"restored_task",
|
||||
&working_dir,
|
||||
"cmd.exe",
|
||||
"/C",
|
||||
vec!["exit", "0"],
|
||||
"0 0 2 * * *",
|
||||
5,
|
||||
);
|
||||
let create_response = send_json(&first_router, Method::POST, "/api/v1/tasks", Some(task)).await;
|
||||
assert_eq!(create_response.status(), StatusCode::CREATED);
|
||||
first_state.shutdown();
|
||||
drop(first_router);
|
||||
drop(first_state);
|
||||
first_pool.close().await;
|
||||
|
||||
let second_pool = connect_file(&database_path).await;
|
||||
let second_state = AppState::start(second_pool.clone(), temp_dir.path().join("logs-second"))
|
||||
.await
|
||||
.expect("second app state should start");
|
||||
assert!(second_state.scheduler.is_scheduled("restored_task"));
|
||||
second_state.shutdown();
|
||||
second_pool.close().await;
|
||||
}
|
||||
@@ -0,0 +1,57 @@
|
||||
use axum::{
|
||||
body::Body,
|
||||
http::{Request, StatusCode, header},
|
||||
};
|
||||
use http_body_util::BodyExt;
|
||||
use task_pilot_backend::state::AppState;
|
||||
use tempfile::TempDir;
|
||||
use tower::ServiceExt;
|
||||
|
||||
#[tokio::test]
|
||||
async fn serves_spa_and_keeps_api_404_as_json() {
|
||||
let temp = TempDir::new().unwrap();
|
||||
let static_dir = temp.path().join("web");
|
||||
std::fs::create_dir_all(&static_dir).unwrap();
|
||||
std::fs::write(
|
||||
static_dir.join("index.html"),
|
||||
"<!doctype html><title>TaskPilot SPA</title>",
|
||||
)
|
||||
.unwrap();
|
||||
let pool = task_pilot_backend::database::connect("sqlite::memory:")
|
||||
.await
|
||||
.unwrap();
|
||||
let state = AppState::start(pool, temp.path().join("logs"))
|
||||
.await
|
||||
.unwrap();
|
||||
let app = task_pilot_backend::router_with_static(state.clone(), static_dir);
|
||||
|
||||
let page = app
|
||||
.clone()
|
||||
.oneshot(
|
||||
Request::builder()
|
||||
.uri("/tasks/example")
|
||||
.body(Body::empty())
|
||||
.unwrap(),
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(page.status(), StatusCode::OK);
|
||||
let page_body = page.into_body().collect().await.unwrap().to_bytes();
|
||||
assert!(String::from_utf8_lossy(&page_body).contains("TaskPilot SPA"));
|
||||
|
||||
let api_not_found = app
|
||||
.oneshot(
|
||||
Request::builder()
|
||||
.uri("/api/v1/does-not-exist")
|
||||
.body(Body::empty())
|
||||
.unwrap(),
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(api_not_found.status(), StatusCode::NOT_FOUND);
|
||||
assert_eq!(
|
||||
api_not_found.headers().get(header::CONTENT_TYPE),
|
||||
Some(&header::HeaderValue::from_static("application/json"))
|
||||
);
|
||||
state.shutdown();
|
||||
}
|
||||
@@ -0,0 +1,354 @@
|
||||
use axum::{
|
||||
Router,
|
||||
body::Body,
|
||||
http::{Method, Request, StatusCode, header},
|
||||
response::Response,
|
||||
};
|
||||
use http_body_util::BodyExt;
|
||||
use serde_json::{Value, json};
|
||||
use sqlx::{
|
||||
SqlitePool,
|
||||
sqlite::{SqliteConnectOptions, SqlitePoolOptions},
|
||||
};
|
||||
use task_pilot_backend::database;
|
||||
use tempfile::TempDir;
|
||||
use tower::ServiceExt;
|
||||
|
||||
async fn test_app() -> (Router, TempDir) {
|
||||
let temp_dir = TempDir::new().expect("temporary directory should be created");
|
||||
let pool = database::connect("sqlite::memory:")
|
||||
.await
|
||||
.expect("test database should initialize");
|
||||
let app = task_pilot_backend::app(pool, temp_dir.path().join("logs"))
|
||||
.await
|
||||
.expect("test app should start");
|
||||
(app, temp_dir)
|
||||
}
|
||||
|
||||
async fn connect_file(path: &std::path::Path) -> SqlitePool {
|
||||
let options = SqliteConnectOptions::new()
|
||||
.filename(path)
|
||||
.create_if_missing(true)
|
||||
.foreign_keys(true);
|
||||
let pool = SqlitePoolOptions::new()
|
||||
.max_connections(1)
|
||||
.connect_with(options)
|
||||
.await
|
||||
.expect("file database should open");
|
||||
database::migrate(&pool)
|
||||
.await
|
||||
.expect("migrations should run");
|
||||
pool
|
||||
}
|
||||
|
||||
async fn send_json(app: &Router, method: Method, uri: &str, body: Option<Value>) -> Response {
|
||||
let token = if matches!(method, Method::GET | Method::HEAD | Method::OPTIONS) {
|
||||
None
|
||||
} else {
|
||||
Some(fetch_api_token(app).await)
|
||||
};
|
||||
let body = body
|
||||
.map(|value| Body::from(serde_json::to_vec(&value).expect("JSON should serialize")))
|
||||
.unwrap_or_else(Body::empty);
|
||||
let mut builder = Request::builder()
|
||||
.method(method)
|
||||
.uri(uri)
|
||||
.header(header::CONTENT_TYPE, "application/json");
|
||||
if let Some(token) = token {
|
||||
builder = builder.header("x-taskpilot-token", token);
|
||||
}
|
||||
let request = builder.body(body).expect("request should build");
|
||||
|
||||
app.clone()
|
||||
.oneshot(request)
|
||||
.await
|
||||
.expect("router should respond")
|
||||
}
|
||||
|
||||
async fn fetch_api_token(app: &Router) -> String {
|
||||
let request = Request::builder()
|
||||
.method(Method::GET)
|
||||
.uri("/api/v1/session")
|
||||
.body(Body::empty())
|
||||
.expect("session request should build");
|
||||
let response = app
|
||||
.clone()
|
||||
.oneshot(request)
|
||||
.await
|
||||
.expect("session endpoint should respond");
|
||||
assert_eq!(response.status(), StatusCode::OK);
|
||||
response_json(response).await["token"]
|
||||
.as_str()
|
||||
.expect("session token should be a string")
|
||||
.to_owned()
|
||||
}
|
||||
|
||||
async fn response_json(response: Response) -> Value {
|
||||
let body = response
|
||||
.into_body()
|
||||
.collect()
|
||||
.await
|
||||
.expect("response body should be readable")
|
||||
.to_bytes();
|
||||
serde_json::from_slice(&body).expect("response should be JSON")
|
||||
}
|
||||
|
||||
fn script_task(id: &str, name: &str) -> Value {
|
||||
json!({
|
||||
"id": id,
|
||||
"name": name,
|
||||
"cron": "0 0 2 * * *",
|
||||
"enabled": true,
|
||||
"timeout_secs": 300,
|
||||
"action": {
|
||||
"type": "script",
|
||||
"working_dir": "D:\\Projects\\sync",
|
||||
"interpreter": "python.exe",
|
||||
"script": "main.py",
|
||||
"args": ["--mode", "full"],
|
||||
"env": {
|
||||
"PYTHONUNBUFFERED": "1"
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn task_crud_flow_works_end_to_end() {
|
||||
let (app, _temp_dir) = test_app().await;
|
||||
|
||||
let create_response = send_json(
|
||||
&app,
|
||||
Method::POST,
|
||||
"/api/v1/tasks",
|
||||
Some(script_task("sync_data", "Data sync")),
|
||||
)
|
||||
.await;
|
||||
assert_eq!(create_response.status(), StatusCode::CREATED);
|
||||
let created = response_json(create_response).await;
|
||||
assert_eq!(created["id"], "sync_data");
|
||||
assert_eq!(created["timezone"], "Asia/Shanghai");
|
||||
assert_eq!(created["action"]["type"], "script");
|
||||
|
||||
let get_response = send_json(&app, Method::GET, "/api/v1/tasks/sync_data", None).await;
|
||||
assert_eq!(get_response.status(), StatusCode::OK);
|
||||
assert_eq!(response_json(get_response).await["name"], "Data sync");
|
||||
|
||||
let list_response = send_json(
|
||||
&app,
|
||||
Method::GET,
|
||||
"/api/v1/tasks?q=sync&enabled=true&action_type=script&page=1&page_size=10",
|
||||
None,
|
||||
)
|
||||
.await;
|
||||
assert_eq!(list_response.status(), StatusCode::OK);
|
||||
let listed = response_json(list_response).await;
|
||||
assert_eq!(listed["total"], 1);
|
||||
assert_eq!(listed["items"][0]["id"], "sync_data");
|
||||
|
||||
let update_response = send_json(
|
||||
&app,
|
||||
Method::PUT,
|
||||
"/api/v1/tasks/sync_data",
|
||||
Some(json!({
|
||||
"name": "Updated data sync",
|
||||
"cron": "*/30 * * * * *",
|
||||
"enabled": false,
|
||||
"timeout_secs": 120,
|
||||
"action": {
|
||||
"type": "http",
|
||||
"url": "https://example.com/health",
|
||||
"method": "GET",
|
||||
"headers": {},
|
||||
"expected_status": [200]
|
||||
}
|
||||
})),
|
||||
)
|
||||
.await;
|
||||
assert_eq!(update_response.status(), StatusCode::OK);
|
||||
let updated = response_json(update_response).await;
|
||||
assert_eq!(updated["name"], "Updated data sync");
|
||||
assert_eq!(updated["enabled"], false);
|
||||
assert_eq!(updated["action"]["type"], "http");
|
||||
|
||||
let delete_response = send_json(&app, Method::DELETE, "/api/v1/tasks/sync_data", None).await;
|
||||
assert_eq!(delete_response.status(), StatusCode::NO_CONTENT);
|
||||
|
||||
let missing_response = send_json(&app, Method::GET, "/api/v1/tasks/sync_data", None).await;
|
||||
assert_eq!(missing_response.status(), StatusCode::NOT_FOUND);
|
||||
let missing = response_json(missing_response).await;
|
||||
assert_eq!(missing["code"], "TASK_NOT_FOUND");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn duplicate_task_id_returns_conflict() {
|
||||
let (app, _temp_dir) = test_app().await;
|
||||
let first = send_json(
|
||||
&app,
|
||||
Method::POST,
|
||||
"/api/v1/tasks",
|
||||
Some(script_task("duplicate", "First")),
|
||||
)
|
||||
.await;
|
||||
assert_eq!(first.status(), StatusCode::CREATED);
|
||||
|
||||
let second = send_json(
|
||||
&app,
|
||||
Method::POST,
|
||||
"/api/v1/tasks",
|
||||
Some(script_task("duplicate", "Second")),
|
||||
)
|
||||
.await;
|
||||
assert_eq!(second.status(), StatusCode::CONFLICT);
|
||||
let body = response_json(second).await;
|
||||
assert_eq!(body["code"], "TASK_ID_CONFLICT");
|
||||
assert_eq!(body["field"], "id");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn invalid_cron_returns_structured_validation_error() {
|
||||
let (app, _temp_dir) = test_app().await;
|
||||
let mut request = script_task("invalid_cron", "Invalid cron");
|
||||
request["cron"] = Value::String("*/0 * * * * *".to_owned());
|
||||
|
||||
let response = send_json(&app, Method::POST, "/api/v1/tasks", Some(request)).await;
|
||||
assert_eq!(response.status(), StatusCode::BAD_REQUEST);
|
||||
let body = response_json(response).await;
|
||||
assert_eq!(body["code"], "VALIDATION_ERROR");
|
||||
assert_eq!(body["field"], "cron");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn malformed_json_and_query_return_structured_errors() {
|
||||
let (app, _temp_dir) = test_app().await;
|
||||
let token = fetch_api_token(&app).await;
|
||||
let malformed_request = Request::builder()
|
||||
.method(Method::POST)
|
||||
.uri("/api/v1/tasks")
|
||||
.header(header::CONTENT_TYPE, "application/json")
|
||||
.header("x-taskpilot-token", token)
|
||||
.body(Body::from("{not-json"))
|
||||
.expect("request should build");
|
||||
let malformed_response = app
|
||||
.clone()
|
||||
.oneshot(malformed_request)
|
||||
.await
|
||||
.expect("router should respond");
|
||||
assert_eq!(malformed_response.status(), StatusCode::BAD_REQUEST);
|
||||
assert_eq!(
|
||||
response_json(malformed_response).await["code"],
|
||||
"INVALID_JSON"
|
||||
);
|
||||
|
||||
let invalid_query = send_json(&app, Method::GET, "/api/v1/tasks?enabled=maybe", None).await;
|
||||
assert_eq!(invalid_query.status(), StatusCode::BAD_REQUEST);
|
||||
assert_eq!(response_json(invalid_query).await["code"], "INVALID_QUERY");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn rejects_cross_origin_and_missing_management_token() {
|
||||
let (app, _temp_dir) = test_app().await;
|
||||
let cross_origin = Request::builder()
|
||||
.method(Method::GET)
|
||||
.uri("/api/v1/tasks")
|
||||
.header(header::ORIGIN, "https://evil.example")
|
||||
.body(Body::empty())
|
||||
.expect("request should build");
|
||||
let response = app
|
||||
.clone()
|
||||
.oneshot(cross_origin)
|
||||
.await
|
||||
.expect("router should respond");
|
||||
assert_eq!(response.status(), StatusCode::FORBIDDEN);
|
||||
|
||||
let missing_token = Request::builder()
|
||||
.method(Method::POST)
|
||||
.uri("/api/v1/tasks")
|
||||
.header(header::ORIGIN, "http://127.0.0.1:5173")
|
||||
.header(header::CONTENT_TYPE, "application/json")
|
||||
.body(Body::from(
|
||||
serde_json::to_vec(&script_task("blocked_task", "Blocked"))
|
||||
.expect("JSON should serialize"),
|
||||
))
|
||||
.expect("request should build");
|
||||
let response = app
|
||||
.oneshot(missing_token)
|
||||
.await
|
||||
.expect("router should respond");
|
||||
assert_eq!(response.status(), StatusCode::FORBIDDEN);
|
||||
assert_eq!(response_json(response).await["code"], "FORBIDDEN");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn list_supports_pagination_and_escaped_search() {
|
||||
let (app, _temp_dir) = test_app().await;
|
||||
for (id, name) in [
|
||||
("sync_one", "Sync one"),
|
||||
("sync_two", "Sync two"),
|
||||
("other", "100% complete"),
|
||||
] {
|
||||
let response = send_json(
|
||||
&app,
|
||||
Method::POST,
|
||||
"/api/v1/tasks",
|
||||
Some(script_task(id, name)),
|
||||
)
|
||||
.await;
|
||||
assert_eq!(response.status(), StatusCode::CREATED);
|
||||
}
|
||||
|
||||
let first_page = send_json(
|
||||
&app,
|
||||
Method::GET,
|
||||
"/api/v1/tasks?q=sync&page=1&page_size=1",
|
||||
None,
|
||||
)
|
||||
.await;
|
||||
let first_page = response_json(first_page).await;
|
||||
assert_eq!(first_page["page"], 1);
|
||||
assert_eq!(first_page["page_size"], 1);
|
||||
assert_eq!(first_page["total"], 2);
|
||||
assert_eq!(first_page["items"].as_array().map(Vec::len), Some(1));
|
||||
|
||||
let escaped = send_json(&app, Method::GET, "/api/v1/tasks?q=%25", None).await;
|
||||
let escaped = response_json(escaped).await;
|
||||
assert_eq!(escaped["total"], 1);
|
||||
assert_eq!(escaped["items"][0]["id"], "other");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn tasks_survive_database_reconnect() {
|
||||
let temp_dir = TempDir::new().expect("temporary directory should be created");
|
||||
let database_path = temp_dir.path().join("task_pilot_test.db");
|
||||
|
||||
let first_pool = connect_file(&database_path).await;
|
||||
let first_app = task_pilot_backend::app(first_pool.clone(), temp_dir.path().join("logs-first"))
|
||||
.await
|
||||
.expect("first app should start");
|
||||
let create_response = send_json(
|
||||
&first_app,
|
||||
Method::POST,
|
||||
"/api/v1/tasks",
|
||||
Some(script_task("persistent_task", "Persistent task")),
|
||||
)
|
||||
.await;
|
||||
assert_eq!(create_response.status(), StatusCode::CREATED);
|
||||
first_pool.close().await;
|
||||
drop(first_app);
|
||||
|
||||
let second_pool = connect_file(&database_path).await;
|
||||
let second_app =
|
||||
task_pilot_backend::app(second_pool.clone(), temp_dir.path().join("logs-second"))
|
||||
.await
|
||||
.expect("second app should start");
|
||||
let get_response = send_json(
|
||||
&second_app,
|
||||
Method::GET,
|
||||
"/api/v1/tasks/persistent_task",
|
||||
None,
|
||||
)
|
||||
.await;
|
||||
assert_eq!(get_response.status(), StatusCode::OK);
|
||||
assert_eq!(response_json(get_response).await["name"], "Persistent task");
|
||||
second_pool.close().await;
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,45 @@
|
||||
# TaskPilot Windows 本地版
|
||||
|
||||
## 启动
|
||||
|
||||
```powershell
|
||||
.\start.ps1
|
||||
```
|
||||
|
||||
然后访问 <http://127.0.0.1:3000>。
|
||||
|
||||
需要自动打开浏览器时:
|
||||
|
||||
```powershell
|
||||
.\start.ps1 -OpenBrowser
|
||||
```
|
||||
|
||||
## 数据目录
|
||||
|
||||
- SQLite 数据库:`data/task_pilot.db`
|
||||
- 运行日志:`logs/{task_id}/{run_id}.log`
|
||||
|
||||
## 配置
|
||||
|
||||
```powershell
|
||||
$env:TASK_PILOT_PORT = "3000"
|
||||
$env:TASK_PILOT_DATABASE_URL = "sqlite://data/task_pilot.db"
|
||||
$env:TASK_PILOT_LOG_DIR = "logs"
|
||||
$env:TASK_PILOT_LOG_RETENTION_DAYS = "30"
|
||||
$env:TASK_PILOT_LOG_MAX_TOTAL_MB = "512"
|
||||
$env:TASK_PILOT_STATIC_DIR = "web"
|
||||
```
|
||||
|
||||
## 安全边界
|
||||
|
||||
- 服务只监听 `127.0.0.1`。
|
||||
- 浏览器跨站 Origin 会被拒绝。
|
||||
- 写操作需要运行期随机管理令牌。
|
||||
- 管理令牌在进程重启后自动更换。
|
||||
|
||||
## 故障排查
|
||||
|
||||
- 3000 端口被占用时,通过 `TASK_PILOT_PORT` 修改端口;打包版前端使用同源 API,无需重新构建。
|
||||
- 页面无法打开时,确认 `web/index.html` 存在。
|
||||
- 任务未执行时,确认任务已启用、Action 配置有效,并在日志抽屉查看错误。
|
||||
- 进程无法启动时,检查解释器或可执行文件路径、工作目录和执行权限。
|
||||
@@ -0,0 +1,34 @@
|
||||
# ADR-0001:Cron 解析与任务调度
|
||||
|
||||
- 状态:已接受
|
||||
- 日期:2026-10-18
|
||||
|
||||
## 背景
|
||||
|
||||
TaskPilot 需要校验 5 位和 6 位 Cron 表达式、计算未来执行时间,并支持在任务创建、编辑、启停后动态调整调度。
|
||||
|
||||
## 决策
|
||||
|
||||
使用 `croner` 负责 Cron 表达式解析和下次执行时间计算,使用 Tokio 定时循环负责任务注册、等待和触发。
|
||||
|
||||
第一阶段验证结果:
|
||||
|
||||
- 支持 5 位分钟级和 6 位秒级表达式。
|
||||
- 支持 `@hourly` 等别名。
|
||||
- 支持月份和星期名称,星期 `7` 与 `0` 均表示星期日。
|
||||
- 默认按 POSIX 语义处理日和星期的组合。
|
||||
- 非法的 `*/0` 会被拒绝,不会形成无限循环。
|
||||
- 可从指定时间计算下一次及连续多次执行时间。
|
||||
|
||||
## 原因
|
||||
|
||||
- `croner` 专注解析和日期计算,不绑定调度运行时。
|
||||
- 自建 Tokio 循环可以按 SQLite 中的任务状态动态注册和取消任务。
|
||||
- 避免将持久化任务 ID 和外部调度器 ID 做双重状态映射。
|
||||
- 后续可以替换触发层而无需更换 Cron 表达式语义。
|
||||
|
||||
## 影响
|
||||
|
||||
- 调度器需要自行维护任务句柄、取消信号和执行并发策略。
|
||||
- 必须用可控时间测试覆盖等待逻辑、任务更新和进程关闭。
|
||||
- 时区支持继续使用 Chrono 的时区能力,Phase 2 引入 `chrono-tz` 存储 IANA 时区。
|
||||
@@ -0,0 +1,21 @@
|
||||
# ADR-0002:前后端开发与生产托管
|
||||
|
||||
- 状态:已接受
|
||||
- 日期:2026-10-18
|
||||
|
||||
## 决策
|
||||
|
||||
开发环境由 Vite 提供 React 页面,并将 `/api` 代理到 `http://127.0.0.1:3000` 的 Axum 服务。
|
||||
|
||||
生产环境先构建 React 静态资源,再由 Axum 托管构建产物。未知的非 `/api` 路由回退到 `index.html`,以支持 React Router。
|
||||
|
||||
## 原因
|
||||
|
||||
- 开发时保留 Vite HMR 和快速反馈。
|
||||
- 生产时只发布一个后端可执行程序及静态资源目录,降低 Windows 本地部署复杂度。
|
||||
- 同源访问可以减少 CORS 和 Cookie/令牌配置的复杂度。
|
||||
|
||||
## 影响
|
||||
|
||||
- 后端必须明确区分 API 404 和前端路由回退。
|
||||
- 静态资源托管将在 Phase 6 交付时接入,Phase 1 只完成开发代理。
|
||||
@@ -0,0 +1,24 @@
|
||||
# Logs
|
||||
logs
|
||||
*.log
|
||||
npm-debug.log*
|
||||
yarn-debug.log*
|
||||
yarn-error.log*
|
||||
pnpm-debug.log*
|
||||
lerna-debug.log*
|
||||
|
||||
node_modules
|
||||
dist
|
||||
dist-ssr
|
||||
*.local
|
||||
|
||||
# Editor directories and files
|
||||
.vscode/*
|
||||
!.vscode/extensions.json
|
||||
.idea
|
||||
.DS_Store
|
||||
*.suo
|
||||
*.ntvs*
|
||||
*.njsproj
|
||||
*.sln
|
||||
*.sw?
|
||||
@@ -0,0 +1,9 @@
|
||||
{
|
||||
"$schema": "./node_modules/oxlint/configuration_schema.json",
|
||||
"plugins": ["react", "typescript", "oxc"],
|
||||
"rules": {
|
||||
"react/rules-of-hooks": "error",
|
||||
"react/only-export-components": ["warn", { "allowConstantExport": true }],
|
||||
"react/incompatible-library": "off"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,151 @@
|
||||
import { expect, test, type APIRequestContext } from '@playwright/test'
|
||||
|
||||
const taskPayload = {
|
||||
id: 'e2e_echo',
|
||||
name: 'E2E echo',
|
||||
cron: '0 0 2 * * *',
|
||||
timezone: 'Asia/Shanghai',
|
||||
enabled: true,
|
||||
timeout_secs: 10,
|
||||
action: {
|
||||
type: 'script',
|
||||
working_dir: '.',
|
||||
interpreter: 'cmd.exe',
|
||||
script: '/C',
|
||||
args: ['echo', 'e2e-output'],
|
||||
env: {},
|
||||
},
|
||||
}
|
||||
|
||||
async function authHeaders(request: APIRequestContext) {
|
||||
const response = await request.get('/api/v1/session')
|
||||
expect(response.ok()).toBeTruthy()
|
||||
const session = (await response.json()) as { token: string }
|
||||
return { 'X-TaskPilot-Token': session.token }
|
||||
}
|
||||
|
||||
test('manages a task and reads a real run log', async ({ page, request }) => {
|
||||
const headers = await authHeaders(request)
|
||||
const existing = await request.get('/api/v1/tasks?page=1&page_size=100')
|
||||
expect(existing.ok()).toBeTruthy()
|
||||
const existingBody = (await existing.json()) as {
|
||||
items: Array<{ id: string }>
|
||||
}
|
||||
for (const task of existingBody.items) {
|
||||
await request.delete(`/api/v1/tasks/${task.id}`, { headers })
|
||||
}
|
||||
|
||||
const createResponse = await request.post('/api/v1/tasks', {
|
||||
data: taskPayload,
|
||||
headers,
|
||||
})
|
||||
expect(createResponse.status()).toBe(201)
|
||||
|
||||
await page.goto('/')
|
||||
await expect(page.getByText('E2E echo')).toBeVisible()
|
||||
await page.screenshot({
|
||||
path: 'test-results/dashboard-desktop.png',
|
||||
fullPage: true,
|
||||
})
|
||||
|
||||
await page.getByRole('button', { name: '+ 新建任务' }).click()
|
||||
const newTaskDialog = page.getByRole('dialog', { name: '新建定时任务' })
|
||||
await expect(newTaskDialog).toBeVisible()
|
||||
await expect(newTaskDialog.getByLabel('任务标识')).toBeFocused()
|
||||
await page.screenshot({
|
||||
path: 'test-results/dashboard-task-editor.png',
|
||||
fullPage: true,
|
||||
})
|
||||
await page.keyboard.press('Escape')
|
||||
await expect(newTaskDialog).toBeHidden()
|
||||
|
||||
await page.getByRole('button', { name: '运行' }).click()
|
||||
const drawer = page.getByRole('dialog', { name: 'E2E echo' })
|
||||
await expect(drawer).toBeVisible()
|
||||
await expect(drawer.getByText('e2e-output')).toBeVisible()
|
||||
await expect(drawer.locator('.status-succeeded').first()).toBeVisible()
|
||||
await page.screenshot({
|
||||
path: 'test-results/dashboard-log-drawer.png',
|
||||
fullPage: true,
|
||||
})
|
||||
await drawer.getByRole('button', { name: '关闭运行日志' }).click()
|
||||
|
||||
await page.getByRole('button', { name: '编辑' }).click()
|
||||
const editor = page.getByRole('dialog', {
|
||||
name: '编辑任务 [e2e_echo]',
|
||||
})
|
||||
await expect(editor).toBeVisible()
|
||||
const nameInput = editor.getByLabel('任务名称')
|
||||
await nameInput.fill('E2E updated')
|
||||
await editor.getByRole('button', { name: '保存修改' }).click()
|
||||
await expect(page.getByText('E2E updated')).toBeVisible()
|
||||
|
||||
await page.getByRole('button', { name: '停用' }).click()
|
||||
await expect(
|
||||
page
|
||||
.getByRole('row', { name: /E2E updated/ })
|
||||
.locator('.status-disabled'),
|
||||
).toBeVisible()
|
||||
|
||||
await page.getByRole('button', { name: '删除' }).click()
|
||||
await page
|
||||
.getByRole('alertdialog')
|
||||
.getByRole('button', { name: '确认删除' })
|
||||
.click()
|
||||
await expect(page.getByRole('heading', { name: '还没有任务' })).toBeVisible()
|
||||
})
|
||||
|
||||
test('keeps the dashboard usable on a narrow viewport', async ({
|
||||
page,
|
||||
request,
|
||||
}) => {
|
||||
const headers = await authHeaders(request)
|
||||
await request.post('/api/v1/tasks', {
|
||||
headers,
|
||||
data: {
|
||||
...taskPayload,
|
||||
id: 'e2e_mobile',
|
||||
name: 'Mobile task',
|
||||
},
|
||||
})
|
||||
await page.setViewportSize({ width: 390, height: 844 })
|
||||
await page.goto('/')
|
||||
await expect(page.getByText('Mobile task')).toBeVisible()
|
||||
await expect(page.getByRole('button', { name: '+ 新建任务' })).toBeVisible()
|
||||
await page.screenshot({
|
||||
path: 'test-results/dashboard-mobile.png',
|
||||
fullPage: true,
|
||||
})
|
||||
})
|
||||
|
||||
test('cancels a running task from the log drawer', async ({
|
||||
page,
|
||||
request,
|
||||
}) => {
|
||||
const headers = await authHeaders(request)
|
||||
const response = await request.post('/api/v1/tasks', {
|
||||
headers,
|
||||
data: {
|
||||
...taskPayload,
|
||||
id: 'e2e_cancel',
|
||||
name: 'Cancel task',
|
||||
timeout_secs: 30,
|
||||
action: {
|
||||
...taskPayload.action,
|
||||
args: ['ping', '-t', '127.0.0.1'],
|
||||
},
|
||||
},
|
||||
})
|
||||
expect(response.status()).toBe(201)
|
||||
|
||||
await page.goto('/')
|
||||
await page
|
||||
.getByRole('row', { name: /Cancel task/ })
|
||||
.getByRole('button', { name: '运行' })
|
||||
.click()
|
||||
const drawer = page.getByRole('dialog', { name: 'Cancel task' })
|
||||
await expect(drawer).toBeVisible()
|
||||
await expect(drawer.getByText('SSE 实时连接')).toBeVisible()
|
||||
await drawer.getByRole('button', { name: '取消运行' }).click()
|
||||
await expect(drawer.locator('.status-cancelled').first()).toBeVisible()
|
||||
})
|
||||
@@ -0,0 +1,22 @@
|
||||
<!doctype html>
|
||||
<html lang="zh-CN">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<meta
|
||||
name="description"
|
||||
content="TaskPilot - Windows 本地定时任务管理平台"
|
||||
/>
|
||||
<link rel="preconnect" href="https://fonts.googleapis.com" />
|
||||
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
|
||||
<link
|
||||
href="https://fonts.googleapis.com/css2?family=Plus+Jakarta+Sans:wght@400;500;600;700&family=JetBrains+Mono:wght@400;500&display=swap"
|
||||
rel="stylesheet"
|
||||
/>
|
||||
<title>TaskPilot</title>
|
||||
</head>
|
||||
<body>
|
||||
<div id="root"></div>
|
||||
<script type="module" src="/src/main.tsx"></script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,47 @@
|
||||
{
|
||||
"name": "taskpilot-frontend",
|
||||
"private": true,
|
||||
"version": "0.0.0",
|
||||
"type": "module",
|
||||
"packageManager": "[email protected]",
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
"build": "tsc -b && vite build",
|
||||
"lint": "oxlint",
|
||||
"typecheck": "tsc -b",
|
||||
"test": "vitest run",
|
||||
"test:watch": "vitest",
|
||||
"test:e2e": "powershell -NoProfile -ExecutionPolicy Bypass -File ../scripts/e2e.ps1",
|
||||
"generate:api": "openapi-typescript ../contracts/openapi.json -o src/api/generated.ts",
|
||||
"preview": "vite preview"
|
||||
},
|
||||
"dependencies": {
|
||||
"@hookform/resolvers": "^5.9.2",
|
||||
"@tanstack/react-query": "^5.104.3",
|
||||
"react": "^19.2.8",
|
||||
"react-dom": "^19.2.8",
|
||||
"react-hook-form": "^7.90.0",
|
||||
"react-router-dom": "^7.18.4",
|
||||
"zod": "^4.6.7"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@playwright/test": "^1.64.1",
|
||||
"@testing-library/jest-dom": "^7.0.1",
|
||||
"@testing-library/react": "^16.3.3",
|
||||
"@testing-library/user-event": "^14.6.8",
|
||||
"@types/node": "^24.13.3",
|
||||
"@types/react": "^19.2.18",
|
||||
"@types/react-dom": "^19.2.7",
|
||||
"@vitejs/plugin-react": "^6.1.1",
|
||||
"jsdom": "^30.1.3",
|
||||
"openapi-typescript": "^7.13.0",
|
||||
"oxlint": "^1.81.0",
|
||||
"typescript": "~5.9.3",
|
||||
"vite": "^8.3.4",
|
||||
"vitest": "^5.0.5"
|
||||
},
|
||||
"volta": {
|
||||
"node": "24.12.0",
|
||||
"pnpm": "11.20.0"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,53 @@
|
||||
import { mkdirSync } from 'node:fs'
|
||||
import { defineConfig, devices } from '@playwright/test'
|
||||
|
||||
mkdirSync(new URL('../.e2e-data/', import.meta.url), { recursive: true })
|
||||
|
||||
const externalServers = process.env.TASKPILOT_E2E_EXTERNAL === '1'
|
||||
|
||||
export default defineConfig({
|
||||
testDir: './e2e',
|
||||
timeout: 30_000,
|
||||
expect: {
|
||||
timeout: 8_000,
|
||||
},
|
||||
fullyParallel: false,
|
||||
workers: 1,
|
||||
reporter: 'list',
|
||||
use: {
|
||||
baseURL: 'http://127.0.0.1:5173',
|
||||
channel: 'chrome',
|
||||
trace: 'retain-on-failure',
|
||||
},
|
||||
webServer: externalServers
|
||||
? undefined
|
||||
: [
|
||||
{
|
||||
command: 'backend\\target\\debug\\task-pilot-backend.exe',
|
||||
cwd: '..',
|
||||
url: 'http://127.0.0.1:3000/api/v1/health',
|
||||
timeout: 120_000,
|
||||
reuseExistingServer: false,
|
||||
env: {
|
||||
TASK_PILOT_DATABASE_URL: 'sqlite://.e2e-data/task_pilot.db',
|
||||
TASK_PILOT_LOG_DIR: '.e2e-data/logs',
|
||||
},
|
||||
},
|
||||
{
|
||||
command: 'node node_modules/vite/bin/vite.js --host 127.0.0.1',
|
||||
cwd: '.',
|
||||
url: 'http://127.0.0.1:5173',
|
||||
timeout: 120_000,
|
||||
reuseExistingServer: false,
|
||||
},
|
||||
],
|
||||
projects: [
|
||||
{
|
||||
name: 'chromium',
|
||||
use: {
|
||||
...devices['Desktop Chrome'],
|
||||
viewport: { width: 1440, height: 1000 },
|
||||
},
|
||||
},
|
||||
],
|
||||
})
|
||||
Generated
+1894
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,156 @@
|
||||
import { QueryClient, QueryClientProvider } from '@tanstack/react-query'
|
||||
import { render, screen, waitFor } from '@testing-library/react'
|
||||
import userEvent from '@testing-library/user-event'
|
||||
import { MemoryRouter } from 'react-router-dom'
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
import type { Task, TaskListResponse } from './api/client'
|
||||
import App from './App'
|
||||
|
||||
const task: Task = {
|
||||
id: 'echo_task',
|
||||
name: 'Echo task',
|
||||
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',
|
||||
interpreter: 'python.exe',
|
||||
script: 'main.py',
|
||||
args: [],
|
||||
env: {},
|
||||
},
|
||||
created_at: '2026-10-18T10:00:00Z',
|
||||
updated_at: '2026-10-18T10:00:00Z',
|
||||
}
|
||||
|
||||
const taskList: TaskListResponse = {
|
||||
items: [task],
|
||||
page: 1,
|
||||
page_size: 20,
|
||||
total: 1,
|
||||
}
|
||||
|
||||
function jsonResponse(body: unknown, status = 200): Response {
|
||||
return new Response(JSON.stringify(body), {
|
||||
status,
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
})
|
||||
}
|
||||
|
||||
function createFetchMock() {
|
||||
return vi.fn(
|
||||
async (input: RequestInfo | URL, init?: RequestInit): Promise<Response> => {
|
||||
const url =
|
||||
typeof input === 'string'
|
||||
? input
|
||||
: input instanceof URL
|
||||
? input.toString()
|
||||
: input.url
|
||||
|
||||
if (url.endsWith('/api/v1/health')) {
|
||||
return jsonResponse({
|
||||
status: 'ok',
|
||||
service: 'task-pilot-backend',
|
||||
version: '0.1.0',
|
||||
})
|
||||
}
|
||||
if (url.endsWith('/api/v1/session')) {
|
||||
return jsonResponse({ token: 'test-token' })
|
||||
}
|
||||
if (url.endsWith('/api/v1/cron/preview')) {
|
||||
return jsonResponse({
|
||||
timezone: 'Asia/Shanghai',
|
||||
runs: [
|
||||
'2026-10-19 02:00:00',
|
||||
'2026-10-20 02:00:00',
|
||||
],
|
||||
})
|
||||
}
|
||||
if (url.includes('/api/v1/tasks') && init?.method === 'POST') {
|
||||
return jsonResponse(
|
||||
{
|
||||
...task,
|
||||
...JSON.parse(String(init.body)),
|
||||
},
|
||||
201,
|
||||
)
|
||||
}
|
||||
if (url.includes('/api/v1/tasks')) {
|
||||
return jsonResponse(taskList)
|
||||
}
|
||||
return jsonResponse({ code: 'NOT_FOUND', message: 'not found', details: {} }, 404)
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
function renderApp() {
|
||||
const queryClient = new QueryClient({
|
||||
defaultOptions: {
|
||||
queries: {
|
||||
retry: false,
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
return render(
|
||||
<QueryClientProvider client={queryClient}>
|
||||
<MemoryRouter>
|
||||
<App />
|
||||
</MemoryRouter>
|
||||
</QueryClientProvider>,
|
||||
)
|
||||
}
|
||||
|
||||
describe('TaskDashboard', () => {
|
||||
it('renders tasks returned by the Rust API', async () => {
|
||||
vi.stubGlobal('fetch', createFetchMock())
|
||||
renderApp()
|
||||
|
||||
expect(await screen.findByText('Echo task')).toBeInTheDocument()
|
||||
expect(screen.getByText('echo_task')).toBeInTheDocument()
|
||||
expect(screen.getByText('0 0 2 * * *')).toBeInTheDocument()
|
||||
expect(await screen.findByText('在线')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('creates a script task with the visible form values', async () => {
|
||||
const fetchMock = createFetchMock()
|
||||
vi.stubGlobal('fetch', fetchMock)
|
||||
const user = userEvent.setup()
|
||||
renderApp()
|
||||
|
||||
await user.click(screen.getByRole('button', { name: '+ 新建任务' }))
|
||||
await user.clear(screen.getByLabelText('任务标识'))
|
||||
await user.type(screen.getByLabelText('任务标识'), 'new_task')
|
||||
await user.type(screen.getByLabelText('任务名称'), 'New task')
|
||||
await user.type(screen.getByLabelText('工作目录'), 'D:\\Projects\\new')
|
||||
|
||||
await user.click(
|
||||
screen.getByRole('button', { name: '保存并热刷新' }),
|
||||
)
|
||||
|
||||
await waitFor(() => {
|
||||
const createCall = fetchMock.mock.calls.find(
|
||||
([input, init]) =>
|
||||
String(input).endsWith('/api/v1/tasks') && init?.method === 'POST',
|
||||
)
|
||||
expect(createCall).toBeDefined()
|
||||
})
|
||||
|
||||
const createCall = fetchMock.mock.calls.find(
|
||||
([input, init]) =>
|
||||
String(input).endsWith('/api/v1/tasks') && init?.method === 'POST',
|
||||
)
|
||||
const payload = JSON.parse(String(createCall?.[1]?.body))
|
||||
expect(payload.id).toBe('new_task')
|
||||
expect(payload.name).toBe('New task')
|
||||
expect(payload.action.type).toBe('script')
|
||||
expect(payload.action.working_dir).toBe('D:\\Projects\\new')
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,31 @@
|
||||
import { Link, Route, Routes } from 'react-router-dom'
|
||||
import { TaskDashboard } from './pages/TaskDashboard'
|
||||
import './App.css'
|
||||
|
||||
function NotFoundPage() {
|
||||
return (
|
||||
<main className="app-shell">
|
||||
<header className="dashboard-header">
|
||||
<div>
|
||||
<p className="eyebrow">404 / ROUTE NOT FOUND</p>
|
||||
<h1>页面不存在</h1>
|
||||
<p className="subtitle">该地址尚未配置到 TaskPilot 路由。</p>
|
||||
</div>
|
||||
</header>
|
||||
<Link className="button button-primary" to="/">
|
||||
返回任务列表
|
||||
</Link>
|
||||
</main>
|
||||
)
|
||||
}
|
||||
|
||||
function App() {
|
||||
return (
|
||||
<Routes>
|
||||
<Route path="/" element={<TaskDashboard />} />
|
||||
<Route path="*" element={<NotFoundPage />} />
|
||||
</Routes>
|
||||
)
|
||||
}
|
||||
|
||||
export default App
|
||||
@@ -0,0 +1,234 @@
|
||||
import type { components } from './generated'
|
||||
|
||||
export type HealthResponse = components['schemas']['HealthResponse']
|
||||
export type Task = components['schemas']['TaskResponse']
|
||||
export type TaskAction = components['schemas']['TaskAction']
|
||||
export type TaskActionType = components['schemas']['TaskActionType']
|
||||
export type CreateTaskRequest = components['schemas']['CreateTaskRequest']
|
||||
export type UpdateTaskRequest = components['schemas']['UpdateTaskRequest']
|
||||
export type TaskListResponse = components['schemas']['TaskListResponse']
|
||||
export type TaskRun = components['schemas']['TaskRunResponse']
|
||||
export type TaskRunListResponse = components['schemas']['TaskRunListResponse']
|
||||
export type RunStatus = components['schemas']['RunStatus']
|
||||
export type RunTrigger = components['schemas']['RunTrigger']
|
||||
export type LogChunkResponse = components['schemas']['LogChunkResponse']
|
||||
export type ErrorResponse = components['schemas']['ErrorResponse']
|
||||
export type SessionResponse = components['schemas']['SessionResponse']
|
||||
export type CronPreviewRequest = components['schemas']['CronPreviewRequest']
|
||||
export type CronPreviewResponse = components['schemas']['CronPreviewResponse']
|
||||
|
||||
export interface TaskFilters {
|
||||
q?: string
|
||||
enabled?: boolean
|
||||
action_type?: TaskActionType
|
||||
page?: number
|
||||
page_size?: number
|
||||
}
|
||||
|
||||
export interface RunFilters {
|
||||
page?: number
|
||||
page_size?: number
|
||||
status?: RunStatus
|
||||
trigger?: RunTrigger
|
||||
}
|
||||
|
||||
export type ExecutionEvent =
|
||||
| {
|
||||
event: 'run'
|
||||
data: {
|
||||
run: TaskRun
|
||||
}
|
||||
}
|
||||
| {
|
||||
event: 'log'
|
||||
data: {
|
||||
run_id: number
|
||||
offset: number
|
||||
next_offset: number
|
||||
content: string
|
||||
}
|
||||
}
|
||||
|
||||
export class ApiRequestError extends Error {
|
||||
readonly status: number
|
||||
readonly code?: string
|
||||
readonly field?: string | null
|
||||
readonly details?: Record<string, string>
|
||||
|
||||
constructor(status: number, body?: ErrorResponse) {
|
||||
super(body?.message ?? `请求失败(HTTP ${status})`)
|
||||
this.name = 'ApiRequestError'
|
||||
this.status = status
|
||||
this.code = body?.code
|
||||
this.field = body?.field
|
||||
this.details = body?.details
|
||||
}
|
||||
}
|
||||
|
||||
async function requestJson<T>(
|
||||
path: string,
|
||||
init: RequestInit = {},
|
||||
): Promise<T> {
|
||||
const headers = new Headers(init.headers)
|
||||
headers.set('Accept', 'application/json')
|
||||
const method = (init.method ?? 'GET').toUpperCase()
|
||||
if (init.body) {
|
||||
headers.set('Content-Type', 'application/json')
|
||||
}
|
||||
if (!['GET', 'HEAD', 'OPTIONS'].includes(method)) {
|
||||
headers.set('X-TaskPilot-Token', await fetchSessionToken())
|
||||
}
|
||||
|
||||
const response = await fetch(path, {
|
||||
...init,
|
||||
headers,
|
||||
})
|
||||
|
||||
if (!response.ok) {
|
||||
let body: ErrorResponse | undefined
|
||||
try {
|
||||
body = (await response.json()) as ErrorResponse
|
||||
} catch {
|
||||
body = undefined
|
||||
}
|
||||
throw new ApiRequestError(response.status, body)
|
||||
}
|
||||
|
||||
if (response.status === 204) {
|
||||
return undefined as T
|
||||
}
|
||||
return (await response.json()) as T
|
||||
}
|
||||
|
||||
async function fetchSessionToken(): Promise<string> {
|
||||
const response = await fetch('/api/v1/session', {
|
||||
headers: { Accept: 'application/json' },
|
||||
})
|
||||
if (!response.ok) {
|
||||
throw new ApiRequestError(response.status)
|
||||
}
|
||||
const session = (await response.json()) as SessionResponse
|
||||
return session.token
|
||||
}
|
||||
|
||||
function withQuery(path: string, values: object): string {
|
||||
const query = new URLSearchParams()
|
||||
for (const [key, value] of Object.entries(values)) {
|
||||
if (value !== undefined && value !== null && value !== '') {
|
||||
query.set(key, String(value))
|
||||
}
|
||||
}
|
||||
const serialized = query.toString()
|
||||
return serialized ? `${path}?${serialized}` : path
|
||||
}
|
||||
|
||||
export function fetchHealth(signal?: AbortSignal): Promise<HealthResponse> {
|
||||
return requestJson<HealthResponse>('/api/v1/health', { signal })
|
||||
}
|
||||
|
||||
export function listTasks(
|
||||
filters: TaskFilters = {},
|
||||
signal?: AbortSignal,
|
||||
): Promise<TaskListResponse> {
|
||||
return requestJson<TaskListResponse>(
|
||||
withQuery('/api/v1/tasks', filters),
|
||||
{ signal },
|
||||
)
|
||||
}
|
||||
|
||||
export function getTask(id: string, signal?: AbortSignal): Promise<Task> {
|
||||
return requestJson<Task>(`/api/v1/tasks/${encodeURIComponent(id)}`, {
|
||||
signal,
|
||||
})
|
||||
}
|
||||
|
||||
export function createTask(payload: CreateTaskRequest): Promise<Task> {
|
||||
return requestJson<Task>('/api/v1/tasks', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify(payload),
|
||||
})
|
||||
}
|
||||
|
||||
export function updateTask(
|
||||
id: string,
|
||||
payload: UpdateTaskRequest,
|
||||
): Promise<Task> {
|
||||
return requestJson<Task>(`/api/v1/tasks/${encodeURIComponent(id)}`, {
|
||||
method: 'PUT',
|
||||
body: JSON.stringify(payload),
|
||||
})
|
||||
}
|
||||
|
||||
export function deleteTask(id: string): Promise<void> {
|
||||
return requestJson<void>(`/api/v1/tasks/${encodeURIComponent(id)}`, {
|
||||
method: 'DELETE',
|
||||
})
|
||||
}
|
||||
|
||||
export function setTaskEnabled(
|
||||
id: string,
|
||||
enabled: boolean,
|
||||
): Promise<Task> {
|
||||
const action = enabled ? 'enable' : 'disable'
|
||||
return requestJson<Task>(
|
||||
`/api/v1/tasks/${encodeURIComponent(id)}/${action}`,
|
||||
{ method: 'POST' },
|
||||
)
|
||||
}
|
||||
|
||||
export function runTask(id: string): Promise<TaskRun> {
|
||||
return requestJson<TaskRun>(
|
||||
`/api/v1/tasks/${encodeURIComponent(id)}/run`,
|
||||
{ method: 'POST' },
|
||||
)
|
||||
}
|
||||
|
||||
export function listTaskRuns(
|
||||
taskId: string,
|
||||
filters: RunFilters = {},
|
||||
signal?: AbortSignal,
|
||||
): Promise<TaskRunListResponse> {
|
||||
return requestJson<TaskRunListResponse>(
|
||||
withQuery(`/api/v1/tasks/${encodeURIComponent(taskId)}/runs`, filters),
|
||||
{ signal },
|
||||
)
|
||||
}
|
||||
|
||||
export function getRun(
|
||||
runId: number,
|
||||
signal?: AbortSignal,
|
||||
): Promise<TaskRun> {
|
||||
return requestJson<TaskRun>(`/api/v1/runs/${runId}`, { signal })
|
||||
}
|
||||
|
||||
export function getRunLogs(
|
||||
runId: number,
|
||||
offset = 0,
|
||||
signal?: AbortSignal,
|
||||
): Promise<LogChunkResponse> {
|
||||
return requestJson<LogChunkResponse>(
|
||||
withQuery(`/api/v1/runs/${runId}/logs`, { offset }),
|
||||
{ signal },
|
||||
)
|
||||
}
|
||||
|
||||
export function cancelRun(runId: number): Promise<TaskRun> {
|
||||
return requestJson<TaskRun>(`/api/v1/runs/${runId}/cancel`, {
|
||||
method: 'POST',
|
||||
})
|
||||
}
|
||||
|
||||
export function runLogDownloadUrl(runId: number): string {
|
||||
return `/api/v1/runs/${runId}/logs/download`
|
||||
}
|
||||
|
||||
export function previewCron(
|
||||
request: CronPreviewRequest,
|
||||
signal?: AbortSignal,
|
||||
): Promise<CronPreviewResponse> {
|
||||
return requestJson<CronPreviewResponse>('/api/v1/cron/preview', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify(request),
|
||||
signal,
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,940 @@
|
||||
/**
|
||||
* This file was auto-generated by openapi-typescript.
|
||||
* Do not make direct changes to the file.
|
||||
*/
|
||||
|
||||
export interface paths {
|
||||
"/api/v1/cron/preview": {
|
||||
parameters: {
|
||||
query?: never;
|
||||
header?: never;
|
||||
path?: never;
|
||||
cookie?: never;
|
||||
};
|
||||
get?: never;
|
||||
put?: never;
|
||||
post: operations["preview_cron"];
|
||||
delete?: never;
|
||||
options?: never;
|
||||
head?: never;
|
||||
patch?: never;
|
||||
trace?: never;
|
||||
};
|
||||
"/api/v1/events": {
|
||||
parameters: {
|
||||
query?: never;
|
||||
header?: never;
|
||||
path?: never;
|
||||
cookie?: never;
|
||||
};
|
||||
get: operations["execution_events"];
|
||||
put?: never;
|
||||
post?: never;
|
||||
delete?: never;
|
||||
options?: never;
|
||||
head?: never;
|
||||
patch?: never;
|
||||
trace?: never;
|
||||
};
|
||||
"/api/v1/health": {
|
||||
parameters: {
|
||||
query?: never;
|
||||
header?: never;
|
||||
path?: never;
|
||||
cookie?: never;
|
||||
};
|
||||
get: operations["health"];
|
||||
put?: never;
|
||||
post?: never;
|
||||
delete?: never;
|
||||
options?: never;
|
||||
head?: never;
|
||||
patch?: never;
|
||||
trace?: never;
|
||||
};
|
||||
"/api/v1/runs/{run_id}": {
|
||||
parameters: {
|
||||
query?: never;
|
||||
header?: never;
|
||||
path?: never;
|
||||
cookie?: never;
|
||||
};
|
||||
get: operations["get_run"];
|
||||
put?: never;
|
||||
post?: never;
|
||||
delete?: never;
|
||||
options?: never;
|
||||
head?: never;
|
||||
patch?: never;
|
||||
trace?: never;
|
||||
};
|
||||
"/api/v1/runs/{run_id}/cancel": {
|
||||
parameters: {
|
||||
query?: never;
|
||||
header?: never;
|
||||
path?: never;
|
||||
cookie?: never;
|
||||
};
|
||||
get?: never;
|
||||
put?: never;
|
||||
post: operations["cancel_run"];
|
||||
delete?: never;
|
||||
options?: never;
|
||||
head?: never;
|
||||
patch?: never;
|
||||
trace?: never;
|
||||
};
|
||||
"/api/v1/runs/{run_id}/logs": {
|
||||
parameters: {
|
||||
query?: never;
|
||||
header?: never;
|
||||
path?: never;
|
||||
cookie?: never;
|
||||
};
|
||||
get: operations["get_run_logs"];
|
||||
put?: never;
|
||||
post?: never;
|
||||
delete?: never;
|
||||
options?: never;
|
||||
head?: never;
|
||||
patch?: never;
|
||||
trace?: never;
|
||||
};
|
||||
"/api/v1/runs/{run_id}/logs/download": {
|
||||
parameters: {
|
||||
query?: never;
|
||||
header?: never;
|
||||
path?: never;
|
||||
cookie?: never;
|
||||
};
|
||||
get: operations["download_run_logs"];
|
||||
put?: never;
|
||||
post?: never;
|
||||
delete?: never;
|
||||
options?: never;
|
||||
head?: never;
|
||||
patch?: never;
|
||||
trace?: never;
|
||||
};
|
||||
"/api/v1/session": {
|
||||
parameters: {
|
||||
query?: never;
|
||||
header?: never;
|
||||
path?: never;
|
||||
cookie?: never;
|
||||
};
|
||||
get: operations["session"];
|
||||
put?: never;
|
||||
post?: never;
|
||||
delete?: never;
|
||||
options?: never;
|
||||
head?: never;
|
||||
patch?: never;
|
||||
trace?: never;
|
||||
};
|
||||
"/api/v1/tasks": {
|
||||
parameters: {
|
||||
query?: never;
|
||||
header?: never;
|
||||
path?: never;
|
||||
cookie?: never;
|
||||
};
|
||||
get: operations["list_tasks"];
|
||||
put?: never;
|
||||
post: operations["create_task"];
|
||||
delete?: never;
|
||||
options?: never;
|
||||
head?: never;
|
||||
patch?: never;
|
||||
trace?: never;
|
||||
};
|
||||
"/api/v1/tasks/{id}": {
|
||||
parameters: {
|
||||
query?: never;
|
||||
header?: never;
|
||||
path?: never;
|
||||
cookie?: never;
|
||||
};
|
||||
get: operations["get_task"];
|
||||
put: operations["update_task"];
|
||||
post?: never;
|
||||
delete: operations["delete_task"];
|
||||
options?: never;
|
||||
head?: never;
|
||||
patch?: never;
|
||||
trace?: never;
|
||||
};
|
||||
"/api/v1/tasks/{id}/disable": {
|
||||
parameters: {
|
||||
query?: never;
|
||||
header?: never;
|
||||
path?: never;
|
||||
cookie?: never;
|
||||
};
|
||||
get?: never;
|
||||
put?: never;
|
||||
post: operations["disable_task"];
|
||||
delete?: never;
|
||||
options?: never;
|
||||
head?: never;
|
||||
patch?: never;
|
||||
trace?: never;
|
||||
};
|
||||
"/api/v1/tasks/{id}/enable": {
|
||||
parameters: {
|
||||
query?: never;
|
||||
header?: never;
|
||||
path?: never;
|
||||
cookie?: never;
|
||||
};
|
||||
get?: never;
|
||||
put?: never;
|
||||
post: operations["enable_task"];
|
||||
delete?: never;
|
||||
options?: never;
|
||||
head?: never;
|
||||
patch?: never;
|
||||
trace?: never;
|
||||
};
|
||||
"/api/v1/tasks/{id}/run": {
|
||||
parameters: {
|
||||
query?: never;
|
||||
header?: never;
|
||||
path?: never;
|
||||
cookie?: never;
|
||||
};
|
||||
get?: never;
|
||||
put?: never;
|
||||
post: operations["run_task"];
|
||||
delete?: never;
|
||||
options?: never;
|
||||
head?: never;
|
||||
patch?: never;
|
||||
trace?: never;
|
||||
};
|
||||
"/api/v1/tasks/{id}/runs": {
|
||||
parameters: {
|
||||
query?: never;
|
||||
header?: never;
|
||||
path?: never;
|
||||
cookie?: never;
|
||||
};
|
||||
get: operations["list_task_runs"];
|
||||
put?: never;
|
||||
post?: never;
|
||||
delete?: never;
|
||||
options?: never;
|
||||
head?: never;
|
||||
patch?: never;
|
||||
trace?: never;
|
||||
};
|
||||
}
|
||||
export type webhooks = Record<string, never>;
|
||||
export interface components {
|
||||
schemas: {
|
||||
/** @enum {string} */
|
||||
ConcurrencyPolicy: "skip";
|
||||
CreateTaskRequest: {
|
||||
action: components["schemas"]["TaskAction"];
|
||||
concurrency_policy?: components["schemas"]["ConcurrencyPolicy"];
|
||||
cron: string;
|
||||
enabled?: boolean;
|
||||
id: string;
|
||||
name: string;
|
||||
retry_policy?: components["schemas"]["RetryPolicy"];
|
||||
/** Format: int64 */
|
||||
timeout_secs?: number;
|
||||
timezone?: string;
|
||||
};
|
||||
CronPreviewRequest: {
|
||||
count: number;
|
||||
expression: string;
|
||||
timezone: string;
|
||||
};
|
||||
CronPreviewResponse: {
|
||||
runs: string[];
|
||||
timezone: string;
|
||||
};
|
||||
ErrorResponse: {
|
||||
code: string;
|
||||
details: {
|
||||
[key: string]: string;
|
||||
};
|
||||
field?: string | null;
|
||||
message: string;
|
||||
};
|
||||
HealthResponse: {
|
||||
/** @description Stable service identifier. */
|
||||
service: string;
|
||||
/** @description Current service status. */
|
||||
status: string;
|
||||
/** @description Backend package version. */
|
||||
version: string;
|
||||
};
|
||||
/** @enum {string} */
|
||||
HttpMethod: "GET" | "POST";
|
||||
LogChunkResponse: {
|
||||
complete: boolean;
|
||||
content: string;
|
||||
/** Format: int64 */
|
||||
next_offset: number;
|
||||
};
|
||||
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";
|
||||
SessionResponse: {
|
||||
/** @description Random per-process token required for mutating browser requests. */
|
||||
token: string;
|
||||
};
|
||||
TaskAction: {
|
||||
args?: string[];
|
||||
env?: {
|
||||
[key: string]: string;
|
||||
};
|
||||
interpreter: string;
|
||||
script: string;
|
||||
/** @enum {string} */
|
||||
type: "script";
|
||||
working_dir: string;
|
||||
} | {
|
||||
body?: string | null;
|
||||
expected_status?: number[];
|
||||
headers?: {
|
||||
[key: string]: string;
|
||||
};
|
||||
method: components["schemas"]["HttpMethod"];
|
||||
/** @enum {string} */
|
||||
type: "http";
|
||||
url: string;
|
||||
} | {
|
||||
args?: string[];
|
||||
env?: {
|
||||
[key: string]: string;
|
||||
};
|
||||
executable: string;
|
||||
/** @enum {string} */
|
||||
type: "process";
|
||||
working_dir?: string | null;
|
||||
};
|
||||
/** @enum {string} */
|
||||
TaskActionType: "script" | "http" | "process";
|
||||
TaskListResponse: {
|
||||
items: components["schemas"]["TaskResponse"][];
|
||||
/** Format: int32 */
|
||||
page: number;
|
||||
/** Format: int32 */
|
||||
page_size: number;
|
||||
/** Format: int64 */
|
||||
total: number;
|
||||
};
|
||||
TaskResponse: {
|
||||
action: components["schemas"]["TaskAction"];
|
||||
concurrency_policy: components["schemas"]["ConcurrencyPolicy"];
|
||||
/** Format: date-time */
|
||||
created_at: string;
|
||||
cron: string;
|
||||
enabled: boolean;
|
||||
id: string;
|
||||
name: string;
|
||||
retry_policy: components["schemas"]["RetryPolicy"];
|
||||
/** Format: int64 */
|
||||
timeout_secs: number;
|
||||
timezone: string;
|
||||
/** Format: date-time */
|
||||
updated_at: string;
|
||||
};
|
||||
TaskRunListResponse: {
|
||||
items: components["schemas"]["TaskRunResponse"][];
|
||||
/** Format: int32 */
|
||||
page: number;
|
||||
/** Format: int32 */
|
||||
page_size: number;
|
||||
/** Format: int64 */
|
||||
total: number;
|
||||
};
|
||||
TaskRunResponse: {
|
||||
/** Format: date-time */
|
||||
created_at: string;
|
||||
/** Format: int64 */
|
||||
duration_ms?: number | null;
|
||||
error_message?: string | null;
|
||||
/** Format: int32 */
|
||||
exit_code?: number | null;
|
||||
/** Format: date-time */
|
||||
finished_at?: string | null;
|
||||
/** Format: int64 */
|
||||
id: number;
|
||||
/** Format: date-time */
|
||||
started_at?: string | null;
|
||||
status: components["schemas"]["RunStatus"];
|
||||
task_id: string;
|
||||
trigger: components["schemas"]["RunTrigger"];
|
||||
};
|
||||
UpdateTaskRequest: {
|
||||
action: components["schemas"]["TaskAction"];
|
||||
concurrency_policy?: components["schemas"]["ConcurrencyPolicy"];
|
||||
cron: string;
|
||||
enabled?: boolean;
|
||||
name: string;
|
||||
retry_policy?: components["schemas"]["RetryPolicy"];
|
||||
/** Format: int64 */
|
||||
timeout_secs?: number;
|
||||
timezone?: string;
|
||||
};
|
||||
};
|
||||
responses: never;
|
||||
parameters: never;
|
||||
requestBodies: never;
|
||||
headers: never;
|
||||
pathItems: never;
|
||||
}
|
||||
export type $defs = Record<string, never>;
|
||||
export interface operations {
|
||||
preview_cron: {
|
||||
parameters: {
|
||||
query?: never;
|
||||
header?: never;
|
||||
path?: never;
|
||||
cookie?: never;
|
||||
};
|
||||
requestBody: {
|
||||
content: {
|
||||
"application/json": components["schemas"]["CronPreviewRequest"];
|
||||
};
|
||||
};
|
||||
responses: {
|
||||
/** @description Upcoming cron occurrences */
|
||||
200: {
|
||||
headers: {
|
||||
[name: string]: unknown;
|
||||
};
|
||||
content: {
|
||||
"application/json": components["schemas"]["CronPreviewResponse"];
|
||||
};
|
||||
};
|
||||
/** @description Invalid cron preview request */
|
||||
400: {
|
||||
headers: {
|
||||
[name: string]: unknown;
|
||||
};
|
||||
content: {
|
||||
"application/json": components["schemas"]["ErrorResponse"];
|
||||
};
|
||||
};
|
||||
};
|
||||
};
|
||||
execution_events: {
|
||||
parameters: {
|
||||
query?: never;
|
||||
header?: never;
|
||||
path?: never;
|
||||
cookie?: never;
|
||||
};
|
||||
requestBody?: never;
|
||||
responses: {
|
||||
/** @description Run status and log events */
|
||||
200: {
|
||||
headers: {
|
||||
[name: string]: unknown;
|
||||
};
|
||||
content: {
|
||||
"text/event-stream": string;
|
||||
};
|
||||
};
|
||||
};
|
||||
};
|
||||
health: {
|
||||
parameters: {
|
||||
query?: never;
|
||||
header?: never;
|
||||
path?: never;
|
||||
cookie?: never;
|
||||
};
|
||||
requestBody?: never;
|
||||
responses: {
|
||||
/** @description TaskPilot API is healthy */
|
||||
200: {
|
||||
headers: {
|
||||
[name: string]: unknown;
|
||||
};
|
||||
content: {
|
||||
"application/json": components["schemas"]["HealthResponse"];
|
||||
};
|
||||
};
|
||||
};
|
||||
};
|
||||
get_run: {
|
||||
parameters: {
|
||||
query?: never;
|
||||
header?: never;
|
||||
path: {
|
||||
/** @description Run identifier */
|
||||
run_id: number;
|
||||
};
|
||||
cookie?: never;
|
||||
};
|
||||
requestBody?: never;
|
||||
responses: {
|
||||
/** @description Task run detail */
|
||||
200: {
|
||||
headers: {
|
||||
[name: string]: unknown;
|
||||
};
|
||||
content: {
|
||||
"application/json": components["schemas"]["TaskRunResponse"];
|
||||
};
|
||||
};
|
||||
/** @description Run not found */
|
||||
404: {
|
||||
headers: {
|
||||
[name: string]: unknown;
|
||||
};
|
||||
content: {
|
||||
"application/json": components["schemas"]["ErrorResponse"];
|
||||
};
|
||||
};
|
||||
};
|
||||
};
|
||||
cancel_run: {
|
||||
parameters: {
|
||||
query?: never;
|
||||
header?: never;
|
||||
path: {
|
||||
/** @description Run identifier */
|
||||
run_id: number;
|
||||
};
|
||||
cookie?: never;
|
||||
};
|
||||
requestBody?: never;
|
||||
responses: {
|
||||
/** @description Cancellation requested */
|
||||
202: {
|
||||
headers: {
|
||||
[name: string]: unknown;
|
||||
};
|
||||
content: {
|
||||
"application/json": components["schemas"]["TaskRunResponse"];
|
||||
};
|
||||
};
|
||||
/** @description Run not found */
|
||||
404: {
|
||||
headers: {
|
||||
[name: string]: unknown;
|
||||
};
|
||||
content: {
|
||||
"application/json": components["schemas"]["ErrorResponse"];
|
||||
};
|
||||
};
|
||||
/** @description Run is not cancellable */
|
||||
409: {
|
||||
headers: {
|
||||
[name: string]: unknown;
|
||||
};
|
||||
content: {
|
||||
"application/json": components["schemas"]["ErrorResponse"];
|
||||
};
|
||||
};
|
||||
};
|
||||
};
|
||||
get_run_logs: {
|
||||
parameters: {
|
||||
query?: {
|
||||
offset?: number;
|
||||
};
|
||||
header?: never;
|
||||
path: {
|
||||
/** @description Run identifier */
|
||||
run_id: number;
|
||||
};
|
||||
cookie?: never;
|
||||
};
|
||||
requestBody?: never;
|
||||
responses: {
|
||||
/** @description Incremental task run log */
|
||||
200: {
|
||||
headers: {
|
||||
[name: string]: unknown;
|
||||
};
|
||||
content: {
|
||||
"application/json": components["schemas"]["LogChunkResponse"];
|
||||
};
|
||||
};
|
||||
/** @description Run not found */
|
||||
404: {
|
||||
headers: {
|
||||
[name: string]: unknown;
|
||||
};
|
||||
content: {
|
||||
"application/json": components["schemas"]["ErrorResponse"];
|
||||
};
|
||||
};
|
||||
};
|
||||
};
|
||||
download_run_logs: {
|
||||
parameters: {
|
||||
query?: never;
|
||||
header?: never;
|
||||
path: {
|
||||
/** @description Run identifier */
|
||||
run_id: number;
|
||||
};
|
||||
cookie?: never;
|
||||
};
|
||||
requestBody?: never;
|
||||
responses: {
|
||||
/** @description Plain text task log */
|
||||
200: {
|
||||
headers: {
|
||||
[name: string]: unknown;
|
||||
};
|
||||
content: {
|
||||
"text/plain": string;
|
||||
};
|
||||
};
|
||||
/** @description Run or log not found */
|
||||
404: {
|
||||
headers: {
|
||||
[name: string]: unknown;
|
||||
};
|
||||
content: {
|
||||
"application/json": components["schemas"]["ErrorResponse"];
|
||||
};
|
||||
};
|
||||
};
|
||||
};
|
||||
session: {
|
||||
parameters: {
|
||||
query?: never;
|
||||
header?: never;
|
||||
path?: never;
|
||||
cookie?: never;
|
||||
};
|
||||
requestBody?: never;
|
||||
responses: {
|
||||
/** @description Local API session token */
|
||||
200: {
|
||||
headers: {
|
||||
[name: string]: unknown;
|
||||
};
|
||||
content: {
|
||||
"application/json": components["schemas"]["SessionResponse"];
|
||||
};
|
||||
};
|
||||
};
|
||||
};
|
||||
list_tasks: {
|
||||
parameters: {
|
||||
query?: {
|
||||
q?: string;
|
||||
enabled?: boolean;
|
||||
action_type?: components["schemas"]["TaskActionType"];
|
||||
page?: number;
|
||||
page_size?: number;
|
||||
};
|
||||
header?: never;
|
||||
path?: never;
|
||||
cookie?: never;
|
||||
};
|
||||
requestBody?: never;
|
||||
responses: {
|
||||
/** @description Paginated task list */
|
||||
200: {
|
||||
headers: {
|
||||
[name: string]: unknown;
|
||||
};
|
||||
content: {
|
||||
"application/json": components["schemas"]["TaskListResponse"];
|
||||
};
|
||||
};
|
||||
};
|
||||
};
|
||||
create_task: {
|
||||
parameters: {
|
||||
query?: never;
|
||||
header?: never;
|
||||
path?: never;
|
||||
cookie?: never;
|
||||
};
|
||||
requestBody: {
|
||||
content: {
|
||||
"application/json": components["schemas"]["CreateTaskRequest"];
|
||||
};
|
||||
};
|
||||
responses: {
|
||||
/** @description Task created */
|
||||
201: {
|
||||
headers: {
|
||||
[name: string]: unknown;
|
||||
};
|
||||
content: {
|
||||
"application/json": components["schemas"]["TaskResponse"];
|
||||
};
|
||||
};
|
||||
/** @description Validation failed */
|
||||
400: {
|
||||
headers: {
|
||||
[name: string]: unknown;
|
||||
};
|
||||
content: {
|
||||
"application/json": components["schemas"]["ErrorResponse"];
|
||||
};
|
||||
};
|
||||
/** @description Task ID already exists */
|
||||
409: {
|
||||
headers: {
|
||||
[name: string]: unknown;
|
||||
};
|
||||
content: {
|
||||
"application/json": components["schemas"]["ErrorResponse"];
|
||||
};
|
||||
};
|
||||
};
|
||||
};
|
||||
get_task: {
|
||||
parameters: {
|
||||
query?: never;
|
||||
header?: never;
|
||||
path: {
|
||||
/** @description Stable task identifier */
|
||||
id: string;
|
||||
};
|
||||
cookie?: never;
|
||||
};
|
||||
requestBody?: never;
|
||||
responses: {
|
||||
/** @description Task detail */
|
||||
200: {
|
||||
headers: {
|
||||
[name: string]: unknown;
|
||||
};
|
||||
content: {
|
||||
"application/json": components["schemas"]["TaskResponse"];
|
||||
};
|
||||
};
|
||||
/** @description Task not found */
|
||||
404: {
|
||||
headers: {
|
||||
[name: string]: unknown;
|
||||
};
|
||||
content: {
|
||||
"application/json": components["schemas"]["ErrorResponse"];
|
||||
};
|
||||
};
|
||||
};
|
||||
};
|
||||
update_task: {
|
||||
parameters: {
|
||||
query?: never;
|
||||
header?: never;
|
||||
path: {
|
||||
/** @description Stable task identifier */
|
||||
id: string;
|
||||
};
|
||||
cookie?: never;
|
||||
};
|
||||
requestBody: {
|
||||
content: {
|
||||
"application/json": components["schemas"]["UpdateTaskRequest"];
|
||||
};
|
||||
};
|
||||
responses: {
|
||||
/** @description Task updated */
|
||||
200: {
|
||||
headers: {
|
||||
[name: string]: unknown;
|
||||
};
|
||||
content: {
|
||||
"application/json": components["schemas"]["TaskResponse"];
|
||||
};
|
||||
};
|
||||
/** @description Validation failed */
|
||||
400: {
|
||||
headers: {
|
||||
[name: string]: unknown;
|
||||
};
|
||||
content: {
|
||||
"application/json": components["schemas"]["ErrorResponse"];
|
||||
};
|
||||
};
|
||||
/** @description Task not found */
|
||||
404: {
|
||||
headers: {
|
||||
[name: string]: unknown;
|
||||
};
|
||||
content: {
|
||||
"application/json": components["schemas"]["ErrorResponse"];
|
||||
};
|
||||
};
|
||||
};
|
||||
};
|
||||
delete_task: {
|
||||
parameters: {
|
||||
query?: never;
|
||||
header?: never;
|
||||
path: {
|
||||
/** @description Stable task identifier */
|
||||
id: string;
|
||||
};
|
||||
cookie?: never;
|
||||
};
|
||||
requestBody?: never;
|
||||
responses: {
|
||||
/** @description Task deleted */
|
||||
204: {
|
||||
headers: {
|
||||
[name: string]: unknown;
|
||||
};
|
||||
content?: never;
|
||||
};
|
||||
/** @description Task not found */
|
||||
404: {
|
||||
headers: {
|
||||
[name: string]: unknown;
|
||||
};
|
||||
content: {
|
||||
"application/json": components["schemas"]["ErrorResponse"];
|
||||
};
|
||||
};
|
||||
};
|
||||
};
|
||||
disable_task: {
|
||||
parameters: {
|
||||
query?: never;
|
||||
header?: never;
|
||||
path: {
|
||||
/** @description Stable task identifier */
|
||||
id: string;
|
||||
};
|
||||
cookie?: never;
|
||||
};
|
||||
requestBody?: never;
|
||||
responses: {
|
||||
/** @description Task disabled */
|
||||
200: {
|
||||
headers: {
|
||||
[name: string]: unknown;
|
||||
};
|
||||
content: {
|
||||
"application/json": components["schemas"]["TaskResponse"];
|
||||
};
|
||||
};
|
||||
/** @description Task not found */
|
||||
404: {
|
||||
headers: {
|
||||
[name: string]: unknown;
|
||||
};
|
||||
content: {
|
||||
"application/json": components["schemas"]["ErrorResponse"];
|
||||
};
|
||||
};
|
||||
};
|
||||
};
|
||||
enable_task: {
|
||||
parameters: {
|
||||
query?: never;
|
||||
header?: never;
|
||||
path: {
|
||||
/** @description Stable task identifier */
|
||||
id: string;
|
||||
};
|
||||
cookie?: never;
|
||||
};
|
||||
requestBody?: never;
|
||||
responses: {
|
||||
/** @description Task enabled */
|
||||
200: {
|
||||
headers: {
|
||||
[name: string]: unknown;
|
||||
};
|
||||
content: {
|
||||
"application/json": components["schemas"]["TaskResponse"];
|
||||
};
|
||||
};
|
||||
/** @description Task not found */
|
||||
404: {
|
||||
headers: {
|
||||
[name: string]: unknown;
|
||||
};
|
||||
content: {
|
||||
"application/json": components["schemas"]["ErrorResponse"];
|
||||
};
|
||||
};
|
||||
};
|
||||
};
|
||||
run_task: {
|
||||
parameters: {
|
||||
query?: never;
|
||||
header?: never;
|
||||
path: {
|
||||
/** @description Stable task identifier */
|
||||
id: string;
|
||||
};
|
||||
cookie?: never;
|
||||
};
|
||||
requestBody?: never;
|
||||
responses: {
|
||||
/** @description Manual run accepted */
|
||||
202: {
|
||||
headers: {
|
||||
[name: string]: unknown;
|
||||
};
|
||||
content: {
|
||||
"application/json": components["schemas"]["TaskRunResponse"];
|
||||
};
|
||||
};
|
||||
/** @description Task not found */
|
||||
404: {
|
||||
headers: {
|
||||
[name: string]: unknown;
|
||||
};
|
||||
content: {
|
||||
"application/json": components["schemas"]["ErrorResponse"];
|
||||
};
|
||||
};
|
||||
/** @description Action executor not implemented */
|
||||
422: {
|
||||
headers: {
|
||||
[name: string]: unknown;
|
||||
};
|
||||
content: {
|
||||
"application/json": components["schemas"]["ErrorResponse"];
|
||||
};
|
||||
};
|
||||
};
|
||||
};
|
||||
list_task_runs: {
|
||||
parameters: {
|
||||
query?: {
|
||||
page?: number;
|
||||
page_size?: number;
|
||||
status?: components["schemas"]["RunStatus"];
|
||||
trigger?: components["schemas"]["RunTrigger"];
|
||||
};
|
||||
header?: never;
|
||||
path: {
|
||||
/** @description Stable task identifier */
|
||||
id: string;
|
||||
};
|
||||
cookie?: never;
|
||||
};
|
||||
requestBody?: never;
|
||||
responses: {
|
||||
/** @description Task run history */
|
||||
200: {
|
||||
headers: {
|
||||
[name: string]: unknown;
|
||||
};
|
||||
content: {
|
||||
"application/json": components["schemas"]["TaskRunListResponse"];
|
||||
};
|
||||
};
|
||||
};
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,83 @@
|
||||
import { useEffect, useRef } from 'react'
|
||||
import { useFocusTrap } from '../lib/useFocusTrap'
|
||||
|
||||
interface ConfirmDialogProps {
|
||||
open: boolean
|
||||
title: string
|
||||
description: string
|
||||
busy?: boolean
|
||||
onConfirm: () => void
|
||||
onCancel: () => void
|
||||
}
|
||||
|
||||
export function ConfirmDialog({
|
||||
open,
|
||||
title,
|
||||
description,
|
||||
busy = false,
|
||||
onConfirm,
|
||||
onCancel,
|
||||
}: ConfirmDialogProps) {
|
||||
const dialogRef = useRef<HTMLElement>(null)
|
||||
useFocusTrap(open, dialogRef)
|
||||
useEffect(() => {
|
||||
if (!open) {
|
||||
return
|
||||
}
|
||||
|
||||
const onKeyDown = (event: KeyboardEvent) => {
|
||||
if (event.key === 'Escape' && !busy) {
|
||||
onCancel()
|
||||
}
|
||||
}
|
||||
window.addEventListener('keydown', onKeyDown)
|
||||
return () => window.removeEventListener('keydown', onKeyDown)
|
||||
}, [busy, onCancel, open])
|
||||
|
||||
if (!open) {
|
||||
return null
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
className="modal-overlay"
|
||||
onMouseDown={(event) => {
|
||||
if (event.currentTarget === event.target && !busy) {
|
||||
onCancel()
|
||||
}
|
||||
}}
|
||||
>
|
||||
<section
|
||||
ref={dialogRef}
|
||||
className="confirm-card"
|
||||
tabIndex={-1}
|
||||
role="alertdialog"
|
||||
aria-modal="true"
|
||||
aria-labelledby="confirm-title"
|
||||
aria-describedby="confirm-description"
|
||||
>
|
||||
<p className="eyebrow">CONFIRM ACTION</p>
|
||||
<h2 id="confirm-title">{title}</h2>
|
||||
<p className="muted-copy">{description}</p>
|
||||
<div className="modal-actions">
|
||||
<button
|
||||
type="button"
|
||||
className="button button-secondary"
|
||||
onClick={onCancel}
|
||||
disabled={busy}
|
||||
>
|
||||
取消
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className="button button-danger"
|
||||
onClick={onConfirm}
|
||||
disabled={busy}
|
||||
>
|
||||
{busy ? '删除中...' : '确认删除'}
|
||||
</button>
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,501 @@
|
||||
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
|
||||
import { useEffect, useRef, useState } from 'react'
|
||||
import {
|
||||
cancelRun,
|
||||
getRun,
|
||||
getRunLogs,
|
||||
listTaskRuns,
|
||||
runLogDownloadUrl,
|
||||
type ExecutionEvent,
|
||||
type RunStatus,
|
||||
type RunTrigger,
|
||||
type Task,
|
||||
type TaskRun,
|
||||
} from '../api/client'
|
||||
import {
|
||||
formatDateTime,
|
||||
formatDuration,
|
||||
isTerminalRunStatus,
|
||||
} from '../lib/format'
|
||||
import { useFocusTrap } from '../lib/useFocusTrap'
|
||||
import { RunStatusBadge } from './StatusBadge'
|
||||
|
||||
interface RunLogDrawerProps {
|
||||
open: boolean
|
||||
task: Task | null
|
||||
initialRunId?: number | null
|
||||
onClose: () => void
|
||||
}
|
||||
|
||||
export function RunLogDrawer({
|
||||
open,
|
||||
task,
|
||||
initialRunId,
|
||||
onClose,
|
||||
}: RunLogDrawerProps) {
|
||||
const queryClient = useQueryClient()
|
||||
const [selectedRunId, setSelectedRunId] = useState<number | null>(
|
||||
initialRunId ?? null,
|
||||
)
|
||||
const [historyStatus, setHistoryStatus] = useState<RunStatus | ''>('')
|
||||
const [historyTrigger, setHistoryTrigger] = useState<RunTrigger | ''>('')
|
||||
const [sseConnected, setSseConnected] = useState(false)
|
||||
const [logEvents] = useState(() => new EventTarget())
|
||||
const drawerRef = useRef<HTMLElement>(null)
|
||||
useFocusTrap(open, drawerRef)
|
||||
const runsQuery = useQuery({
|
||||
queryKey: [
|
||||
'task-runs',
|
||||
task?.id,
|
||||
historyStatus,
|
||||
historyTrigger,
|
||||
],
|
||||
queryFn: ({ signal }) =>
|
||||
listTaskRuns(
|
||||
task!.id,
|
||||
{
|
||||
page: 1,
|
||||
page_size: 50,
|
||||
status: historyStatus || undefined,
|
||||
trigger: historyTrigger || undefined,
|
||||
},
|
||||
signal,
|
||||
),
|
||||
enabled: open && Boolean(task),
|
||||
refetchInterval: (query) =>
|
||||
query.state.data?.items.some((run) => !isTerminalRunStatus(run.status))
|
||||
? 1_000
|
||||
: 5_000,
|
||||
})
|
||||
const runs = runsQuery.data?.items ?? []
|
||||
const effectiveRunId = selectedRunId ?? runs[0]?.id ?? null
|
||||
const selectedRunQuery = useQuery({
|
||||
queryKey: ['run', effectiveRunId],
|
||||
queryFn: ({ signal }) => getRun(effectiveRunId!, signal),
|
||||
enabled: effectiveRunId !== null,
|
||||
refetchInterval: (query) =>
|
||||
query.state.data && !isTerminalRunStatus(query.state.data.status)
|
||||
? 800
|
||||
: false,
|
||||
})
|
||||
const selectedRun =
|
||||
selectedRunQuery.data ?? runs.find((run) => run.id === effectiveRunId) ?? null
|
||||
const cancelMutation = useMutation({
|
||||
mutationFn: (runId: number) => cancelRun(runId),
|
||||
onSuccess: (run) => {
|
||||
queryClient.setQueryData(['run', run.id], run)
|
||||
void queryClient.invalidateQueries({
|
||||
queryKey: ['task-runs', run.task_id],
|
||||
})
|
||||
},
|
||||
})
|
||||
|
||||
useEffect(() => {
|
||||
if (!open || !task) {
|
||||
return
|
||||
}
|
||||
const source = new EventSource('/api/v1/events')
|
||||
source.onopen = () => setSseConnected(true)
|
||||
source.onerror = () => setSseConnected(false)
|
||||
const onRun = (event: MessageEvent<string>) => {
|
||||
try {
|
||||
const payload = JSON.parse(event.data) as ExecutionEvent
|
||||
if (payload.event !== 'run' || payload.data.run.task_id !== task.id) {
|
||||
return
|
||||
}
|
||||
queryClient.setQueryData(['run', payload.data.run.id], payload.data.run)
|
||||
void queryClient.invalidateQueries({
|
||||
queryKey: ['task-runs', task.id],
|
||||
})
|
||||
} catch {
|
||||
setSseConnected(false)
|
||||
}
|
||||
}
|
||||
const onLog = (event: MessageEvent<string>) => {
|
||||
try {
|
||||
const payload = JSON.parse(event.data) as ExecutionEvent
|
||||
if (payload.event === 'log') {
|
||||
logEvents.dispatchEvent(
|
||||
new CustomEvent('log', { detail: payload }),
|
||||
)
|
||||
}
|
||||
} catch {
|
||||
setSseConnected(false)
|
||||
}
|
||||
}
|
||||
const onLagged = () => {
|
||||
void queryClient.invalidateQueries({
|
||||
queryKey: ['task-runs', task.id],
|
||||
})
|
||||
}
|
||||
source.addEventListener('run', onRun as EventListener)
|
||||
source.addEventListener('log', onLog as EventListener)
|
||||
source.addEventListener('lagged', onLagged)
|
||||
return () => {
|
||||
source.removeEventListener('run', onRun as EventListener)
|
||||
source.removeEventListener('log', onLog as EventListener)
|
||||
source.removeEventListener('lagged', onLagged)
|
||||
source.close()
|
||||
setSseConnected(false)
|
||||
}
|
||||
}, [logEvents, open, queryClient, task])
|
||||
|
||||
useEffect(() => {
|
||||
if (!open) {
|
||||
return
|
||||
}
|
||||
const onKeyDown = (event: KeyboardEvent) => {
|
||||
if (event.key === 'Escape') {
|
||||
onClose()
|
||||
}
|
||||
}
|
||||
window.addEventListener('keydown', onKeyDown)
|
||||
return () => window.removeEventListener('keydown', onKeyDown)
|
||||
}, [onClose, open])
|
||||
|
||||
if (!open || !task) {
|
||||
return null
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
className="modal-overlay drawer-overlay"
|
||||
onMouseDown={(event) => {
|
||||
if (event.currentTarget === event.target) {
|
||||
onClose()
|
||||
}
|
||||
}}
|
||||
>
|
||||
<aside
|
||||
ref={drawerRef}
|
||||
className="run-drawer"
|
||||
tabIndex={-1}
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
aria-labelledby="run-drawer-title"
|
||||
>
|
||||
<header className="modal-header">
|
||||
<div>
|
||||
<p className="eyebrow">RUN HISTORY</p>
|
||||
<h2 id="run-drawer-title">{task.name}</h2>
|
||||
<p className="drawer-subtitle mono">{task.id}</p>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
className="icon-button"
|
||||
onClick={onClose}
|
||||
aria-label="关闭运行日志"
|
||||
>
|
||||
×
|
||||
</button>
|
||||
</header>
|
||||
|
||||
<div className="run-drawer-body">
|
||||
<section className="run-history-panel">
|
||||
<div className="section-heading">
|
||||
<div>
|
||||
<p className="section-label">RUNS</p>
|
||||
<h3>最近运行</h3>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
className="button button-secondary button-small"
|
||||
onClick={() => void runsQuery.refetch()}
|
||||
disabled={runsQuery.isFetching}
|
||||
>
|
||||
刷新
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="history-filters">
|
||||
<select
|
||||
className="field-input compact-select"
|
||||
aria-label="运行状态筛选"
|
||||
value={historyStatus}
|
||||
onChange={(event) => {
|
||||
setHistoryStatus(event.target.value as RunStatus | '')
|
||||
setSelectedRunId(null)
|
||||
}}
|
||||
>
|
||||
<option value="">全部状态</option>
|
||||
<option value="queued">排队中</option>
|
||||
<option value="running">运行中</option>
|
||||
<option value="succeeded">成功</option>
|
||||
<option value="failed">失败</option>
|
||||
<option value="timed_out">已超时</option>
|
||||
<option value="cancelled">已取消</option>
|
||||
<option value="skipped">已跳过</option>
|
||||
</select>
|
||||
<select
|
||||
className="field-input compact-select"
|
||||
aria-label="触发方式筛选"
|
||||
value={historyTrigger}
|
||||
onChange={(event) => {
|
||||
setHistoryTrigger(event.target.value as RunTrigger | '')
|
||||
setSelectedRunId(null)
|
||||
}}
|
||||
>
|
||||
<option value="">全部触发</option>
|
||||
<option value="schedule">定时</option>
|
||||
<option value="manual">手动</option>
|
||||
<option value="retry">重试</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
{runsQuery.isPending ? (
|
||||
<p className="muted-copy">正在加载运行记录...</p>
|
||||
) : runsQuery.isError ? (
|
||||
<div className="inline-alert inline-alert-error">
|
||||
{runsQuery.error.message}
|
||||
</div>
|
||||
) : runs.length === 0 ? (
|
||||
<p className="empty-inline">该任务还没有运行记录。</p>
|
||||
) : (
|
||||
<div className="run-list">
|
||||
{runs.map((run) => (
|
||||
<button
|
||||
type="button"
|
||||
className={`run-list-item ${
|
||||
run.id === effectiveRunId ? 'is-selected' : ''
|
||||
}`}
|
||||
key={run.id}
|
||||
onClick={() => setSelectedRunId(run.id)}
|
||||
>
|
||||
<span className="run-list-main">
|
||||
<span className="mono">#{run.id}</span>
|
||||
<RunStatusBadge status={run.status} />
|
||||
</span>
|
||||
<span className="run-list-meta">
|
||||
{formatDateTime(run.created_at)} · {run.trigger}
|
||||
</span>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</section>
|
||||
|
||||
<section className="run-detail-panel">
|
||||
<div className="section-heading">
|
||||
<div>
|
||||
<p className="section-label">DETAIL</p>
|
||||
<h3>
|
||||
{effectiveRunId ? `运行 #${effectiveRunId}` : '运行详情'}
|
||||
</h3>
|
||||
</div>
|
||||
{selectedRun ? (
|
||||
<div className="run-detail-actions">
|
||||
<RunStatusBadge status={selectedRun.status} />
|
||||
{!isTerminalRunStatus(selectedRun.status) ? (
|
||||
<button
|
||||
type="button"
|
||||
className="button button-danger button-small"
|
||||
disabled={
|
||||
cancelMutation.isPending &&
|
||||
cancelMutation.variables === selectedRun.id
|
||||
}
|
||||
onClick={() => cancelMutation.mutate(selectedRun.id)}
|
||||
>
|
||||
{cancelMutation.isPending ? '取消中...' : '取消运行'}
|
||||
</button>
|
||||
) : null}
|
||||
<a
|
||||
className="button button-secondary button-small"
|
||||
href={runLogDownloadUrl(selectedRun.id)}
|
||||
>
|
||||
下载日志
|
||||
</a>
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
{selectedRunQuery.isError ? (
|
||||
<div className="inline-alert inline-alert-error">
|
||||
{selectedRunQuery.error.message}
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{selectedRun ? (
|
||||
<RunMetadata run={selectedRun} />
|
||||
) : (
|
||||
<p className="empty-inline">选择一条运行记录查看日志。</p>
|
||||
)}
|
||||
|
||||
{effectiveRunId ? (
|
||||
<RunLogOutput
|
||||
key={effectiveRunId}
|
||||
runId={effectiveRunId}
|
||||
logEvents={logEvents}
|
||||
sseConnected={sseConnected}
|
||||
runComplete={
|
||||
selectedRun ? isTerminalRunStatus(selectedRun.status) : false
|
||||
}
|
||||
/>
|
||||
) : null}
|
||||
</section>
|
||||
</div>
|
||||
</aside>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function RunMetadata({ run }: { run: TaskRun }) {
|
||||
return (
|
||||
<dl className="run-metadata">
|
||||
<div>
|
||||
<dt>触发方式</dt>
|
||||
<dd>{run.trigger}</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt>开始时间</dt>
|
||||
<dd>{formatDateTime(run.started_at)}</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt>耗时</dt>
|
||||
<dd>{formatDuration(run.duration_ms)}</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt>退出码</dt>
|
||||
<dd>{run.exit_code ?? '--'}</dd>
|
||||
</div>
|
||||
{run.error_message ? (
|
||||
<div className="run-error-metadata">
|
||||
<dt>结果说明</dt>
|
||||
<dd>{run.error_message}</dd>
|
||||
</div>
|
||||
) : null}
|
||||
</dl>
|
||||
)
|
||||
}
|
||||
|
||||
interface RunLogOutputProps {
|
||||
runId: number
|
||||
logEvents: EventTarget
|
||||
sseConnected: boolean
|
||||
runComplete: boolean
|
||||
}
|
||||
|
||||
function RunLogOutput({
|
||||
runId,
|
||||
logEvents,
|
||||
sseConnected,
|
||||
runComplete,
|
||||
}: RunLogOutputProps) {
|
||||
const [content, setContent] = useState('')
|
||||
const [pollComplete, setPollComplete] = useState(runComplete)
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
const [reloadKey, setReloadKey] = useState(0)
|
||||
const [reconcileKey, setReconcileKey] = useState(0)
|
||||
const offsetRef = useRef(0)
|
||||
const outputRef = useRef<HTMLPreElement>(null)
|
||||
const complete = runComplete || pollComplete
|
||||
|
||||
useEffect(() => {
|
||||
let cancelled = false
|
||||
const controller = new AbortController()
|
||||
|
||||
const poll = async () => {
|
||||
while (!cancelled) {
|
||||
try {
|
||||
const startOffset = offsetRef.current
|
||||
const chunk = await getRunLogs(runId, startOffset, controller.signal)
|
||||
if (cancelled) {
|
||||
return
|
||||
}
|
||||
if (offsetRef.current !== startOffset) {
|
||||
continue
|
||||
}
|
||||
if (chunk.content) {
|
||||
setContent((current) => current + chunk.content)
|
||||
}
|
||||
offsetRef.current = chunk.next_offset
|
||||
if (chunk.complete) {
|
||||
setPollComplete(true)
|
||||
return
|
||||
}
|
||||
await new Promise((resolve) => window.setTimeout(resolve, 600))
|
||||
} catch (requestError) {
|
||||
if (!cancelled) {
|
||||
setError(
|
||||
requestError instanceof Error
|
||||
? requestError.message
|
||||
: '读取日志失败',
|
||||
)
|
||||
}
|
||||
return
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
void poll()
|
||||
return () => {
|
||||
cancelled = true
|
||||
controller.abort()
|
||||
}
|
||||
}, [reconcileKey, reloadKey, runId])
|
||||
|
||||
useEffect(() => {
|
||||
const onLog = (event: Event) => {
|
||||
const payload = (event as CustomEvent<ExecutionEvent>).detail
|
||||
if (
|
||||
payload.event !== 'log' ||
|
||||
payload.data.run_id !== runId
|
||||
) {
|
||||
return
|
||||
}
|
||||
if (payload.data.offset === offsetRef.current) {
|
||||
if (payload.data.content) {
|
||||
setContent((current) => current + payload.data.content)
|
||||
}
|
||||
offsetRef.current = payload.data.next_offset
|
||||
} else if (payload.data.offset > offsetRef.current) {
|
||||
setReconcileKey((value) => value + 1)
|
||||
}
|
||||
}
|
||||
logEvents.addEventListener('log', onLog)
|
||||
return () => logEvents.removeEventListener('log', onLog)
|
||||
}, [logEvents, runId])
|
||||
|
||||
useEffect(() => {
|
||||
if (outputRef.current) {
|
||||
outputRef.current.scrollTop = outputRef.current.scrollHeight
|
||||
}
|
||||
}, [content])
|
||||
|
||||
return (
|
||||
<div className="log-viewer-wrap">
|
||||
<div className="log-viewer-header">
|
||||
<span>运行日志</span>
|
||||
<span className={complete ? 'log-complete' : 'log-streaming'}>
|
||||
{error
|
||||
? '读取失败'
|
||||
: complete
|
||||
? '已完成'
|
||||
: sseConnected
|
||||
? 'SSE 实时连接'
|
||||
: '轮询读取中'}
|
||||
</span>
|
||||
</div>
|
||||
{error ? (
|
||||
<div className="inline-alert inline-alert-error">
|
||||
{error}
|
||||
<button
|
||||
type="button"
|
||||
className="text-button"
|
||||
onClick={() => {
|
||||
setContent('')
|
||||
setPollComplete(false)
|
||||
setError(null)
|
||||
offsetRef.current = 0
|
||||
setReloadKey((value) => value + 1)
|
||||
}}
|
||||
>
|
||||
重试
|
||||
</button>
|
||||
</div>
|
||||
) : null}
|
||||
<pre className="log-viewer" ref={outputRef} aria-live="polite">
|
||||
{content || '等待日志输出...'}
|
||||
</pre>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,30 @@
|
||||
import type { RunStatus } from '../api/client'
|
||||
import { runStatusLabels } from '../lib/format'
|
||||
|
||||
interface RunStatusBadgeProps {
|
||||
status: RunStatus
|
||||
}
|
||||
|
||||
export function RunStatusBadge({ status }: RunStatusBadgeProps) {
|
||||
return (
|
||||
<span className={`status-badge status-${status}`}>
|
||||
<span className="status-dot" aria-hidden="true" />
|
||||
{runStatusLabels[status]}
|
||||
</span>
|
||||
)
|
||||
}
|
||||
|
||||
interface EnabledBadgeProps {
|
||||
enabled: boolean
|
||||
}
|
||||
|
||||
export function EnabledBadge({ enabled }: EnabledBadgeProps) {
|
||||
return (
|
||||
<span
|
||||
className={`status-badge ${enabled ? 'status-enabled' : 'status-disabled'}`}
|
||||
>
|
||||
<span className="status-dot" aria-hidden="true" />
|
||||
{enabled ? '活跃中' : '已暂停'}
|
||||
</span>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,623 @@
|
||||
import { zodResolver } from '@hookform/resolvers/zod'
|
||||
import { useQuery } from '@tanstack/react-query'
|
||||
import { useEffect, useRef, useState, type ReactNode } from 'react'
|
||||
import {
|
||||
type FieldPath,
|
||||
type UseFormRegister,
|
||||
useFieldArray,
|
||||
useForm,
|
||||
} from 'react-hook-form'
|
||||
import { ApiRequestError, previewCron, type Task } from '../api/client'
|
||||
import {
|
||||
createEmptyTaskFormValues,
|
||||
mapServerFieldToFormField,
|
||||
taskFormSchema,
|
||||
taskToFormValues,
|
||||
type TaskFormValues,
|
||||
} from '../lib/taskForm'
|
||||
import { useFocusTrap } from '../lib/useFocusTrap'
|
||||
import { useDebouncedValue } from '../lib/useDebouncedValue'
|
||||
|
||||
interface TaskEditorModalProps {
|
||||
open: boolean
|
||||
task: Task | null
|
||||
saving: boolean
|
||||
onClose: () => void
|
||||
onSubmit: (values: TaskFormValues) => Promise<void>
|
||||
}
|
||||
|
||||
export function TaskEditorModal({
|
||||
open,
|
||||
task,
|
||||
saving,
|
||||
onClose,
|
||||
onSubmit,
|
||||
}: TaskEditorModalProps) {
|
||||
const [submitError, setSubmitError] = useState<string | null>(null)
|
||||
const [cronPreviewCount, setCronPreviewCount] = useState(5)
|
||||
const dialogRef = useRef<HTMLElement>(null)
|
||||
useFocusTrap(open, dialogRef, '[data-dialog-autofocus]')
|
||||
const {
|
||||
register,
|
||||
control,
|
||||
handleSubmit,
|
||||
reset,
|
||||
watch,
|
||||
setError,
|
||||
formState: { errors },
|
||||
} = useForm<TaskFormValues>({
|
||||
resolver: zodResolver(taskFormSchema),
|
||||
defaultValues: createEmptyTaskFormValues(),
|
||||
mode: 'onBlur',
|
||||
})
|
||||
const actionType = watch('actionType')
|
||||
const cronValue = watch('cron')
|
||||
const timezoneValue = watch('timezone')
|
||||
const debouncedCron = useDebouncedValue(cronValue, 300)
|
||||
const debouncedTimezone = useDebouncedValue(timezoneValue, 300)
|
||||
const cronPreview = useQuery({
|
||||
queryKey: [
|
||||
'cron-preview',
|
||||
debouncedCron,
|
||||
debouncedTimezone,
|
||||
cronPreviewCount,
|
||||
],
|
||||
queryFn: ({ signal }) =>
|
||||
previewCron(
|
||||
{
|
||||
expression: debouncedCron,
|
||||
timezone: debouncedTimezone,
|
||||
count: cronPreviewCount,
|
||||
},
|
||||
signal,
|
||||
),
|
||||
enabled:
|
||||
open &&
|
||||
Boolean(debouncedCron.trim()) &&
|
||||
Boolean(debouncedTimezone.trim()),
|
||||
retry: false,
|
||||
})
|
||||
const scriptArgs = useFieldArray({ control, name: 'script.args' })
|
||||
const scriptEnv = useFieldArray({ control, name: 'script.env' })
|
||||
const httpHeaders = useFieldArray({ control, name: 'http.headers' })
|
||||
const processArgs = useFieldArray({ control, name: 'process.args' })
|
||||
const processEnv = useFieldArray({ control, name: 'process.env' })
|
||||
|
||||
useEffect(() => {
|
||||
if (!open) {
|
||||
return
|
||||
}
|
||||
reset(task ? taskToFormValues(task) : createEmptyTaskFormValues())
|
||||
setCronPreviewCount(5)
|
||||
setSubmitError(null)
|
||||
}, [open, reset, task])
|
||||
|
||||
useEffect(() => {
|
||||
if (!open) {
|
||||
return
|
||||
}
|
||||
const previousOverflow = document.body.style.overflow
|
||||
document.body.style.overflow = 'hidden'
|
||||
const onKeyDown = (event: KeyboardEvent) => {
|
||||
if (event.key === 'Escape' && !saving) {
|
||||
onClose()
|
||||
}
|
||||
}
|
||||
window.addEventListener('keydown', onKeyDown)
|
||||
return () => {
|
||||
document.body.style.overflow = previousOverflow
|
||||
window.removeEventListener('keydown', onKeyDown)
|
||||
}
|
||||
}, [onClose, open, saving])
|
||||
|
||||
if (!open) {
|
||||
return null
|
||||
}
|
||||
|
||||
const submit = handleSubmit(async (values) => {
|
||||
setSubmitError(null)
|
||||
try {
|
||||
await onSubmit(values)
|
||||
} catch (error) {
|
||||
if (error instanceof ApiRequestError && error.field) {
|
||||
const formField = mapServerFieldToFormField(error.field, values.actionType)
|
||||
if (formField) {
|
||||
setError(formField as FieldPath<TaskFormValues>, {
|
||||
type: 'server',
|
||||
message: error.message,
|
||||
})
|
||||
return
|
||||
}
|
||||
}
|
||||
setSubmitError(
|
||||
error instanceof Error ? error.message : '保存任务时发生未知错误',
|
||||
)
|
||||
}
|
||||
})
|
||||
|
||||
return (
|
||||
<div
|
||||
className="modal-overlay"
|
||||
onMouseDown={(event) => {
|
||||
if (event.currentTarget === event.target && !saving) {
|
||||
onClose()
|
||||
}
|
||||
}}
|
||||
>
|
||||
<section
|
||||
ref={dialogRef}
|
||||
className="modal-card task-editor"
|
||||
tabIndex={-1}
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
aria-labelledby="task-editor-title"
|
||||
>
|
||||
<header className="modal-header">
|
||||
<div>
|
||||
<h2 id="task-editor-title">
|
||||
{task ? `编辑任务 [${task.id}]` : '新建定时任务'}
|
||||
</h2>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
className="icon-button"
|
||||
onClick={onClose}
|
||||
disabled={saving}
|
||||
aria-label="关闭任务编辑器"
|
||||
>
|
||||
×
|
||||
</button>
|
||||
</header>
|
||||
|
||||
<form className="modal-body" onSubmit={submit} noValidate>
|
||||
{submitError ? (
|
||||
<div className="inline-alert inline-alert-error" role="alert">
|
||||
{submitError}
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
<div className="form-grid">
|
||||
<Field label="任务标识" error={errors.id?.message}>
|
||||
<input
|
||||
{...register('id')}
|
||||
className="field-input mono"
|
||||
placeholder="例如:sync_data"
|
||||
disabled={Boolean(task)}
|
||||
data-dialog-autofocus
|
||||
aria-invalid={Boolean(errors.id)}
|
||||
/>
|
||||
</Field>
|
||||
<Field label="任务名称" error={errors.name?.message}>
|
||||
<input
|
||||
{...register('name')}
|
||||
className="field-input"
|
||||
placeholder="业务可读名称"
|
||||
aria-invalid={Boolean(errors.name)}
|
||||
/>
|
||||
</Field>
|
||||
</div>
|
||||
|
||||
<div className="form-grid">
|
||||
<div className="field">
|
||||
<div className="cron-header-row">
|
||||
<span className="field-label">Cron 表达式</span>
|
||||
<div className="cron-n-selector">
|
||||
<span>预览未来</span>
|
||||
<select
|
||||
value={cronPreviewCount}
|
||||
onChange={(event) =>
|
||||
setCronPreviewCount(Number(event.target.value))
|
||||
}
|
||||
aria-label="Cron 预览次数"
|
||||
>
|
||||
<option value="3">3 次</option>
|
||||
<option value="5">5 次</option>
|
||||
<option value="8">8 次</option>
|
||||
<option value="10">10 次</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
<input
|
||||
{...register('cron')}
|
||||
className="field-input mono"
|
||||
placeholder="0 0 2 * * *"
|
||||
aria-invalid={Boolean(errors.cron)}
|
||||
/>
|
||||
<div className="cron-preview-box" aria-live="polite">
|
||||
{cronPreview.isFetching ? (
|
||||
<span className="cron-preview-message">正在计算...</span>
|
||||
) : cronPreview.isError ? (
|
||||
<span className="cron-preview-error">
|
||||
{cronPreview.error.message}
|
||||
</span>
|
||||
) : cronPreview.data?.runs.length ? (
|
||||
cronPreview.data.runs.map((run, index) => (
|
||||
<div className="cron-run-item" key={run}>
|
||||
<span className="cron-run-index">#{index + 1}</span>
|
||||
<span className="cron-run-time">{run}</span>
|
||||
</div>
|
||||
))
|
||||
) : (
|
||||
<span className="cron-preview-message">
|
||||
等待输入有效 Cron 表达式...
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
{errors.cron?.message ? (
|
||||
<span className="field-error">{errors.cron.message}</span>
|
||||
) : (
|
||||
<span className="field-hint">
|
||||
支持 5/6 位字段及 @daily、@hourly
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<Field label="超时时间(秒)" error={errors.timeout_secs?.message}>
|
||||
<input
|
||||
{...register('timeout_secs', { valueAsNumber: true })}
|
||||
type="number"
|
||||
min="1"
|
||||
max="86400"
|
||||
className="field-input mono"
|
||||
aria-invalid={Boolean(errors.timeout_secs)}
|
||||
/>
|
||||
<span className="field-hint">
|
||||
超时后将被 Windows Job Object 强制终止
|
||||
</span>
|
||||
</Field>
|
||||
</div>
|
||||
|
||||
<div className="form-grid">
|
||||
<Field label="IANA 时区" error={errors.timezone?.message}>
|
||||
<input
|
||||
{...register('timezone')}
|
||||
className="field-input"
|
||||
list="timezone-options"
|
||||
placeholder="Asia/Shanghai"
|
||||
aria-invalid={Boolean(errors.timezone)}
|
||||
/>
|
||||
<datalist id="timezone-options">
|
||||
<option value="Asia/Shanghai" />
|
||||
<option value="Asia/Hong_Kong" />
|
||||
<option value="UTC" />
|
||||
<option value="Europe/London" />
|
||||
<option value="America/New_York" />
|
||||
</datalist>
|
||||
</Field>
|
||||
<Field label="任务类型">
|
||||
<select {...register('actionType')} className="field-input">
|
||||
<option value="script">执行脚本</option>
|
||||
<option value="http">HTTP 请求</option>
|
||||
<option value="process">二进制进程</option>
|
||||
</select>
|
||||
</Field>
|
||||
</div>
|
||||
|
||||
<label className="checkbox-field">
|
||||
<input type="checkbox" {...register('enabled')} />
|
||||
<span>
|
||||
<strong>立即激活此任务调度</strong>
|
||||
<small>保存后立即注册到 Cron 调度器</small>
|
||||
</span>
|
||||
</label>
|
||||
|
||||
{actionType === 'script' ? (
|
||||
<fieldset className="action-panel">
|
||||
<legend>脚本配置</legend>
|
||||
<div className="form-grid">
|
||||
<Field
|
||||
label="工作目录"
|
||||
error={errors.script?.working_dir?.message}
|
||||
>
|
||||
<input
|
||||
{...register('script.working_dir')}
|
||||
className="field-input mono"
|
||||
placeholder="D:\Projects\sync"
|
||||
/>
|
||||
</Field>
|
||||
<Field
|
||||
label="解释器"
|
||||
error={errors.script?.interpreter?.message}
|
||||
>
|
||||
<input
|
||||
{...register('script.interpreter')}
|
||||
className="field-input mono"
|
||||
placeholder="python.exe"
|
||||
/>
|
||||
</Field>
|
||||
</div>
|
||||
<Field
|
||||
label="脚本文件或相对路径"
|
||||
error={errors.script?.script?.message}
|
||||
>
|
||||
<input
|
||||
{...register('script.script')}
|
||||
className="field-input mono"
|
||||
placeholder="main.py 或 scripts\sync.py"
|
||||
/>
|
||||
</Field>
|
||||
|
||||
<div className="collection-header">
|
||||
<div>
|
||||
<strong>运行参数</strong>
|
||||
<small>每行一个参数,不经过 Shell 拼接</small>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
className="button button-secondary button-small"
|
||||
onClick={() => scriptArgs.append({ value: '' })}
|
||||
>
|
||||
+ 添加参数
|
||||
</button>
|
||||
</div>
|
||||
<div className="collection-list">
|
||||
{scriptArgs.fields.map((field, index) => (
|
||||
<div className="argument-row" key={field.id}>
|
||||
<input
|
||||
{...register(`script.args.${index}.value`)}
|
||||
className="field-input mono"
|
||||
placeholder="例如:--mode"
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
className="text-button danger-text"
|
||||
onClick={() => scriptArgs.remove(index)}
|
||||
>
|
||||
删除
|
||||
</button>
|
||||
</div>
|
||||
))}
|
||||
{scriptArgs.fields.length === 0 ? (
|
||||
<p className="empty-inline">暂无运行参数</p>
|
||||
) : null}
|
||||
{errors.script?.args?.message ? (
|
||||
<span className="field-error">
|
||||
{errors.script.args.message}
|
||||
</span>
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
<KeyValueEditor
|
||||
title="环境变量"
|
||||
fields={scriptEnv.fields}
|
||||
registerKey={register}
|
||||
name="script.env"
|
||||
error={errors.script?.env?.message}
|
||||
onAdd={() => scriptEnv.append({ key: '', value: '' })}
|
||||
onRemove={scriptEnv.remove}
|
||||
/>
|
||||
</fieldset>
|
||||
) : null}
|
||||
|
||||
{actionType === 'http' ? (
|
||||
<fieldset className="action-panel">
|
||||
<legend>HTTP 配置</legend>
|
||||
<div className="form-grid">
|
||||
<Field label="请求 URL" error={errors.http?.url?.message}>
|
||||
<input
|
||||
{...register('http.url')}
|
||||
className="field-input mono"
|
||||
placeholder="https://api.example.com/health"
|
||||
/>
|
||||
</Field>
|
||||
<Field label="请求方式">
|
||||
<select {...register('http.method')} className="field-input">
|
||||
<option value="GET">GET</option>
|
||||
<option value="POST">POST</option>
|
||||
</select>
|
||||
</Field>
|
||||
</div>
|
||||
<div className="form-grid">
|
||||
<Field
|
||||
label="期望状态码"
|
||||
hint="使用逗号分隔,例如:200, 204"
|
||||
error={errors.http?.expected_status?.message}
|
||||
>
|
||||
<input
|
||||
{...register('http.expected_status')}
|
||||
className="field-input mono"
|
||||
placeholder="200"
|
||||
/>
|
||||
</Field>
|
||||
<Field label="JSON Body" error={errors.http?.body?.message}>
|
||||
<textarea
|
||||
{...register('http.body')}
|
||||
className="field-input field-textarea mono"
|
||||
rows={4}
|
||||
placeholder='{"trigger":"manual"}'
|
||||
/>
|
||||
</Field>
|
||||
</div>
|
||||
<KeyValueEditor
|
||||
title="请求 Header"
|
||||
fields={httpHeaders.fields}
|
||||
registerKey={register}
|
||||
name="http.headers"
|
||||
error={errors.http?.headers?.message}
|
||||
onAdd={() => httpHeaders.append({ key: '', value: '' })}
|
||||
onRemove={httpHeaders.remove}
|
||||
/>
|
||||
</fieldset>
|
||||
) : null}
|
||||
|
||||
{actionType === 'process' ? (
|
||||
<fieldset className="action-panel">
|
||||
<legend>进程配置</legend>
|
||||
<div className="form-grid">
|
||||
<Field
|
||||
label="可执行程序"
|
||||
error={errors.process?.executable?.message}
|
||||
>
|
||||
<input
|
||||
{...register('process.executable')}
|
||||
className="field-input mono"
|
||||
placeholder="C:\Tools\exporter.exe"
|
||||
/>
|
||||
</Field>
|
||||
<Field label="工作目录">
|
||||
<input
|
||||
{...register('process.working_dir')}
|
||||
className="field-input mono"
|
||||
placeholder="可选"
|
||||
/>
|
||||
</Field>
|
||||
</div>
|
||||
<div className="collection-header">
|
||||
<div>
|
||||
<strong>启动参数</strong>
|
||||
<small>每行一个参数</small>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
className="button button-secondary button-small"
|
||||
onClick={() => processArgs.append({ value: '' })}
|
||||
>
|
||||
+ 添加参数
|
||||
</button>
|
||||
</div>
|
||||
<div className="collection-list">
|
||||
{processArgs.fields.map((field, index) => (
|
||||
<div className="argument-row" key={field.id}>
|
||||
<input
|
||||
{...register(`process.args.${index}.value`)}
|
||||
className="field-input mono"
|
||||
placeholder="例如:--port"
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
className="text-button danger-text"
|
||||
onClick={() => processArgs.remove(index)}
|
||||
>
|
||||
删除
|
||||
</button>
|
||||
</div>
|
||||
))}
|
||||
{processArgs.fields.length === 0 ? (
|
||||
<p className="empty-inline">暂无启动参数</p>
|
||||
) : null}
|
||||
{errors.process?.args?.message ? (
|
||||
<span className="field-error">
|
||||
{errors.process.args.message}
|
||||
</span>
|
||||
) : null}
|
||||
</div>
|
||||
<KeyValueEditor
|
||||
title="环境变量"
|
||||
fields={processEnv.fields}
|
||||
registerKey={register}
|
||||
name="process.env"
|
||||
error={errors.process?.env?.message}
|
||||
onAdd={() => processEnv.append({ key: '', value: '' })}
|
||||
onRemove={processEnv.remove}
|
||||
/>
|
||||
</fieldset>
|
||||
) : null}
|
||||
|
||||
<footer className="form-actions">
|
||||
<button
|
||||
type="button"
|
||||
className="button button-secondary"
|
||||
onClick={onClose}
|
||||
disabled={saving}
|
||||
>
|
||||
取消
|
||||
</button>
|
||||
<button
|
||||
type="submit"
|
||||
className="button button-primary"
|
||||
disabled={saving}
|
||||
>
|
||||
{saving ? '保存中...' : task ? '保存修改' : '保存并热刷新'}
|
||||
</button>
|
||||
</footer>
|
||||
</form>
|
||||
</section>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
interface FieldProps {
|
||||
label: string
|
||||
error?: string
|
||||
hint?: string
|
||||
children: ReactNode
|
||||
}
|
||||
|
||||
function Field({ label, error, hint, children }: FieldProps) {
|
||||
return (
|
||||
<label className="field">
|
||||
<span className="field-label">{label}</span>
|
||||
{children}
|
||||
{error ? <span className="field-error">{error}</span> : null}
|
||||
{!error && hint ? <span className="field-hint">{hint}</span> : null}
|
||||
</label>
|
||||
)
|
||||
}
|
||||
|
||||
interface KeyValueEditorProps {
|
||||
title: string
|
||||
name: 'script.env' | 'http.headers' | 'process.env'
|
||||
fields: Array<{ id: string; key: string; value: string }>
|
||||
registerKey: UseFormRegister<TaskFormValues>
|
||||
onAdd: () => void
|
||||
onRemove: (index: number) => void
|
||||
error?: string
|
||||
}
|
||||
|
||||
function KeyValueEditor({
|
||||
title,
|
||||
name,
|
||||
fields,
|
||||
registerKey,
|
||||
onAdd,
|
||||
onRemove,
|
||||
error,
|
||||
}: KeyValueEditorProps) {
|
||||
return (
|
||||
<div className="key-value-editor">
|
||||
<div className="collection-header">
|
||||
<div>
|
||||
<strong>{title}</strong>
|
||||
<small>名称和值会原样提交给后端</small>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
className="button button-secondary button-small"
|
||||
onClick={onAdd}
|
||||
>
|
||||
+ 添加
|
||||
</button>
|
||||
</div>
|
||||
<div className="collection-list">
|
||||
{fields.map((field, index) => (
|
||||
<div className="key-value-row" key={field.id}>
|
||||
<input
|
||||
{...registerKey(
|
||||
`${name}.${index}.key` as FieldPath<TaskFormValues>,
|
||||
)}
|
||||
className="field-input mono"
|
||||
placeholder="名称"
|
||||
/>
|
||||
<input
|
||||
{...registerKey(
|
||||
`${name}.${index}.value` as FieldPath<TaskFormValues>,
|
||||
)}
|
||||
type="password"
|
||||
autoComplete="off"
|
||||
className="field-input mono"
|
||||
placeholder="值"
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
className="text-button danger-text"
|
||||
onClick={() => onRemove(index)}
|
||||
>
|
||||
删除
|
||||
</button>
|
||||
</div>
|
||||
))}
|
||||
{fields.length === 0 ? <p className="empty-inline">暂无配置</p> : null}
|
||||
{error ? <span className="field-error">{error}</span> : null}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,87 @@
|
||||
:root {
|
||||
--bg-color: #f7f7f8;
|
||||
--card-bg: #ffffff;
|
||||
--text-main: #111827;
|
||||
--text-muted: #8c939d;
|
||||
--text-light: #b5b9c0;
|
||||
--primary: #111827;
|
||||
--primary-hover: #262e3d;
|
||||
--border-color: #f0f1f3;
|
||||
--badge-bg: #f3f4f6;
|
||||
--radius-sm: 8px;
|
||||
--radius-md: 12px;
|
||||
--radius-lg: 18px;
|
||||
--shadow-sm: 0 2px 8px rgba(0, 0, 0, 0.02);
|
||||
--shadow-card: 0 8px 30px rgba(0, 0, 0, 0.03);
|
||||
--shadow-modal: 0 20px 40px rgba(15, 23, 42, 0.08);
|
||||
--background: var(--bg-color);
|
||||
--surface: var(--card-bg);
|
||||
--surface-muted: #fafafa;
|
||||
--text: var(--text-main);
|
||||
--text-secondary: var(--text-muted);
|
||||
--text-tertiary: #a4aab3;
|
||||
--border: #eceef1;
|
||||
--success: #10b981;
|
||||
--success-bg: #eefbf5;
|
||||
--danger: #f43f5e;
|
||||
--danger-bg: #fff1f2;
|
||||
--shadow: var(--shadow-card);
|
||||
--font-sans:
|
||||
'Plus Jakarta Sans', -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto,
|
||||
'PingFang SC', 'Hiragino Sans GB', 'Microsoft YaHei', sans-serif;
|
||||
--font-mono: 'JetBrains Mono', Consolas, 'Courier New', monospace;
|
||||
|
||||
color: var(--text);
|
||||
background: var(--background);
|
||||
font-family: var(--font-sans);
|
||||
font-synthesis: none;
|
||||
text-rendering: optimizeLegibility;
|
||||
-webkit-font-smoothing: antialiased;
|
||||
-moz-osx-font-smoothing: grayscale;
|
||||
}
|
||||
|
||||
* {
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
html {
|
||||
min-width: 320px;
|
||||
background: var(--background);
|
||||
}
|
||||
|
||||
body {
|
||||
margin: 0;
|
||||
min-width: 320px;
|
||||
min-height: 100vh;
|
||||
line-height: 1.5;
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
button,
|
||||
input,
|
||||
select,
|
||||
textarea {
|
||||
font: inherit;
|
||||
}
|
||||
|
||||
button {
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
button:disabled {
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
::selection {
|
||||
background: #111827;
|
||||
color: #fff;
|
||||
}
|
||||
|
||||
:focus-visible {
|
||||
outline: 3px solid rgba(17, 24, 39, 0.25);
|
||||
outline-offset: 3px;
|
||||
}
|
||||
|
||||
#root {
|
||||
min-height: 100vh;
|
||||
}
|
||||
@@ -0,0 +1,59 @@
|
||||
import type { RunStatus, TaskActionType } from '../api/client'
|
||||
|
||||
const dateTimeFormatter = new Intl.DateTimeFormat('zh-CN', {
|
||||
year: 'numeric',
|
||||
month: '2-digit',
|
||||
day: '2-digit',
|
||||
hour: '2-digit',
|
||||
minute: '2-digit',
|
||||
second: '2-digit',
|
||||
})
|
||||
|
||||
export function formatDateTime(value: string | null | undefined): string {
|
||||
if (!value) {
|
||||
return '--'
|
||||
}
|
||||
const date = new Date(value)
|
||||
return Number.isNaN(date.getTime()) ? '--' : dateTimeFormatter.format(date)
|
||||
}
|
||||
|
||||
export function formatDuration(value: number | null | undefined): string {
|
||||
if (value === null || value === undefined) {
|
||||
return '--'
|
||||
}
|
||||
if (value < 1_000) {
|
||||
return `${value} ms`
|
||||
}
|
||||
if (value < 60_000) {
|
||||
return `${(value / 1_000).toFixed(2)} s`
|
||||
}
|
||||
return `${(value / 60_000).toFixed(1)} min`
|
||||
}
|
||||
|
||||
export function actionTypeLabel(type: TaskActionType): string {
|
||||
return {
|
||||
script: '脚本',
|
||||
http: 'HTTP',
|
||||
process: '进程',
|
||||
}[type]
|
||||
}
|
||||
|
||||
export const runStatusLabels: Record<RunStatus, string> = {
|
||||
queued: '排队中',
|
||||
running: '运行中',
|
||||
succeeded: '成功',
|
||||
failed: '失败',
|
||||
timed_out: '已超时',
|
||||
cancelled: '已取消',
|
||||
skipped: '已跳过',
|
||||
}
|
||||
|
||||
export function isTerminalRunStatus(status: RunStatus): boolean {
|
||||
return [
|
||||
'succeeded',
|
||||
'failed',
|
||||
'timed_out',
|
||||
'cancelled',
|
||||
'skipped',
|
||||
].includes(status)
|
||||
}
|
||||
@@ -0,0 +1,59 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import {
|
||||
createEmptyTaskFormValues,
|
||||
mapServerFieldToFormField,
|
||||
valuesToCreateRequest,
|
||||
} from './taskForm'
|
||||
|
||||
describe('task form payload', () => {
|
||||
it('creates an HTTP action instead of forcing script type', () => {
|
||||
const values = createEmptyTaskFormValues()
|
||||
values.id = 'health_check'
|
||||
values.name = 'Health check'
|
||||
values.actionType = 'http'
|
||||
values.http.url = 'https://example.com/health'
|
||||
values.http.method = 'POST'
|
||||
values.http.expected_status = '200, 204'
|
||||
values.http.headers = [{ key: 'X-Test', value: 'yes' }]
|
||||
values.http.body = '{"ping":true}'
|
||||
|
||||
const payload = valuesToCreateRequest(values)
|
||||
|
||||
expect(payload.action.type).toBe('http')
|
||||
expect(payload.action).toMatchObject({
|
||||
type: 'http',
|
||||
url: 'https://example.com/health',
|
||||
method: 'POST',
|
||||
expected_status: [200, 204],
|
||||
headers: { 'X-Test': 'yes' },
|
||||
})
|
||||
})
|
||||
|
||||
it('creates a process action with structured arguments', () => {
|
||||
const values = createEmptyTaskFormValues()
|
||||
values.id = 'worker'
|
||||
values.name = 'Worker'
|
||||
values.actionType = 'process'
|
||||
values.process.executable = 'C:\\Tools\\worker.exe'
|
||||
values.process.args = [{ value: '--port' }, { value: '8080' }]
|
||||
values.process.env = [{ key: 'WORKER_MODE', value: 'local' }]
|
||||
|
||||
const payload = valuesToCreateRequest(values)
|
||||
|
||||
expect(payload.action).toEqual({
|
||||
type: 'process',
|
||||
executable: 'C:\\Tools\\worker.exe',
|
||||
working_dir: null,
|
||||
args: ['--port', '8080'],
|
||||
env: { WORKER_MODE: 'local' },
|
||||
})
|
||||
})
|
||||
|
||||
it('maps backend validation fields to the current form section', () => {
|
||||
expect(mapServerFieldToFormField('action.working_dir', 'script')).toBe(
|
||||
'script.working_dir',
|
||||
)
|
||||
expect(mapServerFieldToFormField('action.url', 'http')).toBe('http.url')
|
||||
expect(mapServerFieldToFormField('cron', 'script')).toBe('cron')
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,333 @@
|
||||
import { z } from 'zod'
|
||||
import type {
|
||||
CreateTaskRequest,
|
||||
Task,
|
||||
TaskAction,
|
||||
TaskActionType,
|
||||
UpdateTaskRequest,
|
||||
} from '../api/client'
|
||||
|
||||
export interface KeyValueFormValue {
|
||||
key: string
|
||||
value: string
|
||||
}
|
||||
|
||||
export interface ArgumentFormValue {
|
||||
value: string
|
||||
}
|
||||
|
||||
export interface TaskFormValues {
|
||||
id: string
|
||||
name: string
|
||||
cron: string
|
||||
timezone: string
|
||||
timeout_secs: number
|
||||
enabled: boolean
|
||||
actionType: TaskActionType
|
||||
script: {
|
||||
working_dir: string
|
||||
interpreter: string
|
||||
script: string
|
||||
args: ArgumentFormValue[]
|
||||
env: KeyValueFormValue[]
|
||||
}
|
||||
http: {
|
||||
url: string
|
||||
method: 'GET' | 'POST'
|
||||
body: string
|
||||
expected_status: string
|
||||
headers: KeyValueFormValue[]
|
||||
}
|
||||
process: {
|
||||
executable: string
|
||||
working_dir: string
|
||||
args: ArgumentFormValue[]
|
||||
env: KeyValueFormValue[]
|
||||
}
|
||||
}
|
||||
|
||||
const keyValueSchema = z.object({
|
||||
key: z.string(),
|
||||
value: z.string(),
|
||||
})
|
||||
|
||||
function hasValidCronShape(expression: string): boolean {
|
||||
const value = expression.trim()
|
||||
if (['@daily', '@hourly'].includes(value)) {
|
||||
return true
|
||||
}
|
||||
return [5, 6].includes(value.split(/\s+/).length)
|
||||
}
|
||||
|
||||
export const taskFormSchema = z
|
||||
.object({
|
||||
id: z
|
||||
.string()
|
||||
.trim()
|
||||
.min(1, '请输入任务标识')
|
||||
.max(64, '任务标识不能超过 64 个字符')
|
||||
.regex(
|
||||
/^[A-Za-z0-9_-]+$/,
|
||||
'只能使用英文字母、数字、下划线和连字符',
|
||||
),
|
||||
name: z
|
||||
.string()
|
||||
.trim()
|
||||
.min(1, '请输入任务名称')
|
||||
.max(120, '任务名称不能超过 120 个字符'),
|
||||
cron: z
|
||||
.string()
|
||||
.trim()
|
||||
.min(1, '请输入 Cron 表达式')
|
||||
.refine(hasValidCronShape, 'Cron 必须是 5 位或 6 位字段'),
|
||||
timezone: z.string().trim().min(1, '请输入 IANA 时区'),
|
||||
timeout_secs: z
|
||||
.number({ error: '请输入有效的超时时间' })
|
||||
.int('超时时间必须是整数')
|
||||
.min(1, '超时时间至少为 1 秒')
|
||||
.max(86_400, '超时时间不能超过 86400 秒'),
|
||||
enabled: z.boolean(),
|
||||
actionType: z.enum(['script', 'http', 'process']),
|
||||
script: z.object({
|
||||
working_dir: z.string(),
|
||||
interpreter: z.string(),
|
||||
script: z.string(),
|
||||
args: z.array(z.object({ value: z.string() })),
|
||||
env: z.array(keyValueSchema),
|
||||
}),
|
||||
http: z.object({
|
||||
url: z.string(),
|
||||
method: z.enum(['GET', 'POST']),
|
||||
body: z.string(),
|
||||
expected_status: z.string(),
|
||||
headers: z.array(keyValueSchema),
|
||||
}),
|
||||
process: z.object({
|
||||
executable: z.string(),
|
||||
working_dir: z.string(),
|
||||
args: z.array(z.object({ value: z.string() })),
|
||||
env: z.array(keyValueSchema),
|
||||
}),
|
||||
})
|
||||
.superRefine((values, context) => {
|
||||
if (values.actionType === 'script') {
|
||||
if (!values.script.working_dir.trim()) {
|
||||
context.addIssue({
|
||||
code: 'custom',
|
||||
path: ['script', 'working_dir'],
|
||||
message: '请输入工作目录',
|
||||
})
|
||||
}
|
||||
if (!values.script.interpreter.trim()) {
|
||||
context.addIssue({
|
||||
code: 'custom',
|
||||
path: ['script', 'interpreter'],
|
||||
message: '请输入解释器或可执行程序',
|
||||
})
|
||||
}
|
||||
if (!values.script.script.trim()) {
|
||||
context.addIssue({
|
||||
code: 'custom',
|
||||
path: ['script', 'script'],
|
||||
message: '请输入脚本文件名或路径',
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
if (values.actionType === 'http') {
|
||||
try {
|
||||
const url = new URL(values.http.url)
|
||||
if (!['http:', 'https:'].includes(url.protocol)) {
|
||||
throw new Error('unsupported protocol')
|
||||
}
|
||||
} catch {
|
||||
context.addIssue({
|
||||
code: 'custom',
|
||||
path: ['http', 'url'],
|
||||
message: '请输入有效的 HTTP 或 HTTPS URL',
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
if (
|
||||
values.actionType === 'process' &&
|
||||
!values.process.executable.trim()
|
||||
) {
|
||||
context.addIssue({
|
||||
code: 'custom',
|
||||
path: ['process', 'executable'],
|
||||
message: '请输入可执行程序路径',
|
||||
})
|
||||
}
|
||||
})
|
||||
|
||||
export function createEmptyTaskFormValues(): TaskFormValues {
|
||||
return {
|
||||
id: '',
|
||||
name: '',
|
||||
cron: '0 0 2 * * *',
|
||||
timezone: 'Asia/Shanghai',
|
||||
timeout_secs: 300,
|
||||
enabled: true,
|
||||
actionType: 'script',
|
||||
script: {
|
||||
working_dir: '',
|
||||
interpreter: 'python.exe',
|
||||
script: 'main.py',
|
||||
args: [],
|
||||
env: [],
|
||||
},
|
||||
http: {
|
||||
url: '',
|
||||
method: 'GET',
|
||||
body: '',
|
||||
expected_status: '200',
|
||||
headers: [],
|
||||
},
|
||||
process: {
|
||||
executable: '',
|
||||
working_dir: '',
|
||||
args: [],
|
||||
env: [],
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
export function taskToFormValues(task: Task): TaskFormValues {
|
||||
const values = createEmptyTaskFormValues()
|
||||
values.id = task.id
|
||||
values.name = task.name
|
||||
values.cron = task.cron
|
||||
values.timezone = task.timezone
|
||||
values.timeout_secs = task.timeout_secs
|
||||
values.enabled = task.enabled
|
||||
values.actionType = task.action.type
|
||||
|
||||
if (task.action.type === 'script') {
|
||||
values.script = {
|
||||
working_dir: task.action.working_dir,
|
||||
interpreter: task.action.interpreter,
|
||||
script: task.action.script,
|
||||
args: (task.action.args ?? []).map((value) => ({ value })),
|
||||
env: Object.entries(task.action.env ?? {}).map(([key, value]) => ({
|
||||
key,
|
||||
value,
|
||||
})),
|
||||
}
|
||||
} else if (task.action.type === 'http') {
|
||||
values.http = {
|
||||
url: task.action.url,
|
||||
method: task.action.method,
|
||||
body: task.action.body ?? '',
|
||||
expected_status: (task.action.expected_status ?? []).join(', '),
|
||||
headers: Object.entries(task.action.headers ?? {}).map(
|
||||
([key, value]) => ({ key, value }),
|
||||
),
|
||||
}
|
||||
} else {
|
||||
values.process = {
|
||||
executable: task.action.executable,
|
||||
working_dir: task.action.working_dir ?? '',
|
||||
args: (task.action.args ?? []).map((value) => ({ value })),
|
||||
env: Object.entries(task.action.env ?? {}).map(([key, value]) => ({
|
||||
key,
|
||||
value,
|
||||
})),
|
||||
}
|
||||
}
|
||||
|
||||
return values
|
||||
}
|
||||
|
||||
export function valuesToAction(values: TaskFormValues): TaskAction {
|
||||
if (values.actionType === 'script') {
|
||||
return {
|
||||
type: 'script',
|
||||
working_dir: values.script.working_dir.trim(),
|
||||
interpreter: values.script.interpreter.trim(),
|
||||
script: values.script.script.trim(),
|
||||
args: cleanList(values.script.args.map((argument) => argument.value)),
|
||||
env: keyValuesToObject(values.script.env),
|
||||
}
|
||||
}
|
||||
|
||||
if (values.actionType === 'http') {
|
||||
const expectedStatus = values.http.expected_status
|
||||
.split(',')
|
||||
.map((value) => Number(value.trim()))
|
||||
.filter((value) => Number.isInteger(value) && value >= 100 && value <= 599)
|
||||
|
||||
return {
|
||||
type: 'http',
|
||||
url: values.http.url.trim(),
|
||||
method: values.http.method,
|
||||
body: values.http.body.trim() || null,
|
||||
expected_status: expectedStatus,
|
||||
headers: keyValuesToObject(values.http.headers),
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
type: 'process',
|
||||
executable: values.process.executable.trim(),
|
||||
working_dir: values.process.working_dir.trim() || null,
|
||||
args: cleanList(values.process.args.map((argument) => argument.value)),
|
||||
env: keyValuesToObject(values.process.env),
|
||||
}
|
||||
}
|
||||
|
||||
export function valuesToCreateRequest(
|
||||
values: TaskFormValues,
|
||||
): CreateTaskRequest {
|
||||
return {
|
||||
id: values.id.trim(),
|
||||
...valuesToUpdateRequest(values),
|
||||
}
|
||||
}
|
||||
|
||||
export function valuesToUpdateRequest(
|
||||
values: TaskFormValues,
|
||||
): UpdateTaskRequest {
|
||||
return {
|
||||
name: values.name.trim(),
|
||||
cron: values.cron.trim(),
|
||||
timezone: values.timezone.trim(),
|
||||
timeout_secs: values.timeout_secs,
|
||||
enabled: values.enabled,
|
||||
concurrency_policy: 'skip',
|
||||
retry_policy: {
|
||||
max_attempts: 0,
|
||||
delay_secs: 0,
|
||||
},
|
||||
action: valuesToAction(values),
|
||||
}
|
||||
}
|
||||
|
||||
export function mapServerFieldToFormField(
|
||||
field: string | null | undefined,
|
||||
actionType: TaskActionType,
|
||||
): string | null {
|
||||
if (!field) {
|
||||
return null
|
||||
}
|
||||
if (!field.startsWith('action.')) {
|
||||
return field
|
||||
}
|
||||
|
||||
const actionField = field.slice('action.'.length)
|
||||
return `${actionType}.${actionField}`
|
||||
}
|
||||
|
||||
function cleanList(values: string[]): string[] {
|
||||
return values.map((value) => value.trim()).filter(Boolean)
|
||||
}
|
||||
|
||||
function keyValuesToObject(
|
||||
values: KeyValueFormValue[],
|
||||
): Record<string, string> {
|
||||
return Object.fromEntries(
|
||||
values
|
||||
.map(({ key, value }) => [key.trim(), value] as const)
|
||||
.filter(([key]) => key),
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
import { useEffect, useState } from 'react'
|
||||
|
||||
export function useDebouncedValue<T>(value: T, delayMs: number): T {
|
||||
const [debounced, setDebounced] = useState(value)
|
||||
|
||||
useEffect(() => {
|
||||
const timer = window.setTimeout(() => setDebounced(value), delayMs)
|
||||
return () => window.clearTimeout(timer)
|
||||
}, [delayMs, value])
|
||||
|
||||
return debounced
|
||||
}
|
||||
@@ -0,0 +1,64 @@
|
||||
import { useEffect, type RefObject } from 'react'
|
||||
|
||||
const FOCUSABLE_SELECTOR = [
|
||||
'a[href]',
|
||||
'button:not([disabled])',
|
||||
'input:not([disabled])',
|
||||
'select:not([disabled])',
|
||||
'textarea:not([disabled])',
|
||||
'[tabindex]:not([tabindex="-1"])',
|
||||
].join(',')
|
||||
|
||||
export function useFocusTrap(
|
||||
open: boolean,
|
||||
containerRef: RefObject<HTMLElement | null>,
|
||||
initialFocusSelector?: string,
|
||||
) {
|
||||
useEffect(() => {
|
||||
if (!open || !containerRef.current) {
|
||||
return
|
||||
}
|
||||
const container = containerRef.current
|
||||
const previousFocus =
|
||||
document.activeElement instanceof HTMLElement
|
||||
? document.activeElement
|
||||
: null
|
||||
const focusFirst = () => {
|
||||
const initial = initialFocusSelector
|
||||
? container.querySelector<HTMLElement>(initialFocusSelector)
|
||||
: null
|
||||
const first =
|
||||
initial ?? container.querySelector<HTMLElement>(FOCUSABLE_SELECTOR)
|
||||
;(first ?? container).focus()
|
||||
}
|
||||
const frame = window.requestAnimationFrame(focusFirst)
|
||||
const onKeyDown = (event: KeyboardEvent) => {
|
||||
if (event.key !== 'Tab') {
|
||||
return
|
||||
}
|
||||
const focusable = Array.from(
|
||||
container.querySelectorAll<HTMLElement>(FOCUSABLE_SELECTOR),
|
||||
).filter((element) => element.offsetParent !== null)
|
||||
if (focusable.length === 0) {
|
||||
event.preventDefault()
|
||||
container.focus()
|
||||
return
|
||||
}
|
||||
const first = focusable[0]
|
||||
const last = focusable[focusable.length - 1]
|
||||
if (event.shiftKey && document.activeElement === first) {
|
||||
event.preventDefault()
|
||||
last.focus()
|
||||
} else if (!event.shiftKey && document.activeElement === last) {
|
||||
event.preventDefault()
|
||||
first.focus()
|
||||
}
|
||||
}
|
||||
window.addEventListener('keydown', onKeyDown)
|
||||
return () => {
|
||||
window.cancelAnimationFrame(frame)
|
||||
window.removeEventListener('keydown', onKeyDown)
|
||||
previousFocus?.focus()
|
||||
}
|
||||
}, [containerRef, initialFocusSelector, open])
|
||||
}
|
||||
@@ -0,0 +1,26 @@
|
||||
import { StrictMode } from 'react'
|
||||
import { createRoot } from 'react-dom/client'
|
||||
import { QueryClient, QueryClientProvider } from '@tanstack/react-query'
|
||||
import { BrowserRouter } from 'react-router-dom'
|
||||
import './index.css'
|
||||
import App from './App.tsx'
|
||||
|
||||
const queryClient = new QueryClient({
|
||||
defaultOptions: {
|
||||
queries: {
|
||||
refetchOnWindowFocus: false,
|
||||
retry: 1,
|
||||
staleTime: 15_000,
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
createRoot(document.getElementById('root')!).render(
|
||||
<StrictMode>
|
||||
<QueryClientProvider client={queryClient}>
|
||||
<BrowserRouter>
|
||||
<App />
|
||||
</BrowserRouter>
|
||||
</QueryClientProvider>
|
||||
</StrictMode>,
|
||||
)
|
||||
@@ -0,0 +1,577 @@
|
||||
import {
|
||||
keepPreviousData,
|
||||
useMutation,
|
||||
useQuery,
|
||||
useQueryClient,
|
||||
} from '@tanstack/react-query'
|
||||
import { useEffect, useState } from 'react'
|
||||
import { useSearchParams } from 'react-router-dom'
|
||||
import {
|
||||
createTask,
|
||||
deleteTask,
|
||||
fetchHealth,
|
||||
listTasks,
|
||||
runTask,
|
||||
setTaskEnabled,
|
||||
updateTask,
|
||||
type Task,
|
||||
type TaskActionType,
|
||||
type TaskFilters,
|
||||
} from '../api/client'
|
||||
import { ConfirmDialog } from '../components/ConfirmDialog'
|
||||
import { RunLogDrawer } from '../components/RunLogDrawer'
|
||||
import {
|
||||
EnabledBadge,
|
||||
} from '../components/StatusBadge'
|
||||
import { TaskEditorModal } from '../components/TaskEditorModal'
|
||||
import { actionTypeLabel } from '../lib/format'
|
||||
import {
|
||||
type TaskFormValues,
|
||||
valuesToCreateRequest,
|
||||
valuesToUpdateRequest,
|
||||
} from '../lib/taskForm'
|
||||
|
||||
const PAGE_SIZE = 20
|
||||
|
||||
export function TaskDashboard() {
|
||||
const queryClient = useQueryClient()
|
||||
const [searchParams, setSearchParams] = useSearchParams()
|
||||
const q = searchParams.get('q') ?? ''
|
||||
const enabledValue = searchParams.get('enabled')
|
||||
const actionTypeValue = searchParams.get('action_type')
|
||||
const page = Math.max(Number(searchParams.get('page') ?? 1) || 1, 1)
|
||||
const [editorOpen, setEditorOpen] = useState(false)
|
||||
const [editingTask, setEditingTask] = useState<Task | null>(null)
|
||||
const [deleteTarget, setDeleteTarget] = useState<Task | null>(null)
|
||||
const [logTask, setLogTask] = useState<Task | null>(null)
|
||||
const [initialRunId, setInitialRunId] = useState<number | null>(null)
|
||||
const [notice, setNotice] = useState<string | null>(null)
|
||||
const [actionError, setActionError] = useState<string | null>(null)
|
||||
|
||||
const enabled: boolean | undefined =
|
||||
enabledValue === 'true'
|
||||
? true
|
||||
: enabledValue === 'false'
|
||||
? false
|
||||
: undefined
|
||||
const actionType = isActionType(actionTypeValue)
|
||||
? actionTypeValue
|
||||
: undefined
|
||||
const filters: TaskFilters = {
|
||||
q: q || undefined,
|
||||
enabled,
|
||||
action_type: actionType,
|
||||
page,
|
||||
page_size: PAGE_SIZE,
|
||||
}
|
||||
|
||||
const healthQuery = useQuery({
|
||||
queryKey: ['health'],
|
||||
queryFn: ({ signal }) => fetchHealth(signal),
|
||||
refetchInterval: 30_000,
|
||||
})
|
||||
const tasksQuery = useQuery({
|
||||
queryKey: ['tasks', 'list', filters],
|
||||
queryFn: ({ signal }) => listTasks(filters, signal),
|
||||
placeholderData: keepPreviousData,
|
||||
})
|
||||
const totalTasksQuery = useQuery({
|
||||
queryKey: ['tasks', 'summary', 'total'],
|
||||
queryFn: ({ signal }) => listTasks({ page: 1, page_size: 1 }, signal),
|
||||
})
|
||||
const enabledTasksQuery = useQuery({
|
||||
queryKey: ['tasks', 'summary', 'enabled'],
|
||||
queryFn: ({ signal }) =>
|
||||
listTasks({ enabled: true, page: 1, page_size: 1 }, signal),
|
||||
})
|
||||
const scriptTasksQuery = useQuery({
|
||||
queryKey: ['tasks', 'summary', 'script'],
|
||||
queryFn: ({ signal }) =>
|
||||
listTasks(
|
||||
{ action_type: 'script', page: 1, page_size: 1 },
|
||||
signal,
|
||||
),
|
||||
})
|
||||
|
||||
const saveMutation = useMutation({
|
||||
mutationFn: ({
|
||||
task,
|
||||
values,
|
||||
}: {
|
||||
task: Task | null
|
||||
values: TaskFormValues
|
||||
}) =>
|
||||
task
|
||||
? updateTask(task.id, valuesToUpdateRequest(values))
|
||||
: createTask(valuesToCreateRequest(values)),
|
||||
onSuccess: (task, variables) => {
|
||||
setEditorOpen(false)
|
||||
setEditingTask(null)
|
||||
showNotice(
|
||||
variables.task ? `任务 ${task.id} 已更新` : `任务 ${task.id} 已创建`,
|
||||
)
|
||||
void invalidateTaskData(queryClient)
|
||||
},
|
||||
})
|
||||
const toggleMutation = useMutation({
|
||||
mutationFn: (task: Task) => setTaskEnabled(task.id, !task.enabled),
|
||||
onSuccess: (task) => {
|
||||
showNotice(task.enabled ? `已启用 ${task.id}` : `已停用 ${task.id}`)
|
||||
void invalidateTaskData(queryClient)
|
||||
},
|
||||
onError: (error) => setActionError(error.message),
|
||||
})
|
||||
const deleteMutation = useMutation({
|
||||
mutationFn: (task: Task) => deleteTask(task.id),
|
||||
onSuccess: (_data, task) => {
|
||||
setDeleteTarget(null)
|
||||
showNotice(`任务 ${task.id} 已删除`)
|
||||
void invalidateTaskData(queryClient)
|
||||
},
|
||||
onError: (error) => setActionError(error.message),
|
||||
})
|
||||
const runMutation = useMutation({
|
||||
mutationFn: (task: Task) => runTask(task.id),
|
||||
onSuccess: (run, task) => {
|
||||
setLogTask(task)
|
||||
setInitialRunId(run.id)
|
||||
setActionError(null)
|
||||
showNotice(
|
||||
run.status === 'skipped'
|
||||
? `任务 ${task.id} 已有运行实例,本次触发已跳过`
|
||||
: `任务 ${task.id} 已提交运行`,
|
||||
)
|
||||
void queryClient.invalidateQueries({
|
||||
queryKey: ['task-runs', task.id],
|
||||
})
|
||||
},
|
||||
onError: (error) => setActionError(error.message),
|
||||
})
|
||||
|
||||
useEffect(() => {
|
||||
if (!notice) {
|
||||
return
|
||||
}
|
||||
const timer = window.setTimeout(() => setNotice(null), 4_000)
|
||||
return () => window.clearTimeout(timer)
|
||||
}, [notice])
|
||||
|
||||
const tasks = tasksQuery.data?.items ?? []
|
||||
const total = tasksQuery.data?.total ?? 0
|
||||
const totalPages = Math.max(1, Math.ceil(total / PAGE_SIZE))
|
||||
|
||||
const updateFilters = (changes: Record<string, string | undefined>) => {
|
||||
const next = new URLSearchParams(searchParams)
|
||||
for (const [key, value] of Object.entries(changes)) {
|
||||
if (value) {
|
||||
next.set(key, value)
|
||||
} else {
|
||||
next.delete(key)
|
||||
}
|
||||
}
|
||||
setSearchParams(next)
|
||||
}
|
||||
|
||||
const openCreate = () => {
|
||||
setEditingTask(null)
|
||||
setEditorOpen(true)
|
||||
}
|
||||
|
||||
const openEdit = (task: Task) => {
|
||||
setEditingTask(task)
|
||||
setEditorOpen(true)
|
||||
}
|
||||
|
||||
const refreshAll = () => {
|
||||
void queryClient.invalidateQueries({ queryKey: ['tasks'] })
|
||||
void queryClient.invalidateQueries({ queryKey: ['health'] })
|
||||
}
|
||||
|
||||
const showNotice = (message: string) => {
|
||||
setNotice(message)
|
||||
}
|
||||
|
||||
return (
|
||||
<main className="app-shell">
|
||||
<header className="dashboard-header">
|
||||
<div className="header-title">
|
||||
<h1>TaskPilot</h1>
|
||||
<p className="subtitle">
|
||||
Rust 本地轻量化任务引擎 · Windows 原生调度中心
|
||||
</p>
|
||||
</div>
|
||||
<div className="header-actions">
|
||||
<button
|
||||
type="button"
|
||||
className="button button-primary"
|
||||
onClick={openCreate}
|
||||
>
|
||||
+ 新建任务
|
||||
</button>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
{notice ? (
|
||||
<div className="toast-message" role="status">
|
||||
{notice}
|
||||
</div>
|
||||
) : null}
|
||||
{actionError ? (
|
||||
<div className="inline-alert inline-alert-error page-alert" role="alert">
|
||||
<span>{actionError}</span>
|
||||
<button
|
||||
type="button"
|
||||
className="text-button"
|
||||
onClick={() => setActionError(null)}
|
||||
>
|
||||
关闭
|
||||
</button>
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
<section className="metrics-grid" aria-label="任务统计">
|
||||
<MetricCard
|
||||
label="已托管任务"
|
||||
value={totalTasksQuery.data?.total}
|
||||
loading={totalTasksQuery.isPending}
|
||||
/>
|
||||
<MetricCard
|
||||
label="正常运行中"
|
||||
value={enabledTasksQuery.data?.total}
|
||||
loading={enabledTasksQuery.isPending}
|
||||
/>
|
||||
<MetricCard
|
||||
label="可用脚本"
|
||||
value={scriptTasksQuery.data?.total}
|
||||
loading={scriptTasksQuery.isPending}
|
||||
/>
|
||||
<article className="metric-card">
|
||||
<span>Rust 引擎</span>
|
||||
<strong className={healthQuery.isError ? 'metric-danger' : ''}>
|
||||
{healthQuery.isPending
|
||||
? '...'
|
||||
: healthQuery.isError
|
||||
? '离线'
|
||||
: '在线'}
|
||||
</strong>
|
||||
</article>
|
||||
</section>
|
||||
|
||||
<section className="card task-card">
|
||||
<div className="toolbar">
|
||||
<form
|
||||
className="search-form"
|
||||
onSubmit={(event) => {
|
||||
event.preventDefault()
|
||||
const value = String(
|
||||
new FormData(event.currentTarget).get('q') ?? '',
|
||||
).trim()
|
||||
updateFilters({ q: value || undefined, page: '1' })
|
||||
}}
|
||||
>
|
||||
<label className="visually-hidden" htmlFor="task-search">
|
||||
搜索任务
|
||||
</label>
|
||||
<input
|
||||
id="task-search"
|
||||
key={q}
|
||||
name="q"
|
||||
className="field-input search-input"
|
||||
defaultValue={q}
|
||||
placeholder="搜索任务名称或 ID"
|
||||
/>
|
||||
<button type="submit" className="button button-secondary">
|
||||
搜索
|
||||
</button>
|
||||
</form>
|
||||
|
||||
<div className="filter-row">
|
||||
<label>
|
||||
<span className="visually-hidden">启用状态</span>
|
||||
<select
|
||||
className="field-input compact-select"
|
||||
value={enabledValue ?? ''}
|
||||
onChange={(event) =>
|
||||
updateFilters({
|
||||
enabled: event.target.value || undefined,
|
||||
page: '1',
|
||||
})
|
||||
}
|
||||
>
|
||||
<option value="">全部状态</option>
|
||||
<option value="true">已启用</option>
|
||||
<option value="false">已停用</option>
|
||||
</select>
|
||||
</label>
|
||||
<label>
|
||||
<span className="visually-hidden">任务类型</span>
|
||||
<select
|
||||
className="field-input compact-select"
|
||||
value={actionTypeValue ?? ''}
|
||||
onChange={(event) =>
|
||||
updateFilters({
|
||||
action_type: event.target.value || undefined,
|
||||
page: '1',
|
||||
})
|
||||
}
|
||||
>
|
||||
<option value="">全部类型</option>
|
||||
<option value="script">脚本</option>
|
||||
<option value="http">HTTP</option>
|
||||
<option value="process">进程</option>
|
||||
</select>
|
||||
</label>
|
||||
{(q || enabledValue || actionTypeValue) && (
|
||||
<button
|
||||
type="button"
|
||||
className="text-button"
|
||||
onClick={() => {
|
||||
setSearchParams({})
|
||||
}}
|
||||
>
|
||||
清除筛选
|
||||
</button>
|
||||
)}
|
||||
<button
|
||||
type="button"
|
||||
className="text-button"
|
||||
onClick={refreshAll}
|
||||
disabled={tasksQuery.isFetching}
|
||||
>
|
||||
{tasksQuery.isFetching ? '同步中...' : '刷新'}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{tasksQuery.isError ? (
|
||||
<div className="state-panel state-error">
|
||||
<h2>任务列表加载失败</h2>
|
||||
<p>{tasksQuery.error.message}</p>
|
||||
<button
|
||||
type="button"
|
||||
className="button button-secondary"
|
||||
onClick={() => void tasksQuery.refetch()}
|
||||
>
|
||||
重试
|
||||
</button>
|
||||
</div>
|
||||
) : tasksQuery.isPending ? (
|
||||
<div className="state-panel">
|
||||
<span className="loading-mark" aria-hidden="true" />
|
||||
<p>正在读取任务...</p>
|
||||
</div>
|
||||
) : tasks.length === 0 ? (
|
||||
<div className="state-panel">
|
||||
<p className="section-label">EMPTY</p>
|
||||
<h2>{q || enabledValue || actionTypeValue ? '没有匹配任务' : '还没有任务'}</h2>
|
||||
<p>
|
||||
{q || enabledValue || actionTypeValue
|
||||
? '请调整搜索条件后重试。'
|
||||
: '创建第一个定时任务,开始使用真实调度能力。'}
|
||||
</p>
|
||||
{!q && !enabledValue && !actionTypeValue ? (
|
||||
<button
|
||||
type="button"
|
||||
className="button button-primary"
|
||||
onClick={openCreate}
|
||||
>
|
||||
新建任务
|
||||
</button>
|
||||
) : null}
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
<div className="table-container">
|
||||
<table className="task-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>名称 / 任务标识</th>
|
||||
<th>调度周期</th>
|
||||
<th>类型</th>
|
||||
<th>状态</th>
|
||||
<th>超时配置</th>
|
||||
<th className="align-right">操作</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{tasks.map((task) => {
|
||||
const isRunning =
|
||||
runMutation.isPending &&
|
||||
runMutation.variables?.id === task.id
|
||||
return (
|
||||
<tr key={task.id}>
|
||||
<td>
|
||||
<div className="task-name">{task.name}</div>
|
||||
<div className="task-id mono">{task.id}</div>
|
||||
</td>
|
||||
<td>
|
||||
<code>{task.cron}</code>
|
||||
<div className="cell-note">{task.timezone}</div>
|
||||
</td>
|
||||
<td>
|
||||
<span className="type-badge">
|
||||
{actionTypeLabel(task.action.type)}
|
||||
</span>
|
||||
<div className="cell-note">
|
||||
超时 {task.timeout_secs}s
|
||||
</div>
|
||||
</td>
|
||||
<td>
|
||||
<EnabledBadge enabled={task.enabled} />
|
||||
</td>
|
||||
<td className="cell-note">超时 {task.timeout_secs}s</td>
|
||||
<td>
|
||||
<div className="row-actions">
|
||||
<button
|
||||
type="button"
|
||||
className="button button-secondary button-small"
|
||||
disabled={isRunning}
|
||||
title="立即运行"
|
||||
onClick={() => runMutation.mutate(task)}
|
||||
>
|
||||
{isRunning ? '提交中' : '运行'}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className="button button-secondary button-small"
|
||||
onClick={() => {
|
||||
setLogTask(task)
|
||||
setInitialRunId(null)
|
||||
}}
|
||||
>
|
||||
日志
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className="button button-secondary button-small"
|
||||
onClick={() => openEdit(task)}
|
||||
>
|
||||
编辑
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className="button button-secondary button-small"
|
||||
disabled={
|
||||
toggleMutation.isPending &&
|
||||
toggleMutation.variables?.id === task.id
|
||||
}
|
||||
onClick={() => toggleMutation.mutate(task)}
|
||||
>
|
||||
{task.enabled ? '停用' : '启用'}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className="button button-ghost button-small danger-text"
|
||||
onClick={() => setDeleteTarget(task)}
|
||||
>
|
||||
删除
|
||||
</button>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
)
|
||||
})}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
<footer className="pagination">
|
||||
<span>
|
||||
共 {total} 个任务,第 {page} / {totalPages} 页
|
||||
</span>
|
||||
<div>
|
||||
<button
|
||||
type="button"
|
||||
className="button button-secondary button-small"
|
||||
disabled={page <= 1}
|
||||
onClick={() =>
|
||||
updateFilters({ page: String(Math.max(1, page - 1)) })
|
||||
}
|
||||
>
|
||||
上一页
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className="button button-secondary button-small"
|
||||
disabled={page >= totalPages}
|
||||
onClick={() =>
|
||||
updateFilters({ page: String(Math.min(totalPages, page + 1)) })
|
||||
}
|
||||
>
|
||||
下一页
|
||||
</button>
|
||||
</div>
|
||||
</footer>
|
||||
</>
|
||||
)}
|
||||
</section>
|
||||
|
||||
<TaskEditorModal
|
||||
open={editorOpen}
|
||||
task={editingTask}
|
||||
saving={saveMutation.isPending}
|
||||
onClose={() => {
|
||||
if (!saveMutation.isPending) {
|
||||
setEditorOpen(false)
|
||||
}
|
||||
}}
|
||||
onSubmit={async (values) => {
|
||||
await saveMutation.mutateAsync({ task: editingTask, values })
|
||||
}}
|
||||
/>
|
||||
|
||||
<RunLogDrawer
|
||||
key={`${logTask?.id ?? 'closed'}-${initialRunId ?? 'latest'}`}
|
||||
open={Boolean(logTask)}
|
||||
task={logTask}
|
||||
initialRunId={initialRunId}
|
||||
onClose={() => {
|
||||
setLogTask(null)
|
||||
setInitialRunId(null)
|
||||
}}
|
||||
/>
|
||||
|
||||
<ConfirmDialog
|
||||
open={Boolean(deleteTarget)}
|
||||
title={`删除任务 ${deleteTarget?.id ?? ''}`}
|
||||
description="任务配置和运行历史将被删除,当前运行也会被取消。此操作不能撤销。"
|
||||
busy={deleteMutation.isPending}
|
||||
onCancel={() => {
|
||||
if (!deleteMutation.isPending) {
|
||||
setDeleteTarget(null)
|
||||
}
|
||||
}}
|
||||
onConfirm={() => {
|
||||
if (deleteTarget) {
|
||||
deleteMutation.mutate(deleteTarget)
|
||||
}
|
||||
}}
|
||||
/>
|
||||
</main>
|
||||
)
|
||||
}
|
||||
|
||||
interface MetricCardProps {
|
||||
label: string
|
||||
value: number | undefined
|
||||
loading: boolean
|
||||
}
|
||||
|
||||
function MetricCard({ label, value, loading }: MetricCardProps) {
|
||||
return (
|
||||
<article className="metric-card">
|
||||
<span>{label}</span>
|
||||
<strong>{loading ? '...' : (value ?? '--')}</strong>
|
||||
</article>
|
||||
)
|
||||
}
|
||||
|
||||
function isActionType(value: string | null): value is TaskActionType {
|
||||
return value === 'script' || value === 'http' || value === 'process'
|
||||
}
|
||||
|
||||
async function invalidateTaskData(queryClient: ReturnType<typeof useQueryClient>) {
|
||||
await Promise.all([
|
||||
queryClient.invalidateQueries({ queryKey: ['tasks'] }),
|
||||
queryClient.invalidateQueries({ queryKey: ['task-runs'] }),
|
||||
])
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
import '@testing-library/jest-dom/vitest'
|
||||
import { cleanup } from '@testing-library/react'
|
||||
import { afterEach, vi } from 'vitest'
|
||||
|
||||
afterEach(() => {
|
||||
cleanup()
|
||||
vi.unstubAllGlobals()
|
||||
})
|
||||
@@ -0,0 +1,26 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"tsBuildInfoFile": "./node_modules/.tmp/tsconfig.app.tsbuildinfo",
|
||||
"target": "es2023",
|
||||
"lib": ["ES2023", "DOM"],
|
||||
"module": "esnext",
|
||||
"types": ["vite/client"],
|
||||
"allowArbitraryExtensions": true,
|
||||
"skipLibCheck": true,
|
||||
|
||||
/* Bundler mode */
|
||||
"moduleResolution": "bundler",
|
||||
"allowImportingTsExtensions": true,
|
||||
"verbatimModuleSyntax": true,
|
||||
"moduleDetection": "force",
|
||||
"noEmit": true,
|
||||
"jsx": "react-jsx",
|
||||
|
||||
/* Linting */
|
||||
"noUnusedLocals": true,
|
||||
"noUnusedParameters": true,
|
||||
"erasableSyntaxOnly": true,
|
||||
"noFallthroughCasesInSwitch": true
|
||||
},
|
||||
"include": ["src"]
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
{
|
||||
"files": [],
|
||||
"references": [
|
||||
{ "path": "./tsconfig.app.json" },
|
||||
{ "path": "./tsconfig.node.json" }
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,23 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"tsBuildInfoFile": "./node_modules/.tmp/tsconfig.node.tsbuildinfo",
|
||||
"target": "es2023",
|
||||
"lib": ["ES2023"],
|
||||
"types": ["node"],
|
||||
"skipLibCheck": true,
|
||||
|
||||
/* Bundler mode */
|
||||
"module": "nodenext",
|
||||
"allowImportingTsExtensions": true,
|
||||
"verbatimModuleSyntax": true,
|
||||
"moduleDetection": "force",
|
||||
"noEmit": true,
|
||||
|
||||
/* Linting */
|
||||
"noUnusedLocals": true,
|
||||
"noUnusedParameters": true,
|
||||
"erasableSyntaxOnly": true,
|
||||
"noFallthroughCasesInSwitch": true
|
||||
},
|
||||
"include": ["vite.config.ts", "vitest.config.ts", "playwright.config.ts"]
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
import react from '@vitejs/plugin-react'
|
||||
import { defineConfig } from 'vite'
|
||||
|
||||
export default defineConfig({
|
||||
plugins: [react()],
|
||||
server: {
|
||||
host: '127.0.0.1',
|
||||
port: 5173,
|
||||
strictPort: true,
|
||||
proxy: {
|
||||
'/api': {
|
||||
target: 'http://127.0.0.1:3000',
|
||||
changeOrigin: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
})
|
||||
@@ -0,0 +1,12 @@
|
||||
import react from '@vitejs/plugin-react'
|
||||
import { defineConfig } from 'vitest/config'
|
||||
|
||||
export default defineConfig({
|
||||
plugins: [react()],
|
||||
test: {
|
||||
environment: 'jsdom',
|
||||
setupFiles: ['./src/test/setup.ts'],
|
||||
restoreMocks: true,
|
||||
exclude: ['e2e/**', '**/node_modules/**', '**/dist/**'],
|
||||
},
|
||||
})
|
||||
@@ -0,0 +1,5 @@
|
||||
[toolchain]
|
||||
channel = "1.95.0"
|
||||
components = ["rustfmt", "clippy"]
|
||||
profile = "minimal"
|
||||
targets = ["x86_64-pc-windows-msvc"]
|
||||
@@ -0,0 +1,42 @@
|
||||
$ErrorActionPreference = 'Stop'
|
||||
|
||||
$projectRoot = Split-Path -Parent $PSScriptRoot
|
||||
$releaseRoot = [System.IO.Path]::GetFullPath((Join-Path $projectRoot 'release\TaskPilot'))
|
||||
$expectedPrefix = [System.IO.Path]::GetFullPath((Join-Path $projectRoot 'release')).TrimEnd('\') + '\'
|
||||
if (-not $releaseRoot.StartsWith($expectedPrefix, [System.StringComparison]::OrdinalIgnoreCase)) {
|
||||
throw "Refusing to build outside release directory: $releaseRoot"
|
||||
}
|
||||
|
||||
function Assert-ExitCode {
|
||||
param([string]$Step)
|
||||
|
||||
if ($LASTEXITCODE -ne 0) {
|
||||
throw "$Step failed with exit code $LASTEXITCODE"
|
||||
}
|
||||
}
|
||||
|
||||
Push-Location $projectRoot
|
||||
try {
|
||||
pnpm --dir frontend build
|
||||
Assert-ExitCode 'Frontend build'
|
||||
cargo build --release --bin task-pilot-backend --manifest-path backend\Cargo.toml
|
||||
Assert-ExitCode 'Rust release build'
|
||||
|
||||
if (Test-Path -LiteralPath $releaseRoot) {
|
||||
Remove-Item -LiteralPath $releaseRoot -Recurse -Force
|
||||
}
|
||||
[void](New-Item -ItemType Directory -Path $releaseRoot -Force)
|
||||
[void](New-Item -ItemType Directory -Path (Join-Path $releaseRoot 'web') -Force)
|
||||
[void](New-Item -ItemType Directory -Path (Join-Path $releaseRoot 'data') -Force)
|
||||
[void](New-Item -ItemType Directory -Path (Join-Path $releaseRoot 'logs') -Force)
|
||||
|
||||
Copy-Item -LiteralPath 'backend\target\release\task-pilot-backend.exe' -Destination $releaseRoot
|
||||
Copy-Item -LiteralPath 'scripts\start.ps1' -Destination $releaseRoot
|
||||
Copy-Item -Path 'frontend\dist\*' -Destination (Join-Path $releaseRoot 'web') -Recurse
|
||||
Copy-Item -LiteralPath 'docs\RELEASE.md' -Destination (Join-Path $releaseRoot 'README.md')
|
||||
|
||||
Write-Host "Release package: $releaseRoot"
|
||||
}
|
||||
finally {
|
||||
Pop-Location
|
||||
}
|
||||
@@ -0,0 +1,34 @@
|
||||
$ErrorActionPreference = 'Stop'
|
||||
|
||||
$projectRoot = Split-Path -Parent $PSScriptRoot
|
||||
|
||||
function Assert-ExitCode {
|
||||
param([string]$Step)
|
||||
|
||||
if ($LASTEXITCODE -ne 0) {
|
||||
throw "$Step failed with exit code $LASTEXITCODE"
|
||||
}
|
||||
}
|
||||
|
||||
Push-Location $projectRoot
|
||||
try {
|
||||
pnpm --dir frontend lint
|
||||
Assert-ExitCode 'Frontend lint'
|
||||
pnpm --dir frontend typecheck
|
||||
Assert-ExitCode 'Frontend typecheck'
|
||||
pnpm --dir frontend test
|
||||
Assert-ExitCode 'Frontend tests'
|
||||
pnpm --dir frontend build
|
||||
Assert-ExitCode 'Frontend build'
|
||||
cargo fmt --manifest-path backend\Cargo.toml -- --check
|
||||
Assert-ExitCode 'Rust formatting'
|
||||
cargo clippy --manifest-path backend\Cargo.toml --all-targets -- -D warnings
|
||||
Assert-ExitCode 'Rust clippy'
|
||||
cargo test --manifest-path backend\Cargo.toml
|
||||
Assert-ExitCode 'Rust tests'
|
||||
powershell -NoProfile -ExecutionPolicy Bypass -File (Join-Path $PSScriptRoot 'e2e.ps1')
|
||||
Assert-ExitCode 'Playwright end-to-end tests'
|
||||
}
|
||||
finally {
|
||||
Pop-Location
|
||||
}
|
||||
@@ -0,0 +1,91 @@
|
||||
$ErrorActionPreference = 'Stop'
|
||||
|
||||
$projectRoot = Split-Path -Parent $PSScriptRoot
|
||||
$backendJob = $null
|
||||
$viteJob = $null
|
||||
|
||||
function Assert-ExitCode {
|
||||
param([string]$Step)
|
||||
|
||||
if ($LASTEXITCODE -ne 0) {
|
||||
throw "$Step failed with exit code $LASTEXITCODE"
|
||||
}
|
||||
}
|
||||
|
||||
function Wait-HttpReady {
|
||||
param([string]$Url)
|
||||
|
||||
for ($attempt = 0; $attempt -lt 60; $attempt++) {
|
||||
try {
|
||||
[void](Invoke-WebRequest -Uri $Url -UseBasicParsing -TimeoutSec 2)
|
||||
return
|
||||
}
|
||||
catch {
|
||||
Start-Sleep -Milliseconds 200
|
||||
}
|
||||
}
|
||||
throw "Service did not become ready: $Url"
|
||||
}
|
||||
|
||||
Push-Location $projectRoot
|
||||
try {
|
||||
foreach ($port in 3000, 5173) {
|
||||
if (Get-NetTCPConnection -LocalPort $port -State Listen -ErrorAction SilentlyContinue) {
|
||||
throw "Port $port is already in use"
|
||||
}
|
||||
}
|
||||
|
||||
cargo build --manifest-path backend\Cargo.toml
|
||||
Assert-ExitCode 'Rust build'
|
||||
|
||||
$e2eData = [System.IO.Path]::GetFullPath((Join-Path $projectRoot '.e2e-data'))
|
||||
$expectedE2eData = [System.IO.Path]::GetFullPath($projectRoot).TrimEnd('\') + '\.e2e-data'
|
||||
if ($e2eData -ne $expectedE2eData) {
|
||||
throw "Refusing to clean unexpected end-to-end data path: $e2eData"
|
||||
}
|
||||
if (Test-Path -LiteralPath $e2eData) {
|
||||
Remove-Item -LiteralPath $e2eData -Recurse -Force
|
||||
}
|
||||
[void](New-Item -ItemType Directory -Path $e2eData -Force)
|
||||
$env:TASK_PILOT_DATABASE_URL = 'sqlite://.e2e-data/task_pilot.db'
|
||||
$env:TASK_PILOT_LOG_DIR = '.e2e-data/logs'
|
||||
$env:TASKPILOT_E2E_EXTERNAL = '1'
|
||||
|
||||
$backendBinary = Join-Path $projectRoot 'backend\target\debug\task-pilot-backend.exe'
|
||||
$nodeBinary = (Get-Command node).Source
|
||||
$frontendRoot = Join-Path $projectRoot 'frontend'
|
||||
|
||||
$backendJob = Start-Job -ScriptBlock {
|
||||
param($root, $binary)
|
||||
Set-Location $root
|
||||
& $binary
|
||||
} -ArgumentList $projectRoot, $backendBinary
|
||||
Wait-HttpReady 'http://127.0.0.1:3000/api/v1/health'
|
||||
|
||||
$viteJob = Start-Job -ScriptBlock {
|
||||
param($root, $node)
|
||||
Set-Location $root
|
||||
& $node 'node_modules/vite/bin/vite.js' '--host' '127.0.0.1'
|
||||
} -ArgumentList $frontendRoot, $nodeBinary
|
||||
Wait-HttpReady 'http://127.0.0.1:5173'
|
||||
|
||||
pnpm --dir frontend exec playwright test
|
||||
Assert-ExitCode 'Playwright tests'
|
||||
}
|
||||
finally {
|
||||
foreach ($job in @($viteJob, $backendJob)) {
|
||||
if ($job) {
|
||||
Stop-Job -Job $job -ErrorAction SilentlyContinue
|
||||
Remove-Job -Job $job -Force -ErrorAction SilentlyContinue
|
||||
}
|
||||
}
|
||||
|
||||
foreach ($port in 3000, 5173) {
|
||||
$connection = Get-NetTCPConnection -LocalPort $port -State Listen -ErrorAction SilentlyContinue
|
||||
if ($connection) {
|
||||
Stop-Process -Id $connection.OwningProcess -Force -ErrorAction SilentlyContinue
|
||||
}
|
||||
}
|
||||
|
||||
Pop-Location
|
||||
}
|
||||
@@ -0,0 +1,23 @@
|
||||
$ErrorActionPreference = 'Stop'
|
||||
|
||||
$projectRoot = Split-Path -Parent $PSScriptRoot
|
||||
$contractPath = Join-Path $projectRoot 'contracts\openapi.json'
|
||||
|
||||
function Assert-ExitCode {
|
||||
param([string]$Step)
|
||||
|
||||
if ($LASTEXITCODE -ne 0) {
|
||||
throw "$Step failed with exit code $LASTEXITCODE"
|
||||
}
|
||||
}
|
||||
|
||||
Push-Location $projectRoot
|
||||
try {
|
||||
cargo run --quiet --manifest-path backend\Cargo.toml --bin export-openapi -- $contractPath
|
||||
Assert-ExitCode 'OpenAPI export'
|
||||
pnpm --dir frontend exec openapi-typescript ..\contracts\openapi.json -o src\api\generated.ts
|
||||
Assert-ExitCode 'TypeScript API generation'
|
||||
}
|
||||
finally {
|
||||
Pop-Location
|
||||
}
|
||||
@@ -0,0 +1,40 @@
|
||||
param(
|
||||
[switch]$OpenBrowser
|
||||
)
|
||||
|
||||
$ErrorActionPreference = 'Stop'
|
||||
$scriptRoot = [System.IO.Path]::GetFullPath($PSScriptRoot)
|
||||
$projectRoot = Split-Path -Parent $scriptRoot
|
||||
|
||||
$packagedExecutable = Join-Path $scriptRoot 'task-pilot-backend.exe'
|
||||
if (Test-Path -LiteralPath $packagedExecutable) {
|
||||
$executable = $packagedExecutable
|
||||
$workingDirectory = $scriptRoot
|
||||
$staticDirectory = Join-Path $scriptRoot 'web'
|
||||
}
|
||||
else {
|
||||
$executable = Join-Path $projectRoot 'backend\target\release\task-pilot-backend.exe'
|
||||
$workingDirectory = $projectRoot
|
||||
$staticDirectory = Join-Path $projectRoot 'frontend\dist'
|
||||
if (-not (Test-Path -LiteralPath $executable)) {
|
||||
throw 'Release executable not found. Run .\scripts\build-release.ps1 first.'
|
||||
}
|
||||
}
|
||||
|
||||
$env:TASK_PILOT_STATIC_DIR = $staticDirectory
|
||||
$address = 'http://127.0.0.1:3000'
|
||||
|
||||
Write-Host "TaskPilot: $address"
|
||||
Write-Host "Static assets: $staticDirectory"
|
||||
|
||||
if ($OpenBrowser) {
|
||||
Start-Process $address
|
||||
}
|
||||
|
||||
Push-Location $workingDirectory
|
||||
try {
|
||||
& $executable
|
||||
}
|
||||
finally {
|
||||
Pop-Location
|
||||
}
|
||||
Reference in New Issue
Block a user