Compare commits
4
Commits
e555610037
...
14fb3563c5
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
14fb3563c5 | ||
|
|
942f8de3b7 | ||
|
|
815258c19b | ||
|
|
773196d689 |
@@ -17,7 +17,12 @@
|
||||
/data/
|
||||
/logs/
|
||||
/.e2e-data/
|
||||
/.tmp/
|
||||
/release/
|
||||
/samples/python-task/.venv/
|
||||
|
||||
# 字体二进制体积大(约 6.7 MB),不入库;用 tools/build-fonts.py 重新生成
|
||||
/frontend/public/fonts/LXGWWenKai-*.woff2
|
||||
|
||||
# Editors and operating system files
|
||||
/.idea/
|
||||
|
||||
@@ -1,216 +1,310 @@
|
||||
# TaskPilot
|
||||
|
||||
TaskPilot 是一个面向 Windows 的本地定时任务管理平台。项目目标是用 React 提供任务管理界面,用 Rust 实现 Cron 调度、Windows 进程管理和运行日志。
|
||||
TaskPilot 是一个面向 **Windows 单机** 的本地定时任务管理平台:React 提供任务管理与观测界面,Rust 负责 Cron 调度、进程治理、运行日志与本地 API。
|
||||
|
||||
当前已完成 Phase 1 至 Phase 6:React 任务管理界面、Rust API、SQLite 持久化、Cron 调度、三类任务执行器、实时日志、安全边界、生产静态托管和 Windows 本地打包均已落地。
|
||||
- 单用户、单实例,只监听 `127.0.0.1`
|
||||
- 三类任务:Python/Shell 脚本、HTTP 请求、二进制进程
|
||||
- SQLite 持久化,重启后任务与历史仍在
|
||||
- 运行日志实时推送(SSE),断线自动回退轮询
|
||||
- 前端零外部依赖:样式与脚本随包自带;界面字体可选,未生成时自动回退系统字体
|
||||
|
||||
仓库根目录的 `task_pilot.html` 是最初的单文件视觉原型,目前只作为 React 迁移期间的设计参考,不是正式前端入口。
|
||||
> 根目录的 `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]`。
|
||||
- Python 脚本支持显式虚拟环境配置,并会自动向上探测 `.venv`、`venv`、`env`。
|
||||
- 编辑器会随工作目录实时探测虚拟环境,显示 `已关联虚拟环境: .venv\Scripts\python.exe` 并同步更新调用预览。
|
||||
- 工作目录支持「选择文件夹…」打开页面内的目录浏览器(盘符 → 逐级进入 → 使用此目录),选中后回填输入框,同时保留手动输入。
|
||||
- 执行前会校验 `pyvenv.cfg` 和 `Scripts/python.exe`,优先使用虚拟环境解释器,并注入 PATH、`VIRTUAL_ENV` 和无缓冲输出设置。
|
||||
- 日志按系统 ANSI 代码页(如 GBK)解码非 UTF-8 输出,中文程序不再显示为 `����`,且长日志跨 64 KiB 分块读取不会丢失字符。
|
||||
- 日志支持保留期限和总容量清理,单次日志仍有 5 MiB 上限。
|
||||
- React 页面已接入真实任务列表、搜索筛选、分页、CRUD、启停、手动运行、运行历史和日志轮询。
|
||||
- 三类 Action 使用不同动态表单,并以类型化载荷提交,不再固定为脚本任务。
|
||||
- Playwright 已覆盖运行日志、编辑、停用、删除和窄屏布局主流程。
|
||||
- 后端只监听 `127.0.0.1`,拒绝非本地 Origin。
|
||||
- 浏览器写操作需要运行期随机管理令牌,进程重启后自动更换。
|
||||
- Axum 在生产模式托管 React 静态资源,并支持 SPA 路由回退。
|
||||
- 弹窗和日志抽屉支持初始焦点、Tab 循环、Escape 关闭和焦点恢复。
|
||||
- 提供 Windows Release 打包和一键启动脚本。
|
||||
- 前端与后端均具备基础测试和统一质量检查命令。
|
||||
- [快速开始](#快速开始)
|
||||
- [功能总览](#功能总览)
|
||||
- [任务模型](#任务模型)
|
||||
- [调度与执行语义](#调度与执行语义)
|
||||
- [日志与编码](#日志与编码)
|
||||
- [Python 与虚拟环境](#python-与虚拟环境)
|
||||
- [Web 界面](#web-界面)
|
||||
- [安全模型](#安全模型)
|
||||
- [配置项](#配置项)
|
||||
- [API 参考](#api-参考)
|
||||
- [输入校验](#输入校验)
|
||||
- [数据与文件布局](#数据与文件布局)
|
||||
- [架构与模块](#架构与模块)
|
||||
- [开发与测试](#开发与测试)
|
||||
- [故障排查](#故障排查)
|
||||
- [已知限制](#已知限制)
|
||||
- [架构决策记录](#架构决策记录)
|
||||
|
||||
## 技术栈
|
||||
---
|
||||
|
||||
### 前端
|
||||
## 快速开始
|
||||
|
||||
- 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
|
||||
.\scripts\build-release.ps1 # 构建前端 + Release 可执行文件 + 可分发目录
|
||||
.\release\TaskPilot\start.ps1 # 启动并访问 http://127.0.0.1:3000
|
||||
```
|
||||
|
||||
## 本地开发
|
||||
`start.ps1 -OpenBrowser` 会自动打开浏览器。产物目录结构:
|
||||
|
||||
首次拉取项目后安装前端依赖:
|
||||
```text
|
||||
release/TaskPilot/
|
||||
├── task-pilot-backend.exe # 单可执行文件(内嵌 Web 资源托管)
|
||||
├── web/ # 前端静态资源(字体可选)
|
||||
├── data/task_pilot.db # SQLite 数据库(首次启动创建)
|
||||
├── logs/{task_id}/{run_id}.log
|
||||
├── start.ps1
|
||||
└── README.md # 即 docs/RELEASE.md
|
||||
```
|
||||
|
||||
### 方式二:本地开发
|
||||
|
||||
```powershell
|
||||
pnpm --dir frontend install
|
||||
cargo run --manifest-path backend/Cargo.toml # 终端一:API,127.0.0.1:3000
|
||||
pnpm --dir frontend dev # 终端二:http://127.0.0.1:5173
|
||||
```
|
||||
|
||||
终端一,启动 Rust API:
|
||||
Vite 把 `/api` 代理到后端。改后端端口时(`TASK_PILOT_PORT`)需同步修改
|
||||
`frontend/vite.config.ts` 的代理目标;打包版同源,不需要改前端。
|
||||
|
||||
### 方式三:用 uv 跑官方示例与自检
|
||||
|
||||
仓库自带一个 uv 管理的演示任务和一个端到端自检脚本:
|
||||
|
||||
```powershell
|
||||
# 1) 在示例目录创建虚拟环境(Python 3.11)
|
||||
cd samples\python-task
|
||||
uv venv
|
||||
cd ..\..
|
||||
|
||||
# 2) 启动一个临时后端(任意终端)
|
||||
cargo run --manifest-path backend/Cargo.toml
|
||||
|
||||
# 3) 运行 14 项端到端自检
|
||||
uv run --no-project tools/verify.py --base-url http://127.0.0.1:3000
|
||||
```
|
||||
|
||||
终端二,启动 React 前端:
|
||||
自检会真实创建任务、立即运行、读回日志,校验:健康检查、令牌、目录列举、
|
||||
虚拟环境探测、创建/调度注册、运行成功、中文日志、`VIRTUAL_ENV` 注入、
|
||||
无替换字符、统计口径、删除任务。
|
||||
|
||||
```powershell
|
||||
pnpm --dir frontend dev
|
||||
```text
|
||||
[OK ] venv probe links the demo environment status=linked path=.venv\Scripts\python.exe
|
||||
[OK ] task is registered in the schedule next_run_at=2026-10-25T19:00:00Z
|
||||
[OK ] run succeeded status=succeeded exit=0
|
||||
[OK ] log contains Chinese output
|
||||
14/14 checks passed
|
||||
```
|
||||
|
||||
访问 <http://127.0.0.1:5173>。Rust 服务默认监听 `127.0.0.1:3000`,可通过 `TASK_PILOT_PORT` 修改端口:
|
||||
`--keep` 保留任务,`--working-dir` 可换成任意含 `.venv` 的目录。
|
||||
|
||||
```powershell
|
||||
$env:TASK_PILOT_PORT = "3100"
|
||||
cargo run --manifest-path backend/Cargo.toml
|
||||
---
|
||||
|
||||
## 功能总览
|
||||
|
||||
| 分组 | 能力 |
|
||||
| --- | --- |
|
||||
| 任务管理 | 增删改查、启用/停用、按 ID/名称搜索、按启用状态与 Action 类型筛选、分页 |
|
||||
| 调度 | 5/6 位 Cron(支持秒级、别名、月份/星期名)、IANA 时区、启动恢复调度、下次触发时间 |
|
||||
| 执行 | 手动立即运行;同一任务重叠触发记为 `skipped`;启动时对账遗留运行 |
|
||||
| 进程治理 | Windows Job Object 管理进程树;超时或取消时终止整棵树 |
|
||||
| 观测 | 运行历史(状态/触发方式筛选)、退出码、耗时、错误信息、日志下载 |
|
||||
| 日志 | SSE 实时推送 + 增量轮询 + 断线重连;编码自动识别;敏感值脱敏;容量与保留期清理 |
|
||||
| Python | 自动关联 `.venv`/`venv`/`env`、注入 PATH/`VIRTUAL_ENV`/`PYTHONIOENCODING`、虚拟环境校验 |
|
||||
| 界面 | 三类动态表单、Cron 预览、虚拟环境状态条、页面内目录浏览器、深色日志抽屉 |
|
||||
| 安全 | 仅回环监听、Origin 白名单、写操作令牌、常量时间比较、请求体上限 |
|
||||
| 交付 | 单进程静态托管、SPA 回退、Release 打包、OpenAPI 契约、可选离线字体 |
|
||||
|
||||
---
|
||||
|
||||
## 任务模型
|
||||
|
||||
### 字段
|
||||
|
||||
| 字段 | 说明 |
|
||||
| --- | --- |
|
||||
| `id` | 稳定标识,`[A-Za-z0-9_-]`,1–64 字符;同时决定日志目录名 |
|
||||
| `name` | 展示名,1–120 字符 |
|
||||
| `cron` | 5 或 6 位表达式,例:`0 0 2 * * *`、`*/30 * * * * *`、`@daily` |
|
||||
| `timezone` | IANA 时区,例:`Asia/Shanghai` |
|
||||
| `enabled` | 是否注册调度 |
|
||||
| `timeout_secs` | 1–86400;超时终止整个进程树 |
|
||||
| `concurrency_policy` | 目前仅 `skip`(同任务重叠触发被跳过) |
|
||||
| `retry_policy` | `max_attempts` ≤ 10、`delay_secs` ≤ 3600;**当前只持久化,不自动重试** |
|
||||
| `action` | 见下 |
|
||||
|
||||
### 三类 Action
|
||||
|
||||
```jsonc
|
||||
// 脚本
|
||||
{ "type": "script", "working_dir": "D:\\Projects\\sync", "venv": null,
|
||||
"interpreter": "python.exe", "script": "main.py",
|
||||
"args": ["--mode", "full"], "env": { "PYTHONUNBUFFERED": "1" } }
|
||||
|
||||
// HTTP
|
||||
{ "type": "http", "url": "https://api.example.com/health", "method": "GET",
|
||||
"headers": { "X-Token": "..." }, "body": null, "expected_status": [200, 204] }
|
||||
|
||||
// 进程
|
||||
{ "type": "process", "executable": "C:\\Tools\\agent.exe",
|
||||
"working_dir": "C:\\Tools", "args": ["--serve"], "env": {} }
|
||||
```
|
||||
|
||||
修改后端端口后,还需要同步调整 `frontend/vite.config.ts` 中的代理目标。
|
||||
### 运行状态机
|
||||
|
||||
## Windows Release
|
||||
|
||||
构建前端生产资源、Rust Release 可执行文件和可分发目录:
|
||||
|
||||
```powershell
|
||||
.\scripts\build-release.ps1
|
||||
```text
|
||||
queued ──► running ──► succeeded / failed / timed_out / cancelled
|
||||
└────────────────► skipped (同一任务已有运行实例)
|
||||
```
|
||||
|
||||
产物位于 `release/TaskPilot/`。启动打包版本:
|
||||
终态不可再取消;进程被强杀后,下次启动会把遗留的 `queued`/`running`
|
||||
统一标记为 `cancelled`,并写入「TaskPilot 进程重启,运行被中断」。
|
||||
|
||||
```powershell
|
||||
.\release\TaskPilot\start.ps1
|
||||
```
|
||||
---
|
||||
|
||||
访问 <http://127.0.0.1:3000>。打包使用同源 API,因此 Release 模式修改 `TASK_PILOT_PORT` 后无需重新构建前端。
|
||||
## 调度与执行语义
|
||||
|
||||
打包目录中的 [RELEASE.md](docs/RELEASE.md) 会作为使用与故障排查说明一并复制。
|
||||
- **三类任务都参与调度**。启用即注册,停用即取消;后端重启后从数据库恢复注册。
|
||||
- 每个任务一个独立 Tokio 循环:计算下次触发 → 等待 → 调用执行器。
|
||||
- **重叠触发**:同一任务已有运行实例时,新触发记为 `skipped`,不会并发。
|
||||
- **没有全局并发上限**:10 个任务同时到点会各自启动一个进程。
|
||||
- **取消**:通过 `CancellationToken` + `TerminateJobObject`,5 秒内未退出则 `kill`。
|
||||
- **超时**:与取消同一路径,状态记为 `timed_out`。
|
||||
- **取消后立即重跑**:允许;旧进程终止与新进程启动可能短暂重叠。
|
||||
- **错过的触发不补跑**:下次触发始终从"现在"开始计算。
|
||||
- **下次触发时间**:调度器保存在内存中,`GET /api/v1/schedule` 暴露给前端。
|
||||
- **重试**:字段已持久化但执行器尚未实现自动重试(见「已知限制」)。
|
||||
|
||||
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`(UTF-8 写入)
|
||||
|
||||
运行日志默认写入 `logs/{task_id}/{run_id}.log`,可通过以下环境变量配置:
|
||||
- 每次运行 **5 MiB** 上限,超出后丢弃后续输出并写一条 `[system]` 说明。
|
||||
- **单行 256 KiB** 上限:超出部分丢弃、继续读到行尾,避免内存无界增长。
|
||||
- 保留期(默认 30 天)与总容量(默认 512 MiB)清理;**跳过正在写入的日志**。
|
||||
- 读取按 64 KiB 分块且对齐 UTF-8 边界;`complete` 只在"运行结束且读到文件末尾"时为真。
|
||||
- **输出编码**:默认 `auto` = 逐行先按 UTF-8 解码,失败回退到系统 ANSI 代码页(简体中文为 GBK)。
|
||||
可用 `TASK_PILOT_LOG_ENCODING=gbk|utf-8|windows-1251` 强制。
|
||||
- **脱敏**:环境变量/Header 名包含 `SECRET`、`TOKEN`、`PASSWORD`、`PASS`、`API_KEY`、
|
||||
`PRIVATE_KEY`、`CREDENTIAL`、`AUTHORIZATION`、`AUTH` 时,其值在日志中被替换为 `[REDACTED]`。
|
||||
|
||||
```powershell
|
||||
$env:TASK_PILOT_LOG_DIR = "logs"
|
||||
$env:TASK_PILOT_LOG_RETENTION_DAYS = "30"
|
||||
$env:TASK_PILOT_LOG_MAX_TOTAL_MB = "512"
|
||||
$env:TASK_PILOT_LOG_ENCODING = "auto"
|
||||
```
|
||||
---
|
||||
|
||||
`TASK_PILOT_LOG_ENCODING` 默认为 `auto`:逐行先按 UTF-8 解码,失败时回退到系统 ANSI 代码页(简体中文为 GBK),保证中文程序输出不会变成 `����`。需要强制某一种编码时可填写 `gbk`、`utf-8`、`windows-1251` 等标签。
|
||||
## Python 与虚拟环境
|
||||
|
||||
单次运行日志达到 5 MiB 后会停止记录后续输出。程序启动和每次运行结束时会清理过期日志,并在总容量超限时优先删除最旧日志。
|
||||
解析顺序(执行期与编辑器探测共用同一份逻辑):
|
||||
|
||||
## API
|
||||
1. 手动指定的 `action.venv`
|
||||
2. 从解释器路径推断(`...\.venv\Scripts\python.exe`)
|
||||
3. 从工作目录向上查找 `.venv`、`venv`、`env`
|
||||
|
||||
| 方法 | 路径 | 用途 |
|
||||
虚拟环境必须同时存在 `pyvenv.cfg` 与 `Scripts\python.exe`,否则显式配置在保存时被拒绝。
|
||||
|
||||
命中虚拟环境后注入:
|
||||
|
||||
| 变量 | 行为 |
|
||||
| --- | --- |
|
||||
| `PATH` | 虚拟环境 `Scripts` 目录置前,保留用户 PATH,按大小写不敏感去重 |
|
||||
| `VIRTUAL_ENV` / `VIRTUAL_ENV_PROMPT` | 指向虚拟环境根目录 |
|
||||
| `PYTHONUNBUFFERED` | 强制 `1`(实时日志依赖) |
|
||||
| `PYTHONIOENCODING` | 默认 `utf-8`,用户显式设置优先;避免 GBK 控制台下打印 emoji/生僻字符直接崩溃 |
|
||||
| `PYTHONHOME` / `PYTHONPATH` | 虚拟环境模式下清理继承的污染值 |
|
||||
|
||||
编辑器会实时显示 `● 已关联虚拟环境: .venv\Scripts\python.exe`,并同步更新调用预览。
|
||||
|
||||
---
|
||||
|
||||
## Web 界面
|
||||
|
||||
- **指标卡**:已托管任务、运行中任务、可用脚本、引擎在线状态。
|
||||
- **任务表**:名称/ID、调度周期(含时区与**下次触发时间**)、类型、状态、超时、操作。
|
||||
- **编辑器**:三类动态表单、Cron 预览(实时计算未来 5/10 次)、虚拟环境状态条、调用预览。
|
||||
- **目录选择**:页面内目录浏览器(盘符 → 逐级进入 → 使用此目录),同时保留手动输入。
|
||||
- **日志抽屉**:运行历史筛选、SSE 实时日志、取消运行、下载日志、Escape/焦点陷阱。
|
||||
- **视觉**:对齐 Alright 主题(`#3858f6` 主色、灰阶体系、0.3s 缓动);正文使用
|
||||
`LXGW WenKai`、标题与数字使用 `Outfit-Medium`。字体二进制不入库,用
|
||||
`tools/build-fonts.py` 生成,缺失时回退系统字体。
|
||||
|
||||
---
|
||||
|
||||
## 安全模型
|
||||
|
||||
| 边界 | 实现 |
|
||||
| --- | --- |
|
||||
| 监听地址 | 固定 `127.0.0.1`(`TASK_PILOT_PORT` 只改端口) |
|
||||
| Origin | 带 `Origin` 的请求必须是 `http://127.0.0.1:*` 或 `http://localhost:*`,否则 403 |
|
||||
| 管理令牌 | 非 GET/HEAD/OPTIONS 必须带 `X-TaskPilot-Token`;进程启动时随机生成,重启轮换 |
|
||||
| 比较方式 | 常量时间比较,避免时序侧信道 |
|
||||
| 请求体 | 上限 2 MiB |
|
||||
| 命令执行 | 参数以 argv 传递,不经过 Shell 拼接 |
|
||||
| 读取接口 | 只读接口(任务列表、日志、目录列举、统计)免令牌 |
|
||||
| 前端令牌 | 客户端缓存令牌;写请求 403 时丢弃缓存并重试一次 |
|
||||
|
||||
> 这是**本地单用户工具**的威胁模型:本机其他进程仍可调用只读接口。不要把端口暴露到局域网。
|
||||
|
||||
---
|
||||
|
||||
## 配置项
|
||||
|
||||
| 变量 | 默认值 | 说明 |
|
||||
| --- | --- | --- |
|
||||
| `GET` | `/api/v1/health` | 后端健康检查 |
|
||||
| `GET` | `/api/v1/session` | 获取当前进程的本地管理令牌 |
|
||||
| `TASK_PILOT_PORT` | `3000` | 监听端口(地址固定回环) |
|
||||
| `TASK_PILOT_DATABASE_URL` | `sqlite://data/task_pilot.db` | SQLite 路径;`sqlite::memory:` 用于测试 |
|
||||
| `TASK_PILOT_LOG_DIR` | `logs` | 运行日志根目录 |
|
||||
| `TASK_PILOT_STATIC_DIR` | `frontend/dist` | 生产静态资源目录(打包版由 `start.ps1` 设为 `web`) |
|
||||
| `TASK_PILOT_LOG_RETENTION_DAYS` | `30` | 日志保留天数 |
|
||||
| `TASK_PILOT_LOG_MAX_TOTAL_MB` | `512` | 日志总容量上限 |
|
||||
| `TASK_PILOT_LOG_ENCODING` | `auto` | 子进程输出编码;`auto` 为 UTF-8→系统代码页回退 |
|
||||
| `RUST_LOG` | `task_pilot_backend=debug,tower_http=debug` | 日志级别 |
|
||||
|
||||
数据库连接:SQLite 开启 `foreign_keys`、**WAL**、`busy_timeout=5s`、`synchronous=NORMAL`,最多 5 个连接。
|
||||
|
||||
---
|
||||
|
||||
## API 参考
|
||||
|
||||
### 系统
|
||||
|
||||
| 方法 | 路径 | 说明 |
|
||||
| --- | --- | --- |
|
||||
| `GET` | `/api/v1/health` | 健康检查 |
|
||||
| `GET` | `/api/v1/session` | 获取当前进程的管理令牌 |
|
||||
| `GET` | `/api/v1/openapi.json` | 运行时 OpenAPI 文档 |
|
||||
| `POST` | `/api/v1/python/venv` | 探测工作目录会关联的 Python 虚拟环境 |
|
||||
| `GET` | `/api/v1/system/directories` | 列出盘符或指定目录下的子文件夹 |
|
||||
| `GET` | `/api/v1/tasks` | 查询任务列表 |
|
||||
| `POST` | `/api/v1/cron/preview` | 计算未来若干次触发时间 |
|
||||
| `POST` | `/api/v1/python/venv` | 探测工作目录会关联的虚拟环境 |
|
||||
| `GET` | `/api/v1/system/directories?path=` | 列盘符或子文件夹(目录浏览器) |
|
||||
| `GET` | `/api/v1/stats` | 仪表盘计数(总数/启用/运行中/三类数量) |
|
||||
| `GET` | `/api/v1/schedule` | 已启用任务的下次触发时间 |
|
||||
|
||||
### 任务与运行
|
||||
|
||||
| 方法 | 路径 | 说明 |
|
||||
| --- | --- | --- |
|
||||
| `GET` | `/api/v1/tasks` | 任务列表(`q`、`enabled`、`action_type`、`page`、`page_size`) |
|
||||
| `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 运行状态和日志事件 |
|
||||
| `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` | 手动立即执行(`202`) |
|
||||
| `GET` | `/api/v1/tasks/{id}/runs` | 运行历史(`status`、`trigger`、分页) |
|
||||
| `GET` | `/api/v1/runs/{run_id}` | 单次运行详情 |
|
||||
| `GET` | `/api/v1/runs/{run_id}/logs?offset=0` | 增量日志(`content`、`next_offset`、`complete`) |
|
||||
| `GET` | `/api/v1/runs/{run_id}/logs/download` | 下载完整日志 |
|
||||
| `POST` | `/api/v1/runs/{run_id}/cancel` | 取消运行 |
|
||||
| `GET` | `/api/v1/events` | SSE:`run` / `log` / `lagged` / `ready` |
|
||||
|
||||
任务列表查询参数:
|
||||
### 示例
|
||||
|
||||
- `q`:按任务 ID 或名称搜索。
|
||||
- `enabled`:按启用状态筛选。
|
||||
- `action_type`:`script`、`http` 或 `process`。
|
||||
- `page`:页码,默认 `1`。
|
||||
- `page_size`:每页数量,默认 `20`,最大 `100`。
|
||||
创建任务:
|
||||
|
||||
运行历史查询额外支持 `status` 和 `trigger` 过滤。
|
||||
```http
|
||||
POST /api/v1/tasks
|
||||
X-TaskPilot-Token: <session token>
|
||||
Content-Type: application/json
|
||||
|
||||
健康检查示例:
|
||||
|
||||
```json
|
||||
{
|
||||
"status": "ok",
|
||||
"service": "task-pilot-backend",
|
||||
"version": "0.1.0"
|
||||
}
|
||||
```
|
||||
|
||||
创建脚本任务示例:
|
||||
|
||||
```json
|
||||
{
|
||||
"id": "sync_data",
|
||||
"name": "数据同步",
|
||||
@@ -218,157 +312,225 @@ $env:TASK_PILOT_LOG_ENCODING = "auto"
|
||||
"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"
|
||||
}
|
||||
"env": {}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
校验失败和资源不存在时统一返回结构化错误:
|
||||
统计数据:
|
||||
|
||||
```json
|
||||
{
|
||||
"code": "VALIDATION_ERROR",
|
||||
"message": "Cron 表达式无效",
|
||||
"field": "cron",
|
||||
"details": {}
|
||||
}
|
||||
{ "total": 12, "enabled": 9, "running": 1, "script": 7, "http": 3, "process": 2 }
|
||||
```
|
||||
|
||||
## 开发命令
|
||||
调度信息:
|
||||
|
||||
### 完整检查
|
||||
```json
|
||||
[ { "task_id": "sync_data", "next_run_at": "2026-10-26T18:00:00Z" } ]
|
||||
```
|
||||
|
||||
在项目根目录执行:
|
||||
统一错误结构:
|
||||
|
||||
```json
|
||||
{ "code": "VALIDATION_ERROR", "message": "Cron 表达式无效", "field": "cron", "details": {} }
|
||||
```
|
||||
|
||||
常见错误码:`VALIDATION_ERROR`、`INVALID_JSON`、`INVALID_QUERY`、`TASK_NOT_FOUND`、
|
||||
`RUN_NOT_FOUND`、`RUN_LOG_NOT_FOUND`、`RUN_NOT_CANCELLABLE`、`TASK_ID_CONFLICT`、
|
||||
`DIRECTORY_PATH_INVALID`、`DIRECTORY_NOT_FOUND`、`DIRECTORY_UNREADABLE`、`FORBIDDEN`、`INTERNAL_ERROR`。
|
||||
|
||||
---
|
||||
|
||||
## 输入校验
|
||||
|
||||
保存任务时逐项强制(失败返回 `400 VALIDATION_ERROR` 并带 `field`):
|
||||
|
||||
| 字段 | 规则 |
|
||||
| --- | --- |
|
||||
| `id` | 1–64,`[A-Za-z0-9_-]`;重复返回 `409 TASK_ID_CONFLICT` |
|
||||
| `name` | 去空格后 1–120 字符 |
|
||||
| `cron` | 可解析且存在未来触发点 |
|
||||
| `timezone` | 有效 IANA 名称 |
|
||||
| `timeout_secs` | 1–86400 |
|
||||
| `retry_policy` | `max_attempts ≤ 10`,`delay_secs ≤ 3600` |
|
||||
| `action.working_dir` / `interpreter` / `script` | 非空 |
|
||||
| `action.venv` | 若填写,必须存在 `pyvenv.cfg` 与 `Scripts\python.exe` |
|
||||
| `action.args` | ≤ 256 个,单个 ≤ 2048 字符 |
|
||||
| `action.env` | ≤ 128 条;key ≤ 128、不含 `=`/`\0`;value ≤ 8192、不含 `\0` |
|
||||
| `action.url` | 可解析、限 `http`/`https`、必须有主机名 |
|
||||
| `action.headers` | 名称/值符合 HTTP 规范 |
|
||||
| `action.body` | ≤ 1 MiB |
|
||||
| `action.expected_status` | 每项 100–599 |
|
||||
| 请求体 | 未知字段直接拒绝(`deny_unknown_fields`) |
|
||||
|
||||
查询与读取:`page ≥ 1`、`page_size` 夹到 1–100、日志 `offset` 不得超过文件长度。
|
||||
|
||||
**不在保存时校验**:工作目录是否存在、脚本文件是否存在、解释器是否存在(虚拟环境除外)。
|
||||
这些在执行时才暴露,属于日志里的运行错误。
|
||||
|
||||
---
|
||||
|
||||
## 数据与文件布局
|
||||
|
||||
```text
|
||||
data/task_pilot.db # SQLite(WAL)
|
||||
logs/{task_id}/{run_id}.log # UTF-8 运行日志
|
||||
.tmp/ # check.ps1 的测试临时目录(已 gitignore)
|
||||
```
|
||||
|
||||
`tasks` 表:主键 `id`,索引 `updated_at`、`enabled`、`action_type`。
|
||||
`task_runs` 表:`task_id` 外键 `ON DELETE CASCADE`,`status`/`trigger_type` 带 CHECK 约束,
|
||||
索引 `(task_id, created_at DESC)` 与 `status`。
|
||||
|
||||
---
|
||||
|
||||
## 架构与模块
|
||||
|
||||
```text
|
||||
React (Vite) ──fetch──► Axum router ──security::api_guard(Origin + token)
|
||||
│
|
||||
├── tasks: routes → service(校验) → repository(SQL)
|
||||
├── runs : routes → repository
|
||||
├── Scheduler ──trigger──► TaskExecutor ──► Job Object
|
||||
└── broadcast(512) ──► SSE /api/v1/events
|
||||
```
|
||||
|
||||
| 模块 | 职责 |
|
||||
| --- | --- |
|
||||
| `api.rs` | 路由、OpenAPI 聚合、系统级接口(health/session/cron/stats/schedule) |
|
||||
| `tasks/` | 任务模型、校验、SQL、CRUD 路由 |
|
||||
| `runs/` | 运行记录、执行器、日志落盘、日志读取与 SSE |
|
||||
| `scheduler.rs` | 每任务一个调度循环、下次触发时间、注册与取消 |
|
||||
| `executor.rs` | 三类 Action 执行、并发闸门、超时/取消、日志脱敏与截断 |
|
||||
| `process_job.rs` | Windows Job Object(`KILL_ON_JOB_CLOSE`) |
|
||||
| `python_env.rs` | 虚拟环境解析与探测、PATH/编码注入 |
|
||||
| `directory_browser.rs` | 盘符与目录列举(页面内目录选择器) |
|
||||
| `security.rs` | Origin 白名单、令牌校验、常量时间比较 |
|
||||
| `database.rs` | 连接池与迁移(WAL/busy_timeout) |
|
||||
|
||||
前端:
|
||||
|
||||
| 模块 | 职责 |
|
||||
| --- | --- |
|
||||
| `pages/TaskDashboard.tsx` | 列表、筛选、分页、指标卡、下次触发时间 |
|
||||
| `components/TaskEditorModal.tsx` | 三类动态表单、Cron 预览、虚拟环境状态条 |
|
||||
| `components/DirectoryPickerDialog.tsx` | 页面内目录浏览器 |
|
||||
| `components/RunLogDrawer.tsx` | 运行历史、日志、取消、下载、SSE |
|
||||
| `api/client.ts` | 类型化客户端、令牌缓存、403 重试 |
|
||||
| `lib/taskForm.ts` | Zod 校验与表单 ↔ API 载荷映射 |
|
||||
|
||||
---
|
||||
|
||||
## 开发与测试
|
||||
|
||||
### 环境
|
||||
|
||||
- Windows 10/11
|
||||
- Rust 1.95(`rust-toolchain.toml` 固定,MSVC)
|
||||
- Node.js 24 / pnpm 11(`frontend/package.json` 的 `volta` 固定)
|
||||
- Visual Studio C++ Build Tools(Rust MSVC 需要)
|
||||
- 可选:`uv`(跑示例任务与自检脚本)
|
||||
|
||||
### 完整质量门
|
||||
|
||||
```powershell
|
||||
.\scripts\check.ps1
|
||||
```
|
||||
|
||||
该脚本依次运行:
|
||||
依次执行:前端 lint → 类型检查 → Vitest → 生产构建 → `cargo fmt --check` →
|
||||
`clippy -D warnings` → `cargo test` → Playwright(真实后端 + Vite + Chrome)。
|
||||
|
||||
- 前端 lint
|
||||
- TypeScript 类型检查
|
||||
- Vitest
|
||||
- 前端生产构建
|
||||
- Rust 格式检查
|
||||
- Rust Clippy
|
||||
- Rust 单元和集成测试
|
||||
- Playwright 浏览器端到端测试
|
||||
脚本会把 `TMP`/`TEMP` 指向仓库内 `.tmp/`,让测试不依赖系统临时目录的权限策略,
|
||||
结束时自动清理。
|
||||
|
||||
### 浏览器端到端测试
|
||||
### 单独命令
|
||||
|
||||
```powershell
|
||||
pnpm --dir frontend lint | typecheck | test | 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
|
||||
pnpm --dir frontend test:e2e
|
||||
```
|
||||
|
||||
端到端测试会清理并重建 `.e2e-data/`,启动临时 Rust 服务和 Vite,使用本机 Chrome 完成真实任务执行和页面操作。
|
||||
|
||||
### 生成接口契约和类型
|
||||
|
||||
修改后端 API DTO 或路由后执行:
|
||||
### 契约与类型
|
||||
|
||||
```powershell
|
||||
.\scripts\generate-api.ps1
|
||||
.\scripts\generate-api.ps1 # 更新 contracts/openapi.json 与 frontend/src/api/generated.ts
|
||||
```
|
||||
|
||||
该命令会更新:
|
||||
生成文件不要手工修改。
|
||||
|
||||
- `contracts/openapi.json`
|
||||
- `frontend/src/api/generated.ts`
|
||||
### 界面字体(可选)
|
||||
|
||||
生成文件不应手工修改。
|
||||
|
||||
### 单独检查
|
||||
字体二进制体积约 6.7 MB,**不纳入仓库**。首次克隆后如需与设计一致的中文界面:
|
||||
|
||||
```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
|
||||
uv run --with fonttools --with brotli tools/build-fonts.py
|
||||
```
|
||||
|
||||
## 项目结构
|
||||
脚本会从 `lxgw-wenkai-webfont` 的 CDN 下载分包、合并并裁剪到 GB2312 + 中英文标点,
|
||||
输出到 `frontend/public/fonts/`(已被 `.gitignore` 忽略)。不生成也能正常运行,
|
||||
只是正文回退到系统字体;`Outfit-Medium.ttf`(55 KB)仍随仓库提供。
|
||||
|
||||
```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
|
||||
```
|
||||
### 测试构成
|
||||
|
||||
架构决策记录:
|
||||
| 层 | 覆盖 |
|
||||
| --- | --- |
|
||||
| Rust 单元(52) | Cron、虚拟环境解析与探测、执行器(编码/截断/清理/句柄归属)、安全、目录列举 |
|
||||
| Rust 集成(30) | 任务 CRUD 与校验、运行生命周期、取消与超时、日志分块、调度恢复、统计与调度接口、重启对账 |
|
||||
| 前端 Vitest(13) | 仪表盘渲染、表单载荷、虚拟环境状态、目录浏览器(进入/取消/错误)、下次触发时间 |
|
||||
| Playwright(4) | 真实任务执行与日志、编辑/停用/删除、窄屏、**无外部网络渲染**(拦截所有外部请求) |
|
||||
| Python 自检(14 项) | `tools/verify.py` 覆盖 API 全链路 |
|
||||
|
||||
---
|
||||
|
||||
## 故障排查
|
||||
|
||||
| 现象 | 处理 |
|
||||
| --- | --- |
|
||||
| `start.ps1` 无法运行 | `powershell -ExecutionPolicy Bypass -File .\release\TaskPilot\start.ps1` |
|
||||
| 端口被占用 | `$env:TASK_PILOT_PORT = "3100"` 后重启 |
|
||||
| 任务不执行 | 确认已启用、Cron 与下次触发时间正确;查看运行历史里的错误信息 |
|
||||
| PowerShell 脚本任务报「不是数字签名」 | 执行策略拦未签名脚本:在参数里加 `-ExecutionPolicy Bypass`,或对脚本目录 `Unblock-File` |
|
||||
| 日志出现 `����` | 程序未按 UTF-8 输出:设置 `TASK_PILOT_LOG_ENCODING=gbk`(或让程序输出 UTF-8) |
|
||||
| 日志尾部缺失 | 单次运行超过 5 MiB 或单行超过 256 KiB,会截断并写 `[system]` 说明 |
|
||||
| 运行一直显示"运行中" | 已由启动对账修复;若仍出现,检查进程是否被系统挂起 |
|
||||
| 虚拟环境未生效 | 目录下需同时有 `pyvenv.cfg` 与 `Scripts\python.exe`;也可在表单里显式指定 |
|
||||
| `cargo test` 报临时目录拒绝访问 | 用 `.\scripts\check.ps1`(会把 TEMP 指向 `.tmp/`)或手动设置 `TEMP` 到可写目录 |
|
||||
|
||||
---
|
||||
|
||||
## 已知限制
|
||||
|
||||
- 自动重试尚未实现:`retry_policy` 只校验与存储。
|
||||
- 并发策略只有 `skip`;没有全局并发上限与队列。
|
||||
- HTTP Action 只校验状态码,不支持响应体断言、认证方案或 Header 断言。
|
||||
- 敏感值脱敏基于变量名关键词与值替换,不是凭据保险箱。
|
||||
- Conda 环境不会自动激活,需要显式填写解释器路径。
|
||||
- 本地单用户工具:读取接口免令牌,不支持远程部署与多用户。
|
||||
- 未安装为 Windows 服务;关闭启动脚本后调度停止。
|
||||
- 错过的调度不会补跑。
|
||||
- 界面字体不入库(体积原因),需用 `tools/build-fonts.py` 生成;缺失时回退系统字体。
|
||||
|
||||
---
|
||||
|
||||
## 架构决策记录
|
||||
|
||||
- [ADR-0001:Cron 解析与任务调度](docs/decisions/0001-cron-engine.md)
|
||||
- [ADR-0002:前后端开发与生产托管](docs/decisions/0002-static-hosting.md)
|
||||
- [ADR-0003:Python 虚拟环境启动环境](docs/decisions/0003-python-environment.md)
|
||||
- [ADR-0004:运行日志的输出编码](docs/decisions/0004-log-encoding.md)
|
||||
- [ADR-0005:由后端提供系统文件夹选择](docs/decisions/0005-folder-picker.md)
|
||||
|
||||
## 当前限制
|
||||
|
||||
- 重试策略字段已持久化,但自动重试尚未实现。
|
||||
- HTTP 任务执行器尚未提供响应 Header 转换等高级断言,只校验状态码。
|
||||
- 敏感值脱敏基于变量名关键词和值替换,不是系统凭据保险箱。
|
||||
- Conda 环境不会按 venv 自动激活,目前需要显式填写其 Python 解释器路径。
|
||||
- 当前版本是本地单用户工具,不支持多用户账户和远程部署。
|
||||
- 尚未安装为 Windows 系统服务,关闭启动脚本后调度器也会停止。
|
||||
- 旧 `task_pilot.html` 暂时保留在根目录作为设计参考,正式入口为 React 应用。
|
||||
- [ADR-0006:视觉风格对齐主题并自托管字体](docs/decisions/0006-visual-theme-and-offline-fonts.md)
|
||||
- [ADR-0007:运行期可靠性优化](docs/decisions/0007-runtime-reliability.md)
|
||||
|
||||
## 开发计划
|
||||
|
||||
完整架构、数据模型、API 草案、阶段任务和验收标准见 [PLAN.md](PLAN.md)。六个开发阶段均已完成,后续进入维护、功能反馈和版本迭代。
|
||||
完整架构、数据模型、阶段任务与验收标准见 [PLAN.md](PLAN.md)。六个阶段均已完成,当前处于维护与迭代阶段。
|
||||
|
||||
+53
-3
@@ -1,6 +1,6 @@
|
||||
use axum::{
|
||||
Json, Router,
|
||||
extract::State,
|
||||
extract::{DefaultBodyLimit, State},
|
||||
http::{HeaderValue, Method, header},
|
||||
middleware,
|
||||
response::IntoResponse,
|
||||
@@ -31,8 +31,9 @@ use crate::{
|
||||
state::AppState,
|
||||
tasks::{
|
||||
model::{
|
||||
ConcurrencyPolicy, CreateTaskRequest, HttpMethod, RetryPolicy, TaskAction,
|
||||
TaskActionType, TaskListResponse, TaskResponse, UpdateTaskRequest,
|
||||
ConcurrencyPolicy, CreateTaskRequest, HttpMethod, RetryPolicy, ScheduledTaskResponse,
|
||||
TaskAction, TaskActionType, TaskListResponse, TaskResponse, TaskStatsResponse,
|
||||
UpdateTaskRequest,
|
||||
},
|
||||
routes::{
|
||||
__path_create_task, __path_delete_task, __path_disable_task, __path_enable_task,
|
||||
@@ -162,6 +163,47 @@ pub async fn list_directories(
|
||||
})
|
||||
}
|
||||
|
||||
#[utoipa::path(
|
||||
get,
|
||||
path = "/api/v1/stats",
|
||||
tag = "tasks",
|
||||
responses((status = 200, description = "Dashboard counters", body = TaskStatsResponse))
|
||||
)]
|
||||
pub async fn task_stats(State(state): State<AppState>) -> ApiResult<Json<TaskStatsResponse>> {
|
||||
let counts = crate::tasks::repository::counts(&state.pool).await?;
|
||||
Ok(Json(TaskStatsResponse {
|
||||
total: counts.total,
|
||||
enabled: counts.enabled,
|
||||
running: u64::try_from(state.executor.running_count()).unwrap_or_default(),
|
||||
script: counts.script,
|
||||
http: counts.http,
|
||||
process: counts.process,
|
||||
}))
|
||||
}
|
||||
|
||||
#[utoipa::path(
|
||||
get,
|
||||
path = "/api/v1/schedule",
|
||||
tag = "tasks",
|
||||
responses(
|
||||
(status = 200, description = "Next trigger time per enabled task", body = Vec<ScheduledTaskResponse>)
|
||||
)
|
||||
)]
|
||||
pub async fn list_schedule(
|
||||
State(state): State<AppState>,
|
||||
) -> ApiResult<Json<Vec<ScheduledTaskResponse>>> {
|
||||
let tasks = crate::tasks::repository::list_enabled_tasks(&state.pool).await?;
|
||||
Ok(Json(
|
||||
tasks
|
||||
.into_iter()
|
||||
.map(|task| ScheduledTaskResponse {
|
||||
next_run_at: state.scheduler.next_run_at(&task.id),
|
||||
task_id: task.id,
|
||||
})
|
||||
.collect(),
|
||||
))
|
||||
}
|
||||
|
||||
#[derive(OpenApi)]
|
||||
#[openapi(
|
||||
info(
|
||||
@@ -175,6 +217,8 @@ pub async fn list_directories(
|
||||
preview_cron,
|
||||
probe_python_venv,
|
||||
list_directories,
|
||||
task_stats,
|
||||
list_schedule,
|
||||
list_tasks,
|
||||
create_task,
|
||||
get_task,
|
||||
@@ -202,6 +246,8 @@ pub async fn list_directories(
|
||||
InterpreterKind,
|
||||
DirectoryEntry,
|
||||
DirectoryListingResponse,
|
||||
TaskStatsResponse,
|
||||
ScheduledTaskResponse,
|
||||
ErrorResponse,
|
||||
TaskActionType,
|
||||
HttpMethod,
|
||||
@@ -252,10 +298,14 @@ pub fn router(state: AppState) -> Router {
|
||||
axum::routing::post(probe_python_venv),
|
||||
)
|
||||
.route("/api/v1/system/directories", get(list_directories))
|
||||
.route("/api/v1/stats", get(task_stats))
|
||||
.route("/api/v1/schedule", get(list_schedule))
|
||||
.route("/api/v1/openapi.json", get(openapi_json))
|
||||
.merge(crate::tasks::routes::router())
|
||||
.merge(crate::runs::routes::router())
|
||||
.route("/api/{*path}", any(api_not_found))
|
||||
// 本地工具也不需要接受超大请求体;任务载荷与日志上传都用不到 2 MiB 以上。
|
||||
.layer(DefaultBodyLimit::max(2 * 1024 * 1024))
|
||||
.layer(cors)
|
||||
.route_layer(middleware::from_fn_with_state(
|
||||
state.clone(),
|
||||
|
||||
+16
-9
@@ -1,20 +1,27 @@
|
||||
use std::{error::Error, str::FromStr};
|
||||
use std::{error::Error, str::FromStr, time::Duration};
|
||||
|
||||
use sqlx::{
|
||||
SqlitePool,
|
||||
migrate::MigrateError,
|
||||
sqlite::{SqliteConnectOptions, SqlitePoolOptions},
|
||||
sqlite::{SqliteConnectOptions, SqliteJournalMode, SqlitePoolOptions, SqliteSynchronous},
|
||||
};
|
||||
|
||||
pub async fn connect(database_url: &str) -> Result<SqlitePool, Box<dyn Error>> {
|
||||
let options = SqliteConnectOptions::from_str(database_url)?
|
||||
let in_memory = database_url.contains(":memory:");
|
||||
let mut options = SqliteConnectOptions::from_str(database_url)?
|
||||
.create_if_missing(true)
|
||||
.foreign_keys(true);
|
||||
let max_connections = if database_url == "sqlite::memory:" {
|
||||
1
|
||||
} else {
|
||||
5
|
||||
};
|
||||
.foreign_keys(true)
|
||||
.busy_timeout(Duration::from_secs(5));
|
||||
|
||||
// 运行日志和状态写入与前端查询会并发访问同一个库;WAL 允许读写并行,
|
||||
// busy_timeout 则避免瞬时冲突直接返回 "database is locked"。
|
||||
if !in_memory {
|
||||
options = options
|
||||
.journal_mode(SqliteJournalMode::Wal)
|
||||
.synchronous(SqliteSynchronous::Normal);
|
||||
}
|
||||
|
||||
let max_connections = if in_memory { 1 } else { 5 };
|
||||
let pool = SqlitePoolOptions::new()
|
||||
.max_connections(max_connections)
|
||||
.connect_with(options)
|
||||
|
||||
@@ -79,7 +79,7 @@ pub fn prepare_script_environment(
|
||||
.to_string_lossy()
|
||||
.into_owned(),
|
||||
);
|
||||
environment.insert("PYTHONUNBUFFERED".to_owned(), "1".to_owned());
|
||||
apply_stdio_defaults(&mut environment);
|
||||
return Ok(PreparedPython {
|
||||
interpreter: python,
|
||||
virtual_env: Some(venv.clone()),
|
||||
@@ -93,7 +93,7 @@ pub fn prepare_script_environment(
|
||||
});
|
||||
}
|
||||
|
||||
environment.insert("PYTHONUNBUFFERED".to_owned(), "1".to_owned());
|
||||
apply_stdio_defaults(&mut environment);
|
||||
Ok(PreparedPython {
|
||||
interpreter: interpreter_path,
|
||||
virtual_env: None,
|
||||
@@ -102,6 +102,17 @@ pub fn prepare_script_environment(
|
||||
})
|
||||
}
|
||||
|
||||
/// Python 的标准流默认跟随系统代码页(简体中文为 GBK),一旦打印 emoji 或生僻字符
|
||||
/// 就会 `UnicodeEncodeError` 直接失败。这里默认改用 UTF-8,日志侧本来就优先按 UTF-8 解码。
|
||||
/// 用户在任务里显式设置的 `PYTHONIOENCODING` 仍然优先。
|
||||
fn apply_stdio_defaults(environment: &mut BTreeMap<String, String>) {
|
||||
// 实时日志依赖标准流不缓冲,这一项始终强制。
|
||||
environment.insert("PYTHONUNBUFFERED".to_owned(), "1".to_owned());
|
||||
environment
|
||||
.entry("PYTHONIOENCODING".to_owned())
|
||||
.or_insert_with(|| "utf-8".to_owned());
|
||||
}
|
||||
|
||||
/// 虚拟环境的来源,用于在界面上说明"为什么"关联到了这个环境。
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, ToSchema)]
|
||||
#[serde(rename_all = "snake_case")]
|
||||
@@ -630,6 +641,48 @@ mod tests {
|
||||
assert!(prepared.remove_environment.contains(&"VIRTUAL_ENV_PROMPT"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn injects_utf8_stdio_for_python_tasks() {
|
||||
let temp = TempDir::new().unwrap();
|
||||
|
||||
let prepared = prepare_script_environment(
|
||||
temp.path().to_str().unwrap(),
|
||||
"python.exe",
|
||||
None,
|
||||
&BTreeMap::new(),
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
assert_eq!(
|
||||
prepared.environment.get("PYTHONIOENCODING"),
|
||||
Some(&"utf-8".to_owned())
|
||||
);
|
||||
assert_eq!(
|
||||
prepared.environment.get("PYTHONUNBUFFERED"),
|
||||
Some(&"1".to_owned())
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn keeps_user_pythonioencoding_override() {
|
||||
let temp = TempDir::new().unwrap();
|
||||
let mut user_env = BTreeMap::new();
|
||||
user_env.insert("PYTHONIOENCODING".to_owned(), "gbk".to_owned());
|
||||
|
||||
let prepared = prepare_script_environment(
|
||||
temp.path().to_str().unwrap(),
|
||||
"python.exe",
|
||||
None,
|
||||
&user_env,
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
assert_eq!(
|
||||
prepared.environment.get("PYTHONIOENCODING"),
|
||||
Some(&"gbk".to_owned())
|
||||
);
|
||||
}
|
||||
|
||||
fn probe(working_dir: &str, interpreter: &str, venv: Option<&str>) -> VenvProbeResponse {
|
||||
probe_venv(&VenvProbeRequest {
|
||||
working_dir: working_dir.to_owned(),
|
||||
|
||||
+177
-16
@@ -16,7 +16,7 @@ use reqwest::Method;
|
||||
use serde::Serialize;
|
||||
use sqlx::SqlitePool;
|
||||
use tokio::{
|
||||
io::{AsyncBufReadExt, AsyncRead, AsyncWriteExt, BufReader},
|
||||
io::{AsyncBufRead, AsyncBufReadExt, AsyncRead, AsyncWriteExt, BufReader},
|
||||
process::{Child, Command},
|
||||
sync::{Mutex as AsyncMutex, broadcast},
|
||||
time::{Duration, sleep, timeout},
|
||||
@@ -35,6 +35,7 @@ use crate::{
|
||||
};
|
||||
|
||||
const MAX_LOG_BYTES: u64 = 5 * 1024 * 1024;
|
||||
const MAX_LOG_LINE_BYTES: usize = 256 * 1024;
|
||||
const MAX_HTTP_LOG_BODY_BYTES: usize = 64 * 1024;
|
||||
const TERMINATION_WAIT_SECS: u64 = 5;
|
||||
const CREATE_NO_WINDOW: u32 = 0x0800_0000;
|
||||
@@ -255,7 +256,7 @@ impl TaskExecutor {
|
||||
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);
|
||||
remove_running_if(&self.inner.running, &task.id, None);
|
||||
return Err(error);
|
||||
}
|
||||
};
|
||||
@@ -301,6 +302,11 @@ impl TaskExecutor {
|
||||
self.inner.events.subscribe()
|
||||
}
|
||||
|
||||
/// 当前正在执行的任务数量(用于统计接口)。
|
||||
pub fn running_count(&self) -> usize {
|
||||
lock_running(&self.inner.running).len()
|
||||
}
|
||||
|
||||
pub fn log_path(&self, task_id: &str, run_id: i64) -> PathBuf {
|
||||
self.inner
|
||||
.logs_dir
|
||||
@@ -320,18 +326,44 @@ impl TaskExecutor {
|
||||
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))
|
||||
let active = self.active_log_paths();
|
||||
tokio::task::spawn_blocking(move || cleanup_logs_sync(&logs_dir, &config, &active))
|
||||
.await
|
||||
.map_err(std::io::Error::other)?
|
||||
}
|
||||
|
||||
fn active_log_paths(&self) -> std::collections::HashSet<PathBuf> {
|
||||
lock_running(&self.inner.running)
|
||||
.iter()
|
||||
.filter_map(|(task_id, handle)| {
|
||||
handle.run_id.map(|run_id| {
|
||||
self.inner
|
||||
.logs_dir
|
||||
.join(task_id)
|
||||
.join(format!("{run_id}.log"))
|
||||
})
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
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;
|
||||
match repository::mark_running(&self.inner.pool, run_id, started_at).await {
|
||||
Ok(true) => {}
|
||||
Ok(false) => {
|
||||
tracing::warn!(
|
||||
run_id,
|
||||
"run row disappeared before it could be marked running"
|
||||
);
|
||||
remove_running_if(&self.inner.running, &task.id, Some(run_id));
|
||||
return;
|
||||
}
|
||||
Err(error) => {
|
||||
tracing::error!(run_id, error = %error, "failed to mark task run as running");
|
||||
remove_running_if(&self.inner.running, &task.id, Some(run_id));
|
||||
return;
|
||||
}
|
||||
}
|
||||
self.emit_run_by_id(run_id).await;
|
||||
|
||||
@@ -339,7 +371,7 @@ impl TaskExecutor {
|
||||
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(
|
||||
match repository::complete(
|
||||
&self.inner.pool,
|
||||
run_id,
|
||||
outcome.status,
|
||||
@@ -350,11 +382,19 @@ impl TaskExecutor {
|
||||
)
|
||||
.await
|
||||
{
|
||||
tracing::error!(run_id, error = %error, "failed to complete task run");
|
||||
Ok(true) => {}
|
||||
Ok(false) => {
|
||||
tracing::warn!(
|
||||
run_id,
|
||||
"run row disappeared before completion could be recorded"
|
||||
);
|
||||
}
|
||||
Err(error) => 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);
|
||||
// 只清理属于自己的句柄:取消后立刻重跑时,旧 run 收尾不能删掉新 run 的闸门。
|
||||
remove_running_if(&self.inner.running, &task.id, Some(run_id));
|
||||
if let Err(error) = self.cleanup_logs().await {
|
||||
tracing::warn!(%error, "failed to clean task logs");
|
||||
}
|
||||
@@ -852,6 +892,21 @@ fn lock_running(
|
||||
.unwrap_or_else(|poisoned| poisoned.into_inner())
|
||||
}
|
||||
|
||||
/// 只在句柄仍属于指定 run 时移除;`None` 表示"刚插入、还没拿到 run id"的占位句柄。
|
||||
fn remove_running_if(
|
||||
running: &Mutex<HashMap<String, RunningHandle>>,
|
||||
task_id: &str,
|
||||
run_id: Option<i64>,
|
||||
) {
|
||||
let mut running = lock_running(running);
|
||||
if running
|
||||
.get(task_id)
|
||||
.is_some_and(|handle| handle.run_id == run_id)
|
||||
{
|
||||
running.remove(task_id);
|
||||
}
|
||||
}
|
||||
|
||||
fn spawn_log_reader<R>(
|
||||
reader: R,
|
||||
stream: &'static str,
|
||||
@@ -864,12 +919,17 @@ where
|
||||
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(_) => {
|
||||
match read_line_capped(&mut reader, &mut line, MAX_LOG_LINE_BYTES).await {
|
||||
Ok((0, _)) => break,
|
||||
Ok((_, truncated)) => {
|
||||
let text = sink.decode_output(&line);
|
||||
sink.append(&format!("[{stream}] {text}")).await;
|
||||
let mut payload = format!("[{stream}] {text}");
|
||||
if truncated {
|
||||
payload.push_str(&format!(
|
||||
"\n[system] 单行输出超过 {MAX_LOG_LINE_BYTES} 字节,已截断\n"
|
||||
));
|
||||
}
|
||||
sink.append(&payload).await;
|
||||
}
|
||||
Err(error) => {
|
||||
tracing::warn!(stream, %error, "failed to read task output");
|
||||
@@ -880,6 +940,38 @@ where
|
||||
})
|
||||
}
|
||||
|
||||
/// 读取一行,最多保留 `max` 字节。超出上限时丢弃剩余部分但继续读到换行,
|
||||
/// 既保住内存上限,也不会让后续输出错位。
|
||||
async fn read_line_capped<R>(
|
||||
reader: &mut R,
|
||||
buffer: &mut Vec<u8>,
|
||||
max: usize,
|
||||
) -> std::io::Result<(usize, bool)>
|
||||
where
|
||||
R: AsyncBufRead + Unpin,
|
||||
{
|
||||
buffer.clear();
|
||||
let mut truncated = false;
|
||||
loop {
|
||||
let chunk = reader.fill_buf().await?;
|
||||
if chunk.is_empty() {
|
||||
return Ok((buffer.len(), truncated));
|
||||
}
|
||||
let newline = chunk.iter().position(|byte| *byte == b'\n');
|
||||
let take = newline.map_or(chunk.len(), |index| index + 1);
|
||||
let remaining = max.saturating_sub(buffer.len());
|
||||
let copied = take.min(remaining);
|
||||
buffer.extend_from_slice(&chunk[..copied]);
|
||||
if copied < take {
|
||||
truncated = true;
|
||||
}
|
||||
reader.consume(take);
|
||||
if newline.is_some() {
|
||||
return Ok((buffer.len(), truncated));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async fn wait_after_termination(child: &mut Child) {
|
||||
if timeout(Duration::from_secs(TERMINATION_WAIT_SECS), child.wait())
|
||||
.await
|
||||
@@ -935,13 +1027,19 @@ fn failed_outcome(message: impl Into<String>) -> ScriptOutcome {
|
||||
}
|
||||
}
|
||||
|
||||
fn cleanup_logs_sync(root: &Path, config: &LogRetentionConfig) -> std::io::Result<()> {
|
||||
fn cleanup_logs_sync(
|
||||
root: &Path,
|
||||
config: &LogRetentionConfig,
|
||||
active: &std::collections::HashSet<PathBuf>,
|
||||
) -> std::io::Result<()> {
|
||||
if !root.exists() {
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
let mut files = Vec::new();
|
||||
collect_log_files(root, &mut files)?;
|
||||
// 正在写入的日志不参与保留期/容量清理。
|
||||
files.retain(|(path, _)| !active.contains(path));
|
||||
let retention = Duration::from_secs(config.retention_days.saturating_mul(86_400));
|
||||
let cutoff = SystemTime::now().checked_sub(retention);
|
||||
|
||||
@@ -1104,6 +1202,7 @@ mod tests {
|
||||
retention_days: 30,
|
||||
max_total_bytes: 10,
|
||||
},
|
||||
&std::collections::HashSet::new(),
|
||||
)
|
||||
.unwrap();
|
||||
let total = std::fs::read_dir(temp.path())
|
||||
@@ -1112,4 +1211,66 @@ mod tests {
|
||||
.sum::<u64>();
|
||||
assert!(total <= 10);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn cleanup_skips_logs_of_running_tasks() {
|
||||
let temp = tempfile::TempDir::new().unwrap();
|
||||
let active = temp.path().join("active.log");
|
||||
std::fs::write(&active, vec![b'x'; 32]).unwrap();
|
||||
std::thread::sleep(std::time::Duration::from_millis(5));
|
||||
let stale = temp.path().join("stale.log");
|
||||
std::fs::write(&stale, vec![b'x'; 32]).unwrap();
|
||||
|
||||
let mut protected = std::collections::HashSet::new();
|
||||
protected.insert(active.clone());
|
||||
cleanup_logs_sync(
|
||||
temp.path(),
|
||||
&LogRetentionConfig {
|
||||
retention_days: 0,
|
||||
max_total_bytes: 1,
|
||||
},
|
||||
&protected,
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
assert!(active.exists(), "active log must survive cleanup");
|
||||
assert!(!stale.exists(), "stale log should be removed");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn capped_line_reader_truncates_but_keeps_reading() {
|
||||
let payload = [b'a'; 64];
|
||||
let mut reader = BufReader::new(&payload[..]);
|
||||
let mut buffer = Vec::new();
|
||||
|
||||
let (bytes, truncated) = read_line_capped(&mut reader, &mut buffer, 16)
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
assert_eq!(bytes, 16);
|
||||
assert!(truncated);
|
||||
|
||||
let (next, next_truncated) = read_line_capped(&mut reader, &mut buffer, 16)
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(next, 0);
|
||||
assert!(!next_truncated);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn removes_only_the_matching_run_handle() {
|
||||
let running = Mutex::new(HashMap::from([(
|
||||
"task".to_owned(),
|
||||
RunningHandle {
|
||||
run_id: Some(2),
|
||||
cancellation: CancellationToken::new(),
|
||||
},
|
||||
)]));
|
||||
|
||||
remove_running_if(&running, "task", Some(1));
|
||||
assert!(lock_running(&running).contains_key("task"));
|
||||
|
||||
remove_running_if(&running, "task", Some(2));
|
||||
assert!(!lock_running(&running).contains_key("task"));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -119,20 +119,22 @@ pub async fn create_skipped(
|
||||
})
|
||||
}
|
||||
|
||||
/// 返回是否真的更新到了运行记录;记录被删除(例如任务被删)时返回 false。
|
||||
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 = ?")
|
||||
) -> ApiResult<bool> {
|
||||
let result = 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(())
|
||||
Ok(result.rows_affected() > 0)
|
||||
}
|
||||
|
||||
/// 返回是否真的更新到了运行记录。
|
||||
pub async fn complete(
|
||||
pool: &SqlitePool,
|
||||
run_id: i64,
|
||||
@@ -141,8 +143,8 @@ pub async fn complete(
|
||||
duration_ms: u64,
|
||||
exit_code: Option<i32>,
|
||||
error_message: Option<&str>,
|
||||
) -> ApiResult<()> {
|
||||
sqlx::query(
|
||||
) -> ApiResult<bool> {
|
||||
let result = sqlx::query(
|
||||
"UPDATE task_runs SET
|
||||
status = ?, finished_at = ?, duration_ms = ?,
|
||||
exit_code = ?, error_message = ?
|
||||
@@ -156,7 +158,22 @@ pub async fn complete(
|
||||
.bind(run_id)
|
||||
.execute(pool)
|
||||
.await?;
|
||||
Ok(())
|
||||
Ok(result.rows_affected() > 0)
|
||||
}
|
||||
|
||||
/// 启动时收敛上一次进程遗留下来的非终态运行,避免界面永远显示"运行中"。
|
||||
pub async fn fail_interrupted_runs(pool: &SqlitePool) -> ApiResult<u64> {
|
||||
let result = sqlx::query(
|
||||
"UPDATE task_runs SET status = ?, finished_at = ?, error_message = ?
|
||||
WHERE status IN ('queued', 'running')",
|
||||
)
|
||||
.bind(RunStatus::Cancelled.as_str())
|
||||
.bind(Utc::now())
|
||||
.bind("TaskPilot 进程重启,运行被中断")
|
||||
.execute(pool)
|
||||
.await?;
|
||||
|
||||
Ok(result.rows_affected())
|
||||
}
|
||||
|
||||
pub async fn get(pool: &SqlitePool, run_id: i64) -> ApiResult<Option<TaskRunResponse>> {
|
||||
|
||||
+50
-12
@@ -14,18 +14,23 @@ use tokio_util::sync::CancellationToken;
|
||||
use crate::{
|
||||
error::ApiResult,
|
||||
runs::{executor::TaskExecutor, model::RunTrigger},
|
||||
tasks::{model::TaskAction, model::TaskResponse, repository},
|
||||
tasks::{model::TaskResponse, repository},
|
||||
};
|
||||
|
||||
struct ScheduledTask {
|
||||
cancellation: CancellationToken,
|
||||
handle: JoinHandle<()>,
|
||||
/// 只用来持有任务;取消时通过令牌让循环自行退出,不直接 abort。
|
||||
/// 字段以下划线开头是因为它只承担"保持任务存活"的作用。
|
||||
_handle: JoinHandle<()>,
|
||||
}
|
||||
|
||||
type NextRuns = Arc<Mutex<HashMap<String, DateTime<Utc>>>>;
|
||||
|
||||
struct SchedulerInner {
|
||||
executor: TaskExecutor,
|
||||
shutdown: CancellationToken,
|
||||
jobs: Mutex<HashMap<String, ScheduledTask>>,
|
||||
next_runs: NextRuns,
|
||||
}
|
||||
|
||||
#[derive(Clone)]
|
||||
@@ -40,19 +45,22 @@ impl Scheduler {
|
||||
executor,
|
||||
shutdown: CancellationToken::new(),
|
||||
jobs: Mutex::new(HashMap::new()),
|
||||
next_runs: Arc::new(Mutex::new(HashMap::new())),
|
||||
}),
|
||||
};
|
||||
|
||||
for task in repository::list_enabled_script_tasks(&pool).await? {
|
||||
for task in repository::list_enabled_tasks(&pool).await? {
|
||||
scheduler.refresh(task);
|
||||
}
|
||||
|
||||
Ok(scheduler)
|
||||
}
|
||||
|
||||
/// 重新注册任务:停用会取消调度,启用则按 cron 新建循环。
|
||||
/// 三类 Action 都参与调度,执行器本身负责区分脚本、HTTP 与进程。
|
||||
pub fn refresh(&self, task: TaskResponse) {
|
||||
self.cancel(&task.id);
|
||||
if !task.enabled || !matches!(task.action, TaskAction::Script { .. }) {
|
||||
if !task.enabled {
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -61,38 +69,52 @@ impl Scheduler {
|
||||
let executor = self.inner.executor.clone();
|
||||
let shutdown = self.inner.shutdown.clone();
|
||||
let cancellation_for_loop = cancellation.clone();
|
||||
let next_runs = Arc::clone(&self.inner.next_runs);
|
||||
let handle = tokio::spawn(async move {
|
||||
schedule_loop(task_for_loop, executor, cancellation_for_loop, shutdown).await;
|
||||
schedule_loop(
|
||||
task_for_loop,
|
||||
executor,
|
||||
cancellation_for_loop,
|
||||
shutdown,
|
||||
next_runs,
|
||||
)
|
||||
.await;
|
||||
});
|
||||
|
||||
lock_jobs(&self.inner.jobs).insert(
|
||||
task.id,
|
||||
ScheduledTask {
|
||||
cancellation,
|
||||
handle,
|
||||
_handle: handle,
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
/// 取消注册。循环会在下一个 select 点退出,不会留下半途的 running 记录。
|
||||
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();
|
||||
}
|
||||
lock_next_runs(&self.inner.next_runs).remove(task_id);
|
||||
}
|
||||
|
||||
pub fn is_scheduled(&self, task_id: &str) -> bool {
|
||||
lock_jobs(&self.inner.jobs).contains_key(task_id)
|
||||
}
|
||||
|
||||
/// 下次触发时间;未注册或刚取消时返回 None。
|
||||
pub fn next_run_at(&self, task_id: &str) -> Option<DateTime<Utc>> {
|
||||
lock_next_runs(&self.inner.next_runs).get(task_id).copied()
|
||||
}
|
||||
|
||||
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();
|
||||
lock_next_runs(&self.inner.next_runs).clear();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -105,7 +127,6 @@ impl Drop for SchedulerInner {
|
||||
.unwrap_or_else(|poisoned| poisoned.into_inner());
|
||||
for job in jobs.values() {
|
||||
job.cancellation.cancel();
|
||||
job.handle.abort();
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -115,6 +136,7 @@ async fn schedule_loop(
|
||||
executor: TaskExecutor,
|
||||
cancellation: CancellationToken,
|
||||
shutdown: CancellationToken,
|
||||
next_runs: NextRuns,
|
||||
) {
|
||||
loop {
|
||||
let now = Utc::now();
|
||||
@@ -122,14 +144,22 @@ async fn schedule_loop(
|
||||
Ok(next) => next,
|
||||
Err(error) => {
|
||||
tracing::error!(task_id = %task.id, %error, "cron schedule calculation failed");
|
||||
lock_next_runs(&next_runs).remove(&task.id);
|
||||
return;
|
||||
}
|
||||
};
|
||||
lock_next_runs(&next_runs).insert(task.id.clone(), next);
|
||||
let delay = (next - now).to_std().unwrap_or_default();
|
||||
|
||||
tokio::select! {
|
||||
_ = cancellation.cancelled() => return,
|
||||
_ = shutdown.cancelled() => return,
|
||||
_ = cancellation.cancelled() => {
|
||||
lock_next_runs(&next_runs).remove(&task.id);
|
||||
return;
|
||||
}
|
||||
_ = shutdown.cancelled() => {
|
||||
lock_next_runs(&next_runs).remove(&task.id);
|
||||
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");
|
||||
@@ -156,11 +186,19 @@ fn lock_jobs(
|
||||
jobs.lock().unwrap_or_else(|poisoned| poisoned.into_inner())
|
||||
}
|
||||
|
||||
fn lock_next_runs(
|
||||
next_runs: &Mutex<HashMap<String, DateTime<Utc>>>,
|
||||
) -> MutexGuard<'_, HashMap<String, DateTime<Utc>>> {
|
||||
next_runs
|
||||
.lock()
|
||||
.unwrap_or_else(|poisoned| poisoned.into_inner())
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use chrono::{TimeZone, Timelike};
|
||||
|
||||
use crate::tasks::model::{ConcurrencyPolicy, RetryPolicy};
|
||||
use crate::tasks::model::{ConcurrencyPolicy, RetryPolicy, TaskAction};
|
||||
|
||||
use super::*;
|
||||
|
||||
|
||||
@@ -33,6 +33,13 @@ impl AppState {
|
||||
log_config.max_total_bytes = megabytes.saturating_mul(1024 * 1024);
|
||||
}
|
||||
let executor = TaskExecutor::new(pool.clone(), logs_dir, log_config);
|
||||
let reconciled = crate::runs::repository::fail_interrupted_runs(&pool).await?;
|
||||
if reconciled > 0 {
|
||||
tracing::warn!(
|
||||
count = reconciled,
|
||||
"marked runs interrupted by the previous shutdown as cancelled"
|
||||
);
|
||||
}
|
||||
executor.cleanup_logs().await.map_err(ApiError::internal)?;
|
||||
let scheduler = Scheduler::start(pool.clone(), executor.clone()).await?;
|
||||
|
||||
|
||||
@@ -176,3 +176,28 @@ pub struct ListTasksQuery {
|
||||
pub page: Option<u32>,
|
||||
pub page_size: Option<u32>,
|
||||
}
|
||||
|
||||
/// 仪表盘指标:一次请求拿到全部计数,避免为每个指标各发一次列表查询。
|
||||
#[derive(Debug, Serialize, ToSchema)]
|
||||
pub struct TaskStatsResponse {
|
||||
/// 任务总数
|
||||
pub total: u64,
|
||||
/// 已启用任务数
|
||||
pub enabled: u64,
|
||||
/// 当前正在执行的任务数
|
||||
pub running: u64,
|
||||
/// 脚本类任务数
|
||||
pub script: u64,
|
||||
/// HTTP 类任务数
|
||||
pub http: u64,
|
||||
/// 进程类任务数
|
||||
pub process: u64,
|
||||
}
|
||||
|
||||
/// 已注册调度的任务及其下次触发时间。
|
||||
#[derive(Debug, Serialize, ToSchema)]
|
||||
pub struct ScheduledTaskResponse {
|
||||
pub task_id: String,
|
||||
/// 下次触发时间;调度正在重建时可能为空
|
||||
pub next_run_at: Option<DateTime<Utc>>,
|
||||
}
|
||||
|
||||
@@ -15,6 +15,38 @@ const TASK_COLUMNS: &str = "
|
||||
created_at, updated_at
|
||||
";
|
||||
|
||||
#[derive(Debug, Clone, Copy, Default)]
|
||||
pub struct TaskCounts {
|
||||
pub total: u64,
|
||||
pub enabled: u64,
|
||||
pub script: u64,
|
||||
pub http: u64,
|
||||
pub process: u64,
|
||||
}
|
||||
|
||||
/// 单条 SQL 统计全部指标,供仪表盘使用。
|
||||
pub async fn counts(pool: &SqlitePool) -> ApiResult<TaskCounts> {
|
||||
let (total, enabled, script, http, process): (i64, i64, i64, i64, i64) = sqlx::query_as(
|
||||
"SELECT
|
||||
COUNT(*),
|
||||
COALESCE(SUM(enabled), 0),
|
||||
COALESCE(SUM(action_type = 'script'), 0),
|
||||
COALESCE(SUM(action_type = 'http'), 0),
|
||||
COALESCE(SUM(action_type = 'process'), 0)
|
||||
FROM tasks",
|
||||
)
|
||||
.fetch_one(pool)
|
||||
.await?;
|
||||
|
||||
Ok(TaskCounts {
|
||||
total: u64::try_from(total).unwrap_or_default(),
|
||||
enabled: u64::try_from(enabled).unwrap_or_default(),
|
||||
script: u64::try_from(script).unwrap_or_default(),
|
||||
http: u64::try_from(http).unwrap_or_default(),
|
||||
process: u64::try_from(process).unwrap_or_default(),
|
||||
})
|
||||
}
|
||||
|
||||
pub struct ListFilters {
|
||||
pub q: Option<String>,
|
||||
pub enabled: Option<bool>,
|
||||
@@ -233,14 +265,15 @@ pub async fn list(pool: &SqlitePool, filters: &ListFilters) -> ApiResult<TaskLis
|
||||
})
|
||||
}
|
||||
|
||||
pub async fn list_enabled_script_tasks(pool: &SqlitePool) -> ApiResult<Vec<TaskResponse>> {
|
||||
/// 启动时恢复调度用的列表:所有启用任务都参与调度,不再只限脚本。
|
||||
pub async fn list_enabled_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'
|
||||
WHERE enabled = 1
|
||||
ORDER BY updated_at DESC, id ASC",
|
||||
)
|
||||
.fetch_all(pool)
|
||||
|
||||
+148
-2
@@ -573,7 +573,12 @@ async fn overlapping_trigger_is_skipped_and_timeout_kills_process_tree() {
|
||||
&working_dir,
|
||||
"powershell.exe",
|
||||
"-NoProfile",
|
||||
vec!["-File", &script_path.to_string_lossy()],
|
||||
vec![
|
||||
"-ExecutionPolicy",
|
||||
"Bypass",
|
||||
"-File",
|
||||
&script_path.to_string_lossy(),
|
||||
],
|
||||
"0 0 2 * * *",
|
||||
1,
|
||||
);
|
||||
@@ -603,7 +608,10 @@ async fn overlapping_trigger_is_skipped_and_timeout_kills_process_tree() {
|
||||
assert_eq!(second_run["status"], "skipped");
|
||||
|
||||
let completed = wait_for_run(&app.router, first_run_id).await;
|
||||
assert_eq!(completed["status"], "timed_out");
|
||||
assert_eq!(
|
||||
completed["status"], "timed_out",
|
||||
"unexpected run: {completed}"
|
||||
);
|
||||
assert!(
|
||||
completed["error_message"]
|
||||
.as_str()
|
||||
@@ -821,3 +829,141 @@ async fn long_utf8_log_is_reassembled_across_chunk_boundaries() {
|
||||
);
|
||||
app.state.shutdown();
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn stats_endpoint_aggregates_task_counts() {
|
||||
let app = test_app().await;
|
||||
let working_dir = app._temp_dir.path().to_string_lossy().into_owned();
|
||||
let script = script_task(
|
||||
"stats_script",
|
||||
&working_dir,
|
||||
"cmd.exe",
|
||||
"/C",
|
||||
vec!["exit", "0"],
|
||||
"0 0 2 * * *",
|
||||
5,
|
||||
);
|
||||
let http = http_task("http://127.0.0.1:9/never");
|
||||
for payload in [script.clone(), http.clone()] {
|
||||
let response = send_json(&app.router, Method::POST, "/api/v1/tasks", Some(payload)).await;
|
||||
assert_eq!(response.status(), StatusCode::CREATED);
|
||||
}
|
||||
|
||||
let response = send_json(&app.router, Method::GET, "/api/v1/stats", None).await;
|
||||
assert_eq!(response.status(), StatusCode::OK);
|
||||
let stats = response_json(response).await;
|
||||
|
||||
assert_eq!(stats["total"], 2);
|
||||
assert_eq!(stats["enabled"], 2);
|
||||
assert_eq!(stats["script"], 1);
|
||||
assert_eq!(stats["http"], 1);
|
||||
assert_eq!(stats["process"], 0);
|
||||
assert_eq!(stats["running"], 0);
|
||||
app.state.shutdown();
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn schedule_endpoint_reports_next_run_for_enabled_tasks() {
|
||||
let app = test_app().await;
|
||||
let working_dir = app._temp_dir.path().to_string_lossy().into_owned();
|
||||
let task = script_task(
|
||||
"scheduled_stats",
|
||||
&working_dir,
|
||||
"cmd.exe",
|
||||
"/C",
|
||||
vec!["exit", "0"],
|
||||
"*/30 * * * * *",
|
||||
5,
|
||||
);
|
||||
let created = send_json(&app.router, Method::POST, "/api/v1/tasks", Some(task)).await;
|
||||
assert_eq!(created.status(), StatusCode::CREATED);
|
||||
|
||||
let response = send_json(&app.router, Method::GET, "/api/v1/schedule", None).await;
|
||||
assert_eq!(response.status(), StatusCode::OK);
|
||||
let schedule = response_json(response).await;
|
||||
let entries = schedule.as_array().expect("schedule should be an array");
|
||||
let entry = entries
|
||||
.iter()
|
||||
.find(|item| item["task_id"] == "scheduled_stats")
|
||||
.expect("enabled task should appear in the schedule");
|
||||
|
||||
assert!(
|
||||
entry["next_run_at"].is_string(),
|
||||
"next_run_at should be computed for a registered task"
|
||||
);
|
||||
app.state.shutdown();
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn http_tasks_are_registered_for_scheduling() {
|
||||
let app = test_app().await;
|
||||
let task = http_task("http://127.0.0.1:9/never");
|
||||
let created = send_json(&app.router, Method::POST, "/api/v1/tasks", Some(task)).await;
|
||||
assert_eq!(created.status(), StatusCode::CREATED);
|
||||
|
||||
assert!(
|
||||
app.state.scheduler.is_scheduled("http_task"),
|
||||
"HTTP tasks must take part in scheduled runs"
|
||||
);
|
||||
|
||||
let disabled = send_json(
|
||||
&app.router,
|
||||
Method::POST,
|
||||
"/api/v1/tasks/http_task/disable",
|
||||
None,
|
||||
)
|
||||
.await;
|
||||
assert_eq!(disabled.status(), StatusCode::OK);
|
||||
assert!(!app.state.scheduler.is_scheduled("http_task"));
|
||||
app.state.shutdown();
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn startup_marks_runs_interrupted_by_restart_as_cancelled() {
|
||||
let app = test_app().await;
|
||||
let working_dir = app._temp_dir.path().to_string_lossy().into_owned();
|
||||
let task = script_task(
|
||||
"interrupted_task",
|
||||
&working_dir,
|
||||
"cmd.exe",
|
||||
"/C",
|
||||
vec!["exit", "0"],
|
||||
"0 0 2 * * *",
|
||||
5,
|
||||
);
|
||||
let created = send_json(&app.router, Method::POST, "/api/v1/tasks", Some(task)).await;
|
||||
assert_eq!(created.status(), StatusCode::CREATED);
|
||||
|
||||
// 模拟上一次进程被强杀后遗留的运行记录。
|
||||
sqlx::query(
|
||||
"INSERT INTO task_runs (task_id, trigger_type, status, created_at)
|
||||
VALUES ('interrupted_task', 'manual', 'running', ?)",
|
||||
)
|
||||
.bind(chrono::Utc::now())
|
||||
.execute(&app.state.pool)
|
||||
.await
|
||||
.expect("stale run should be inserted");
|
||||
|
||||
let reconciled = task_pilot_backend::runs::repository::fail_interrupted_runs(&app.state.pool)
|
||||
.await
|
||||
.expect("reconciliation should run");
|
||||
assert_eq!(reconciled, 1);
|
||||
|
||||
let runs = send_json(
|
||||
&app.router,
|
||||
Method::GET,
|
||||
"/api/v1/tasks/interrupted_task/runs",
|
||||
None,
|
||||
)
|
||||
.await;
|
||||
let body = response_json(runs).await;
|
||||
let item = &body["items"][0];
|
||||
assert_eq!(item["status"], "cancelled");
|
||||
assert!(
|
||||
item["error_message"]
|
||||
.as_str()
|
||||
.unwrap_or_default()
|
||||
.contains("重启")
|
||||
);
|
||||
app.state.shutdown();
|
||||
}
|
||||
|
||||
@@ -317,6 +317,29 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"/api/v1/schedule": {
|
||||
"get": {
|
||||
"tags": [
|
||||
"tasks"
|
||||
],
|
||||
"operationId": "list_schedule",
|
||||
"responses": {
|
||||
"200": {
|
||||
"description": "Next trigger time per enabled task",
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"type": "array",
|
||||
"items": {
|
||||
"$ref": "#/components/schemas/ScheduledTaskResponse"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"/api/v1/session": {
|
||||
"get": {
|
||||
"tags": [
|
||||
@@ -337,6 +360,26 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"/api/v1/stats": {
|
||||
"get": {
|
||||
"tags": [
|
||||
"tasks"
|
||||
],
|
||||
"operationId": "task_stats",
|
||||
"responses": {
|
||||
"200": {
|
||||
"description": "Dashboard counters",
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/TaskStatsResponse"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"/api/v1/system/directories": {
|
||||
"get": {
|
||||
"tags": [
|
||||
@@ -1088,6 +1131,26 @@
|
||||
"retry"
|
||||
]
|
||||
},
|
||||
"ScheduledTaskResponse": {
|
||||
"type": "object",
|
||||
"description": "已注册调度的任务及其下次触发时间。",
|
||||
"required": [
|
||||
"task_id"
|
||||
],
|
||||
"properties": {
|
||||
"next_run_at": {
|
||||
"type": [
|
||||
"string",
|
||||
"null"
|
||||
],
|
||||
"format": "date-time",
|
||||
"description": "下次触发时间;调度正在重建时可能为空"
|
||||
},
|
||||
"task_id": {
|
||||
"type": "string"
|
||||
}
|
||||
}
|
||||
},
|
||||
"SessionResponse": {
|
||||
"type": "object",
|
||||
"required": [
|
||||
@@ -1426,6 +1489,56 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"TaskStatsResponse": {
|
||||
"type": "object",
|
||||
"description": "仪表盘指标:一次请求拿到全部计数,避免为每个指标各发一次列表查询。",
|
||||
"required": [
|
||||
"total",
|
||||
"enabled",
|
||||
"running",
|
||||
"script",
|
||||
"http",
|
||||
"process"
|
||||
],
|
||||
"properties": {
|
||||
"enabled": {
|
||||
"type": "integer",
|
||||
"format": "int64",
|
||||
"description": "已启用任务数",
|
||||
"minimum": 0
|
||||
},
|
||||
"http": {
|
||||
"type": "integer",
|
||||
"format": "int64",
|
||||
"description": "HTTP 类任务数",
|
||||
"minimum": 0
|
||||
},
|
||||
"process": {
|
||||
"type": "integer",
|
||||
"format": "int64",
|
||||
"description": "进程类任务数",
|
||||
"minimum": 0
|
||||
},
|
||||
"running": {
|
||||
"type": "integer",
|
||||
"format": "int64",
|
||||
"description": "当前正在执行的任务数",
|
||||
"minimum": 0
|
||||
},
|
||||
"script": {
|
||||
"type": "integer",
|
||||
"format": "int64",
|
||||
"description": "脚本类任务数",
|
||||
"minimum": 0
|
||||
},
|
||||
"total": {
|
||||
"type": "integer",
|
||||
"format": "int64",
|
||||
"description": "任务总数",
|
||||
"minimum": 0
|
||||
}
|
||||
}
|
||||
},
|
||||
"UpdateTaskRequest": {
|
||||
"type": "object",
|
||||
"required": [
|
||||
|
||||
+13
-4
@@ -16,7 +16,7 @@
|
||||
|
||||
## 数据目录
|
||||
|
||||
- SQLite 数据库:`data/task_pilot.db`
|
||||
- SQLite 数据库:`data/task_pilot.db`(WAL 模式,运行中会额外出现 `-wal`/`-shm` 文件)
|
||||
- 运行日志:`logs/{task_id}/{run_id}.log`
|
||||
|
||||
## 配置
|
||||
@@ -33,16 +33,25 @@ $env:TASK_PILOT_STATIC_DIR = "web"
|
||||
|
||||
`TASK_PILOT_LOG_ENCODING` 默认 `auto`,会按 UTF-8 → 系统 ANSI 代码页(简体中文为 GBK)的顺序解码子进程输出;日志出现乱码时可显式指定 `gbk` 或 `utf-8`。
|
||||
|
||||
单次运行日志上限 5 MiB,单行上限 256 KiB;日志按保留期与总容量清理,正在写入的文件不会被删除。
|
||||
|
||||
## 安全边界
|
||||
|
||||
- 服务只监听 `127.0.0.1`。
|
||||
- 浏览器跨站 Origin 会被拒绝。
|
||||
- 写操作需要运行期随机管理令牌。
|
||||
- 管理令牌在进程重启后自动更换。
|
||||
- 写操作需要运行期随机管理令牌,进程重启后自动更换。
|
||||
- 请求体上限 2 MiB。
|
||||
|
||||
## 离线使用
|
||||
|
||||
前端资源、字体(`LXGW WenKai` / `Outfit-Medium`)与所有脚本都随包自带,不需要联网即可打开界面与执行任务。
|
||||
|
||||
## 故障排查
|
||||
|
||||
- 3000 端口被占用时,通过 `TASK_PILOT_PORT` 修改端口;打包版前端使用同源 API,无需重新构建。
|
||||
- 页面无法打开时,确认 `web/index.html` 存在。
|
||||
- 任务未执行时,确认任务已启用、Action 配置有效,并在日志抽屉查看错误。
|
||||
- 任务未执行时,确认任务已启用、Cron 正确,并在任务表的「调度周期」列查看下次触发时间。
|
||||
- PowerShell 脚本任务报「不是数字签名」时,是执行策略拦截未签名脚本:在参数里加 `-ExecutionPolicy Bypass`,或对脚本目录执行 `Unblock-File`。
|
||||
- 进程无法启动时,检查解释器或可执行文件路径、工作目录和执行权限。
|
||||
- 日志里出现 `����`:改用 UTF-8 输出,或设置 `TASK_PILOT_LOG_ENCODING=gbk`。
|
||||
- 进程被强杀后,遗留的运行会在下次启动时标记为「已取消(进程重启中断)」,不是数据损坏。
|
||||
|
||||
@@ -0,0 +1,42 @@
|
||||
# ADR-0006:视觉风格对齐主题并自托管字体
|
||||
|
||||
- 状态:已接受
|
||||
- 日期:2026-10-25
|
||||
|
||||
## 背景
|
||||
|
||||
产品要求前端视觉与参考站点(Alright / nicetheme)一致,包括字体、配色和交互,但不改变既有布局。参考站点使用 Bootstrap 5 + 自定义变量,正文依赖 CDN 的 `lxgw-wenkai-webfont`,标题使用自托管的 `Outfit-Medium`。
|
||||
|
||||
本项目的目标是"本机、离线可复现":依赖 CDN 会在断网、内网或 CDN 故障时导致字体回退,从而破坏视觉一致性。
|
||||
|
||||
## 决策
|
||||
|
||||
1. 从参考站点抓取其 CSS 的变量定义,把真实色值映射到本项目既有语义变量(`--primary`、`--text-main`、`--border`、`--success` 等),布局相关声明一律不改。
|
||||
2. 交互细节对齐主题:`0.3s ease-in-out` 过渡、主/危险按钮 hover 使用 `opacity: .85`、输入框聚焦使用主色半透明光环、选中项使用主色浅染。
|
||||
3. 字重遵循主题的 `--font-weight-bold: 500`;标题与指标数字使用 `Outfit-Medium`,正文使用 `LXGW WenKai`,等宽使用主题的 `SFMono-Regular…Consolas` 栈。
|
||||
4. 字体全部自托管:`Outfit-Medium.ttf` 直接取自主题;`LXGW WenKai` 由官方 webfont 的 97 个 woff2 子集合并后,再裁剪到 GB2312 + 常用符号子集,输出简体中文 Regular 与 Bold 两个文件。
|
||||
5. 移除所有 Google Fonts / CDN 字体链接,`index.html` 只 preload 本地字体文件。
|
||||
6. 新增端到端用例"renders fully offline":拦截并拒绝一切非 `127.0.0.1` 请求,断言页面正常渲染、`document.fonts.check` 命中两种字体且没有任何外部请求。
|
||||
|
||||
## 原因
|
||||
|
||||
- 直接用主题的变量值,颜色不会"看起来像"而是确实一致。
|
||||
- 字体子集化把 8.8 MB 的 194 个子集压到 6.7 MB 的两个文件,仓库与安装包体积可控,浏览器只需一次请求。
|
||||
- 离线断言把"可复现"变成可回归的测试,而不是口头承诺。
|
||||
|
||||
## 边界
|
||||
|
||||
- 字体子集覆盖 GB2312(6763 字)+ Latin + CJK 标点 + 全角符号;更生僻的汉字(GB18030 扩展区)会按浏览器逐字回退到系统字体,显示正常但字形风格不一致。
|
||||
- 只包含 400 / 700 两个字重,500 / 600 由 CSS 字重匹配落到 400 / 700;主题的 Light(300)未包含。
|
||||
- 未实现参考站点的暗色模式(`data-scheme="dark"`)。
|
||||
- 字体文件随 `frontend/public/fonts` 一起进入安装包,体积增加约 6.7 MB。
|
||||
|
||||
## 后续调整(2026-10-26)
|
||||
|
||||
仓库托管的远程仓库对单次推送体积有上限,6.7 MB 的字体二进制无法一次推上去。因此:
|
||||
|
||||
- 中文字体二进制 **不再纳入版本库**(`/frontend/public/fonts/LXGWWenKai-*.woff2` 已加入 `.gitignore`);
|
||||
- 新增 `tools/build-fonts.py`,一条命令即可从官方 CDN 重新下载、合并并子集化生成这两个文件;
|
||||
- `@font-face` 仍然保留,字体缺失时浏览器回退到系统字体,功能不受影响;
|
||||
- 对应的端到端用例改为「无外部网络渲染」:始终断言没有外部请求,只有字体文件存在时才断言字体命中;
|
||||
- `Outfit-Medium.ttf`(55 KB)体积很小,继续随仓库提供。
|
||||
@@ -0,0 +1,41 @@
|
||||
# ADR-0007:运行期可靠性优化
|
||||
|
||||
- 状态:已接受
|
||||
- 日期:2026-10-25
|
||||
|
||||
## 背景
|
||||
|
||||
代码评审与端到端自检暴露出五类问题,都会让"看起来正常"的功能在边界条件下失真:
|
||||
|
||||
1. 取消任务时执行器直接删除 `running` 句柄,而旧运行的收尾逻辑又无条件删除同任务句柄。取消后立刻重跑时,旧运行会把新运行的闸门删掉,跳过策略失效,同一任务可能并发执行。
|
||||
2. 调度器用 `JoinHandle::abort()` 取消循环。若恰好卡在 `executor.trigger()` 的数据库 await 中,`running` 表项和 `queued` 运行会永久残留,该任务此后永远被判定为"运行中"并跳过。
|
||||
3. 进程被强杀后,数据库里的 `queued`/`running` 运行没有对账,前端会一直显示"运行中"并持续轮询。
|
||||
4. `mark_running`/`complete` 不检查 `rows_affected`,运行记录被级联删除后收尾静默失效。
|
||||
5. 调度器只注册脚本任务,HTTP 与进程任务"启用"后返回 200 却永远不会被定时触发。
|
||||
|
||||
## 决策
|
||||
|
||||
1. 句柄带运行 ID,收尾使用 `remove_running_if(task_id, run_id)`:只有仍属于本次运行的句柄才会被移除;取消时依旧立即释放闸门,允许马上重跑。
|
||||
2. 调度循环只用 `CancellationToken` 退出,不再 `abort`;`ScheduledTask` 的 `JoinHandle` 仅用于保活。
|
||||
3. 启动时执行 `fail_interrupted_runs`:把遗留的 `queued`/`running` 统一改为 `cancelled`,并写入"进程重启,运行被中断"。
|
||||
4. `mark_running`/`complete` 返回是否更新成功,执行器对 0 行更新记警告并同步清理内存句柄。
|
||||
5. 三类 Action 统一参与调度:`list_enabled_tasks` 不再按 `action_type` 过滤,`Scheduler::refresh` 只判断 `enabled`。
|
||||
6. SQLite 启用 WAL + `busy_timeout(5s)` + `synchronous=NORMAL`,避免执行器写日志时前端查询撞上 `database is locked`。
|
||||
7. 日志清理跳过正在写入的文件;单行输出上限 256 KiB,超出后截断并继续读行尾。
|
||||
8. 请求体上限 2 MiB;新增 `/api/v1/stats`(一次查询拿到全部计数)与 `/api/v1/schedule`(下次触发时间)。
|
||||
9. 前端缓存会话令牌,写请求收到 403 时丢弃缓存并重试一次(覆盖后端重启轮换令牌)。
|
||||
10. Python 任务默认注入 `PYTHONIOENCODING=utf-8`(用户显式设置优先),`PYTHONUNBUFFERED=1` 保持强制。
|
||||
|
||||
## 原因
|
||||
|
||||
- 句柄竞态与 `abort` 泄漏都会造成"任务不再执行"且无任何报错,是排障成本最高的一类缺陷。
|
||||
- 对账让崩溃恢复变成确定行为,前端轮询也能正常收敛。
|
||||
- 统一调度消除了"启用但永不触发"的假能力。
|
||||
- 统计与调度接口把仪表盘从 4 次请求降到 2 次,并让"下次执行时间"成为可见信息。
|
||||
|
||||
## 边界
|
||||
|
||||
- 取消后立即可重跑,意味着旧进程的终止与新进程的启动可能短暂重叠(取决于 Job Object 终止速度)。
|
||||
- 没有全局并发上限:多个任务在同一时刻触发会各自启动一个进程。
|
||||
- 对账只针对 `queued`/`running`,不会尝试恢复重试或补偿错过的调度。
|
||||
- 单行截断后该行内容不完整,日志里会多一条 `[system]` 说明。
|
||||
@@ -221,3 +221,46 @@ test('cancels a running task from the log drawer', async ({
|
||||
await drawer.getByRole('button', { name: '取消运行' }).click()
|
||||
await expect(drawer.locator('.status-cancelled').first()).toBeVisible()
|
||||
})
|
||||
|
||||
test('renders without external network access', async ({ page }) => {
|
||||
const blocked: string[] = []
|
||||
await page.route('**/*', (route) => {
|
||||
const url = route.request().url()
|
||||
const isLocal =
|
||||
url.startsWith('http://127.0.0.1:5173') ||
|
||||
url.startsWith('http://localhost:5173') ||
|
||||
url.startsWith('data:') ||
|
||||
url.startsWith('blob:')
|
||||
if (isLocal) {
|
||||
return route.continue()
|
||||
}
|
||||
blocked.push(url)
|
||||
return route.abort()
|
||||
})
|
||||
|
||||
await page.goto('/')
|
||||
await expect(page.getByRole('heading', { name: 'TaskPilot' })).toBeVisible()
|
||||
|
||||
const state = await page.evaluate(async () => {
|
||||
// 字体二进制不入库;未生成时应回退系统字体,而不是报错。
|
||||
const probe = await fetch('/fonts/LXGWWenKai-Regular.woff2', {
|
||||
method: 'HEAD',
|
||||
})
|
||||
await document.fonts.ready
|
||||
return {
|
||||
wenkaiAvailable: probe.ok,
|
||||
wenkaiLoaded: document.fonts.check('14px "LXGW WenKai"'),
|
||||
displayLoaded: document.fonts.check('500 26px "Outfit-Medium"'),
|
||||
}
|
||||
})
|
||||
|
||||
expect(blocked).toEqual([])
|
||||
expect(state.displayLoaded).toBe(true)
|
||||
if (state.wenkaiAvailable) {
|
||||
expect(state.wenkaiLoaded).toBe(true)
|
||||
}
|
||||
await page.screenshot({
|
||||
path: 'test-results/dashboard-offline.png',
|
||||
fullPage: true,
|
||||
})
|
||||
})
|
||||
|
||||
+5
-4
@@ -7,11 +7,12 @@
|
||||
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"
|
||||
rel="preload"
|
||||
href="/fonts/Outfit-Medium.ttf"
|
||||
as="font"
|
||||
type="font/ttf"
|
||||
crossorigin
|
||||
/>
|
||||
<title>TaskPilot</title>
|
||||
</head>
|
||||
|
||||
Binary file not shown.
+79
-75
@@ -67,7 +67,7 @@ h3 {
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
min-height: 40px;
|
||||
border: 1px solid #d7f2e7;
|
||||
border: 1px solid var(--success-border);
|
||||
border-radius: 999px;
|
||||
padding: 0 14px;
|
||||
background: var(--success-bg);
|
||||
@@ -81,7 +81,7 @@ h3 {
|
||||
}
|
||||
|
||||
.connection-pill.is-offline {
|
||||
border-color: #ffd9df;
|
||||
border-color: var(--danger-border);
|
||||
background: var(--danger-bg);
|
||||
color: var(--danger);
|
||||
}
|
||||
@@ -92,16 +92,17 @@ h3 {
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
border: 1px solid transparent;
|
||||
border-radius: 8px;
|
||||
border-radius: var(--radius-sm);
|
||||
padding: 8px 16px;
|
||||
font-size: 13px;
|
||||
font-weight: 500;
|
||||
text-decoration: none;
|
||||
transition:
|
||||
background-color 150ms ease,
|
||||
border-color 150ms ease,
|
||||
color 150ms ease,
|
||||
transform 150ms ease;
|
||||
background-color var(--transition-base),
|
||||
border-color var(--transition-base),
|
||||
color var(--transition-base),
|
||||
transform var(--transition-base),
|
||||
opacity var(--transition-base);
|
||||
}
|
||||
|
||||
.button:hover:not(:disabled) {
|
||||
@@ -114,12 +115,13 @@ h3 {
|
||||
}
|
||||
|
||||
.button-primary {
|
||||
background: var(--text);
|
||||
background: var(--primary);
|
||||
color: #fff;
|
||||
}
|
||||
|
||||
.button-primary:hover:not(:disabled) {
|
||||
background: #293241;
|
||||
background: var(--primary);
|
||||
opacity: 0.85;
|
||||
}
|
||||
|
||||
.button-secondary {
|
||||
@@ -129,8 +131,8 @@ h3 {
|
||||
}
|
||||
|
||||
.button-secondary:hover:not(:disabled) {
|
||||
border-color: #c8ccd3;
|
||||
background: #f7f8fa;
|
||||
border-color: var(--text-light);
|
||||
background: var(--surface-muted);
|
||||
}
|
||||
|
||||
.button-danger {
|
||||
@@ -139,7 +141,8 @@ h3 {
|
||||
}
|
||||
|
||||
.button-danger:hover:not(:disabled) {
|
||||
background: #b91c3b;
|
||||
background: var(--danger);
|
||||
opacity: 0.85;
|
||||
}
|
||||
|
||||
.button-ghost {
|
||||
@@ -165,7 +168,7 @@ h3 {
|
||||
}
|
||||
|
||||
.text-button:hover {
|
||||
color: var(--text);
|
||||
color: var(--primary);
|
||||
}
|
||||
|
||||
.danger-text {
|
||||
@@ -178,10 +181,10 @@ h3 {
|
||||
right: 24px;
|
||||
bottom: 24px;
|
||||
max-width: min(420px, calc(100vw - 48px));
|
||||
border: 1px solid #2f3845;
|
||||
border: 1px solid var(--theme-secondary);
|
||||
border-radius: 12px;
|
||||
padding: 14px 18px;
|
||||
background: #111827;
|
||||
background: var(--theme-dark);
|
||||
color: #fff;
|
||||
box-shadow: 0 16px 48px rgba(15, 23, 42, 0.2);
|
||||
font-size: 13px;
|
||||
@@ -210,7 +213,7 @@ h3 {
|
||||
align-items: flex-start;
|
||||
justify-content: space-between;
|
||||
gap: 16px;
|
||||
border-color: #ffd7df;
|
||||
border-color: var(--danger-border);
|
||||
background: var(--danger-bg);
|
||||
color: var(--danger);
|
||||
}
|
||||
@@ -255,8 +258,9 @@ h3 {
|
||||
overflow: hidden;
|
||||
display: block;
|
||||
margin-top: 8px;
|
||||
font-family: var(--font-display);
|
||||
font-size: 26px;
|
||||
font-weight: 700;
|
||||
font-weight: 500;
|
||||
letter-spacing: -0.03em;
|
||||
line-height: 1.2;
|
||||
text-overflow: ellipsis;
|
||||
@@ -274,7 +278,7 @@ h3 {
|
||||
.card {
|
||||
overflow: hidden;
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 18px;
|
||||
border-radius: var(--radius-md);
|
||||
background: var(--surface);
|
||||
box-shadow: var(--shadow);
|
||||
}
|
||||
@@ -286,7 +290,7 @@ h3 {
|
||||
gap: 14px;
|
||||
border-bottom: 1px solid var(--border);
|
||||
padding: 12px 18px;
|
||||
background: #fafafa;
|
||||
background: var(--surface-muted);
|
||||
}
|
||||
|
||||
.search-form {
|
||||
@@ -316,24 +320,24 @@ h3 {
|
||||
width: 100%;
|
||||
min-height: 38px;
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 8px;
|
||||
border-radius: var(--radius-sm);
|
||||
outline: 0;
|
||||
padding: 8px 12px;
|
||||
font-size: 13px;
|
||||
background: var(--surface);
|
||||
color: var(--text);
|
||||
transition:
|
||||
border-color 150ms ease,
|
||||
box-shadow 150ms ease;
|
||||
border-color var(--transition-base),
|
||||
box-shadow var(--transition-base);
|
||||
}
|
||||
|
||||
.field-input:hover {
|
||||
border-color: #cfd3da;
|
||||
border-color: var(--text-light);
|
||||
}
|
||||
|
||||
.field-input:focus {
|
||||
border-color: var(--primary);
|
||||
box-shadow: 0 0 0 3px rgba(17, 24, 39, 0.06);
|
||||
box-shadow: 0 0 0 3px var(--theme-primary-soft);
|
||||
}
|
||||
|
||||
.field-input:disabled {
|
||||
@@ -358,7 +362,7 @@ code {
|
||||
border-radius: 6px;
|
||||
padding: 3px 8px;
|
||||
background: var(--badge-bg);
|
||||
color: #374151;
|
||||
color: var(--theme-secondary);
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
@@ -376,11 +380,11 @@ code {
|
||||
|
||||
.task-table th {
|
||||
padding: 14px 12px;
|
||||
background: #fafafa;
|
||||
background: var(--surface-muted);
|
||||
color: var(--text-tertiary);
|
||||
font-family: var(--font-sans);
|
||||
font-size: 11px;
|
||||
font-weight: 600;
|
||||
font-weight: 500;
|
||||
letter-spacing: 0.06em;
|
||||
text-transform: uppercase;
|
||||
white-space: nowrap;
|
||||
@@ -395,11 +399,11 @@ code {
|
||||
}
|
||||
|
||||
.task-table tbody tr {
|
||||
transition: background-color 120ms ease;
|
||||
transition: background-color var(--transition-base);
|
||||
}
|
||||
|
||||
.task-table tbody tr:hover {
|
||||
background: #fbfcfd;
|
||||
background: var(--surface-muted);
|
||||
}
|
||||
|
||||
.task-name {
|
||||
@@ -443,7 +447,7 @@ code {
|
||||
display: inline-flex;
|
||||
padding: 0;
|
||||
background: transparent;
|
||||
color: #4b5563;
|
||||
color: var(--text-muted);
|
||||
font-size: 12px;
|
||||
font-weight: 400;
|
||||
}
|
||||
@@ -455,7 +459,7 @@ code {
|
||||
border-radius: 999px;
|
||||
padding: 4px 10px;
|
||||
background: var(--badge-bg);
|
||||
color: #5d6470;
|
||||
color: var(--text-muted);
|
||||
font-size: 12px;
|
||||
font-weight: 500;
|
||||
white-space: nowrap;
|
||||
@@ -470,25 +474,25 @@ code {
|
||||
|
||||
.status-enabled,
|
||||
.status-succeeded {
|
||||
background: #ecfdf5;
|
||||
color: #067a56;
|
||||
background: var(--success-bg);
|
||||
color: #2b8a63;
|
||||
}
|
||||
|
||||
.status-disabled,
|
||||
.status-cancelled,
|
||||
.status-skipped {
|
||||
background: #f3f4f6;
|
||||
color: #6b7280;
|
||||
background: var(--badge-bg);
|
||||
color: var(--text-muted);
|
||||
}
|
||||
|
||||
.status-queued {
|
||||
background: #eff6ff;
|
||||
color: #2563eb;
|
||||
background: var(--primary-soft);
|
||||
color: var(--primary);
|
||||
}
|
||||
|
||||
.status-running {
|
||||
background: #fffbeb;
|
||||
color: #b45309;
|
||||
background: var(--warning-bg);
|
||||
color: var(--warning);
|
||||
}
|
||||
|
||||
.status-running .status-dot {
|
||||
@@ -588,7 +592,7 @@ code {
|
||||
max-height: calc(100vh - 48px);
|
||||
overflow: hidden;
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 18px;
|
||||
border-radius: var(--radius-lg);
|
||||
background: var(--surface);
|
||||
box-shadow: var(--shadow-modal);
|
||||
animation: modal-in 180ms ease-out;
|
||||
@@ -699,17 +703,17 @@ code {
|
||||
flex-direction: column;
|
||||
gap: 6px;
|
||||
overflow-y: auto;
|
||||
border: 1px solid #eeeff2;
|
||||
border-radius: 8px;
|
||||
border: 1px solid var(--border);
|
||||
border-radius: var(--radius-sm);
|
||||
padding: 10px 12px;
|
||||
background: #fafafa;
|
||||
background: var(--surface-muted);
|
||||
}
|
||||
|
||||
.cron-run-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
color: #374151;
|
||||
color: var(--theme-secondary);
|
||||
font-family: var(--font-mono);
|
||||
font-size: 12px;
|
||||
}
|
||||
@@ -722,7 +726,7 @@ code {
|
||||
}
|
||||
|
||||
.cron-run-time {
|
||||
border: 1px solid #e5e7eb;
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 4px;
|
||||
padding: 2px 8px;
|
||||
background: #fff;
|
||||
@@ -748,7 +752,7 @@ code {
|
||||
}
|
||||
|
||||
.field-label {
|
||||
color: #374151;
|
||||
color: var(--theme-secondary);
|
||||
font-size: 13px;
|
||||
font-weight: 600;
|
||||
}
|
||||
@@ -794,7 +798,7 @@ code {
|
||||
margin-top: -1px;
|
||||
padding: 8px 12px;
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 8px;
|
||||
border-radius: var(--radius-sm);
|
||||
background: var(--surface);
|
||||
font-size: 12px;
|
||||
}
|
||||
@@ -839,8 +843,8 @@ code {
|
||||
}
|
||||
|
||||
.field-input[aria-invalid='true'] {
|
||||
border-color: #f08aa0;
|
||||
background: #fffafb;
|
||||
border-color: rgba(255, 62, 62, 0.55);
|
||||
background: rgba(255, 62, 62, 0.04);
|
||||
}
|
||||
|
||||
.field-textarea {
|
||||
@@ -856,7 +860,7 @@ code {
|
||||
min-height: 48px;
|
||||
border: 1px solid var(--border);
|
||||
border: 0;
|
||||
border-radius: 8px;
|
||||
border-radius: var(--radius-sm);
|
||||
padding: 8px 0;
|
||||
background: transparent;
|
||||
cursor: pointer;
|
||||
@@ -887,10 +891,10 @@ code {
|
||||
|
||||
.dynamic-fields {
|
||||
margin: 22px 0 0;
|
||||
border: 1px solid #eeeff2;
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 12px;
|
||||
padding: 20px;
|
||||
background: #fafafa;
|
||||
background: var(--surface-muted);
|
||||
}
|
||||
|
||||
.collection-header {
|
||||
@@ -940,18 +944,18 @@ code {
|
||||
|
||||
.cli-preview {
|
||||
margin-top: 18px;
|
||||
border-radius: 8px;
|
||||
border-radius: var(--radius-sm);
|
||||
padding: 14px 18px;
|
||||
background: #18191c;
|
||||
background: var(--theme-dark);
|
||||
}
|
||||
|
||||
.cli-preview-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
margin-bottom: 8px;
|
||||
border-bottom: 1px solid #282a30;
|
||||
border-bottom: 1px solid rgba(255, 255, 255, 0.08);
|
||||
padding-bottom: 6px;
|
||||
color: #71767f;
|
||||
color: var(--text-tertiary);
|
||||
font-size: 11px;
|
||||
letter-spacing: 0.06em;
|
||||
text-transform: uppercase;
|
||||
@@ -965,11 +969,11 @@ code {
|
||||
|
||||
.cli-line-env {
|
||||
margin-bottom: 4px;
|
||||
color: #9da3af;
|
||||
color: var(--text-tertiary);
|
||||
}
|
||||
|
||||
.cli-line-cmd {
|
||||
color: #34d399;
|
||||
color: var(--success);
|
||||
font-weight: 500;
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
@@ -980,7 +984,7 @@ code {
|
||||
gap: 8px;
|
||||
border-top: 1px solid var(--border);
|
||||
padding: 16px 28px;
|
||||
background: #fafafb;
|
||||
background: var(--surface-muted);
|
||||
}
|
||||
|
||||
.confirm-card {
|
||||
@@ -1047,7 +1051,7 @@ code {
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
border: 0;
|
||||
border-radius: 8px;
|
||||
border-radius: var(--radius-sm);
|
||||
padding: 8px 10px;
|
||||
background: transparent;
|
||||
color: var(--text);
|
||||
@@ -1083,7 +1087,7 @@ code {
|
||||
gap: 16px;
|
||||
border-top: 1px solid var(--border);
|
||||
padding: 14px 24px;
|
||||
background: #fafafb;
|
||||
background: var(--surface-muted);
|
||||
}
|
||||
|
||||
.directory-picker-summary {
|
||||
@@ -1119,7 +1123,7 @@ code {
|
||||
flex-direction: column;
|
||||
overflow: hidden;
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 18px;
|
||||
border-radius: var(--radius-lg);
|
||||
background: var(--surface);
|
||||
box-shadow: var(--shadow-modal);
|
||||
animation: drawer-in 220ms ease-out;
|
||||
@@ -1158,7 +1162,7 @@ code {
|
||||
|
||||
.run-history-panel {
|
||||
border-right: 1px solid var(--border);
|
||||
background: #fafbfc;
|
||||
background: var(--surface-muted);
|
||||
}
|
||||
|
||||
.section-heading {
|
||||
@@ -1208,13 +1212,13 @@ code {
|
||||
}
|
||||
|
||||
.run-list-item:hover {
|
||||
background: #fff;
|
||||
background: var(--surface);
|
||||
}
|
||||
|
||||
.run-list-item.is-selected {
|
||||
border-color: var(--border);
|
||||
background: #fff;
|
||||
box-shadow: 0 4px 18px rgba(15, 23, 42, 0.04);
|
||||
border-color: var(--primary);
|
||||
background: var(--primary-soft);
|
||||
box-shadow: 0 4px 18px rgba(27, 26, 49, 0.06);
|
||||
}
|
||||
|
||||
.run-list-main {
|
||||
@@ -1261,35 +1265,35 @@ code {
|
||||
|
||||
.run-error-metadata {
|
||||
grid-column: 1 / -1;
|
||||
border-color: #ffd7df !important;
|
||||
border-color: var(--danger-border) !important;
|
||||
background: var(--danger-bg) !important;
|
||||
color: var(--danger);
|
||||
}
|
||||
|
||||
.log-viewer-wrap {
|
||||
overflow: hidden;
|
||||
border: 1px solid #252c38;
|
||||
border: 1px solid rgba(255, 255, 255, 0.08);
|
||||
border-radius: 14px;
|
||||
background: #0d1117;
|
||||
background: var(--theme-dark);
|
||||
}
|
||||
|
||||
.log-viewer-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
border-bottom: 1px solid #252c38;
|
||||
border-bottom: 1px solid rgba(255, 255, 255, 0.08);
|
||||
padding: 10px 14px;
|
||||
color: #aab4c3;
|
||||
color: var(--text-tertiary);
|
||||
font-family: var(--font-mono);
|
||||
font-size: 10px;
|
||||
}
|
||||
|
||||
.log-streaming {
|
||||
color: #fbbf24;
|
||||
color: var(--warning);
|
||||
}
|
||||
|
||||
.log-complete {
|
||||
color: #6ee7b7;
|
||||
color: var(--success);
|
||||
}
|
||||
|
||||
.log-viewer {
|
||||
@@ -1298,7 +1302,7 @@ code {
|
||||
overflow: auto;
|
||||
margin: 0;
|
||||
padding: 16px;
|
||||
color: #d1d5db;
|
||||
color: var(--text-light);
|
||||
font-family: var(--font-mono);
|
||||
font-size: 12px;
|
||||
line-height: 1.65;
|
||||
|
||||
@@ -68,6 +68,21 @@ function createFetchMock(options: FetchMockOptions = {}) {
|
||||
if (url.endsWith('/api/v1/session')) {
|
||||
return jsonResponse({ token: 'test-token' })
|
||||
}
|
||||
if (url.endsWith('/api/v1/stats')) {
|
||||
return jsonResponse({
|
||||
total: 1,
|
||||
enabled: 1,
|
||||
running: 0,
|
||||
script: 1,
|
||||
http: 0,
|
||||
process: 0,
|
||||
})
|
||||
}
|
||||
if (url.endsWith('/api/v1/schedule')) {
|
||||
return jsonResponse([
|
||||
{ task_id: 'echo_task', next_run_at: '2026-10-19T02:00:00Z' },
|
||||
])
|
||||
}
|
||||
if (url.endsWith('/api/v1/cron/preview')) {
|
||||
return jsonResponse({
|
||||
timezone: 'Asia/Shanghai',
|
||||
@@ -152,6 +167,13 @@ describe('TaskDashboard', () => {
|
||||
expect(await screen.findByText('在线')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('shows the next run time reported by the schedule endpoint', async () => {
|
||||
vi.stubGlobal('fetch', createFetchMock())
|
||||
renderApp()
|
||||
|
||||
expect(await screen.findByText(/下次/)).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('creates a script task with the visible form values', async () => {
|
||||
const fetchMock = createFetchMock()
|
||||
vi.stubGlobal('fetch', fetchMock)
|
||||
|
||||
+56
-18
@@ -24,6 +24,8 @@ export type InterpreterKind = components['schemas']['InterpreterKind']
|
||||
export type DirectoryEntry = components['schemas']['DirectoryEntry']
|
||||
export type DirectoryListingResponse =
|
||||
components['schemas']['DirectoryListingResponse']
|
||||
export type TaskStats = components['schemas']['TaskStatsResponse']
|
||||
export type ScheduledTask = components['schemas']['ScheduledTaskResponse']
|
||||
|
||||
export interface TaskFilters {
|
||||
q?: string
|
||||
@@ -77,20 +79,27 @@ 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 mutating = !['GET', 'HEAD', 'OPTIONS'].includes(method)
|
||||
|
||||
const send = async (): Promise<Response> => {
|
||||
const headers = new Headers(init.headers)
|
||||
headers.set('Accept', 'application/json')
|
||||
if (init.body) {
|
||||
headers.set('Content-Type', 'application/json')
|
||||
}
|
||||
if (mutating) {
|
||||
headers.set('X-TaskPilot-Token', await fetchSessionToken())
|
||||
}
|
||||
return fetch(path, { ...init, headers })
|
||||
}
|
||||
|
||||
const response = await fetch(path, {
|
||||
...init,
|
||||
headers,
|
||||
})
|
||||
let response = await send()
|
||||
if (mutating && response.status === 403) {
|
||||
// 后端重启会轮换管理令牌:丢弃缓存后重试一次。
|
||||
invalidateSessionToken()
|
||||
response = await send()
|
||||
}
|
||||
|
||||
if (!response.ok) {
|
||||
let body: ErrorResponse | undefined
|
||||
@@ -108,15 +117,34 @@ async function requestJson<T>(
|
||||
return (await response.json()) as T
|
||||
}
|
||||
|
||||
let cachedSessionToken: string | null = null
|
||||
let pendingSessionToken: Promise<string> | null = null
|
||||
|
||||
export function invalidateSessionToken(): void {
|
||||
cachedSessionToken = null
|
||||
}
|
||||
|
||||
async function fetchSessionToken(): Promise<string> {
|
||||
const response = await fetch('/api/v1/session', {
|
||||
headers: { Accept: 'application/json' },
|
||||
})
|
||||
if (!response.ok) {
|
||||
throw new ApiRequestError(response.status)
|
||||
if (cachedSessionToken) {
|
||||
return cachedSessionToken
|
||||
}
|
||||
const session = (await response.json()) as SessionResponse
|
||||
return session.token
|
||||
if (!pendingSessionToken) {
|
||||
pendingSessionToken = fetch('/api/v1/session', {
|
||||
headers: { Accept: 'application/json' },
|
||||
})
|
||||
.then(async (response) => {
|
||||
if (!response.ok) {
|
||||
throw new ApiRequestError(response.status)
|
||||
}
|
||||
const session = (await response.json()) as SessionResponse
|
||||
cachedSessionToken = session.token
|
||||
return session.token
|
||||
})
|
||||
.finally(() => {
|
||||
pendingSessionToken = null
|
||||
})
|
||||
}
|
||||
return pendingSessionToken
|
||||
}
|
||||
|
||||
function withQuery(path: string, values: object): string {
|
||||
@@ -265,3 +293,13 @@ export function listDirectories(
|
||||
{ signal },
|
||||
)
|
||||
}
|
||||
|
||||
/** 仪表盘计数:一次请求拿到全部指标。 */
|
||||
export function fetchStats(signal?: AbortSignal): Promise<TaskStats> {
|
||||
return requestJson<TaskStats>('/api/v1/stats', { signal })
|
||||
}
|
||||
|
||||
/** 已启用任务的下次触发时间。 */
|
||||
export function fetchSchedule(signal?: AbortSignal): Promise<ScheduledTask[]> {
|
||||
return requestJson<ScheduledTask[]>('/api/v1/schedule', { signal })
|
||||
}
|
||||
|
||||
@@ -132,6 +132,22 @@ export interface paths {
|
||||
patch?: never;
|
||||
trace?: never;
|
||||
};
|
||||
"/api/v1/schedule": {
|
||||
parameters: {
|
||||
query?: never;
|
||||
header?: never;
|
||||
path?: never;
|
||||
cookie?: never;
|
||||
};
|
||||
get: operations["list_schedule"];
|
||||
put?: never;
|
||||
post?: never;
|
||||
delete?: never;
|
||||
options?: never;
|
||||
head?: never;
|
||||
patch?: never;
|
||||
trace?: never;
|
||||
};
|
||||
"/api/v1/session": {
|
||||
parameters: {
|
||||
query?: never;
|
||||
@@ -148,6 +164,22 @@ export interface paths {
|
||||
patch?: never;
|
||||
trace?: never;
|
||||
};
|
||||
"/api/v1/stats": {
|
||||
parameters: {
|
||||
query?: never;
|
||||
header?: never;
|
||||
path?: never;
|
||||
cookie?: never;
|
||||
};
|
||||
get: operations["task_stats"];
|
||||
put?: never;
|
||||
post?: never;
|
||||
delete?: never;
|
||||
options?: never;
|
||||
head?: never;
|
||||
patch?: never;
|
||||
trace?: never;
|
||||
};
|
||||
"/api/v1/system/directories": {
|
||||
parameters: {
|
||||
query?: never;
|
||||
@@ -338,6 +370,15 @@ export interface components {
|
||||
RunStatus: "queued" | "running" | "succeeded" | "failed" | "timed_out" | "cancelled" | "skipped";
|
||||
/** @enum {string} */
|
||||
RunTrigger: "schedule" | "manual" | "retry";
|
||||
/** @description 已注册调度的任务及其下次触发时间。 */
|
||||
ScheduledTaskResponse: {
|
||||
/**
|
||||
* Format: date-time
|
||||
* @description 下次触发时间;调度正在重建时可能为空
|
||||
*/
|
||||
next_run_at?: string | null;
|
||||
task_id: string;
|
||||
};
|
||||
SessionResponse: {
|
||||
/** @description Random per-process token required for mutating browser requests. */
|
||||
token: string;
|
||||
@@ -427,6 +468,39 @@ export interface components {
|
||||
task_id: string;
|
||||
trigger: components["schemas"]["RunTrigger"];
|
||||
};
|
||||
/** @description 仪表盘指标:一次请求拿到全部计数,避免为每个指标各发一次列表查询。 */
|
||||
TaskStatsResponse: {
|
||||
/**
|
||||
* Format: int64
|
||||
* @description 已启用任务数
|
||||
*/
|
||||
enabled: number;
|
||||
/**
|
||||
* Format: int64
|
||||
* @description HTTP 类任务数
|
||||
*/
|
||||
http: number;
|
||||
/**
|
||||
* Format: int64
|
||||
* @description 进程类任务数
|
||||
*/
|
||||
process: number;
|
||||
/**
|
||||
* Format: int64
|
||||
* @description 当前正在执行的任务数
|
||||
*/
|
||||
running: number;
|
||||
/**
|
||||
* Format: int64
|
||||
* @description 脚本类任务数
|
||||
*/
|
||||
script: number;
|
||||
/**
|
||||
* Format: int64
|
||||
* @description 任务总数
|
||||
*/
|
||||
total: number;
|
||||
};
|
||||
UpdateTaskRequest: {
|
||||
action: components["schemas"]["TaskAction"];
|
||||
concurrency_policy?: components["schemas"]["ConcurrencyPolicy"];
|
||||
@@ -718,6 +792,26 @@ export interface operations {
|
||||
};
|
||||
};
|
||||
};
|
||||
list_schedule: {
|
||||
parameters: {
|
||||
query?: never;
|
||||
header?: never;
|
||||
path?: never;
|
||||
cookie?: never;
|
||||
};
|
||||
requestBody?: never;
|
||||
responses: {
|
||||
/** @description Next trigger time per enabled task */
|
||||
200: {
|
||||
headers: {
|
||||
[name: string]: unknown;
|
||||
};
|
||||
content: {
|
||||
"application/json": components["schemas"]["ScheduledTaskResponse"][];
|
||||
};
|
||||
};
|
||||
};
|
||||
};
|
||||
session: {
|
||||
parameters: {
|
||||
query?: never;
|
||||
@@ -738,6 +832,26 @@ export interface operations {
|
||||
};
|
||||
};
|
||||
};
|
||||
task_stats: {
|
||||
parameters: {
|
||||
query?: never;
|
||||
header?: never;
|
||||
path?: never;
|
||||
cookie?: never;
|
||||
};
|
||||
requestBody?: never;
|
||||
responses: {
|
||||
/** @description Dashboard counters */
|
||||
200: {
|
||||
headers: {
|
||||
[name: string]: unknown;
|
||||
};
|
||||
content: {
|
||||
"application/json": components["schemas"]["TaskStatsResponse"];
|
||||
};
|
||||
};
|
||||
};
|
||||
};
|
||||
list_directories: {
|
||||
parameters: {
|
||||
query?: {
|
||||
|
||||
+86
-28
@@ -1,37 +1,84 @@
|
||||
@font-face {
|
||||
font-family: 'Outfit-Medium';
|
||||
src: url('/fonts/Outfit-Medium.ttf') format('truetype');
|
||||
font-weight: 500;
|
||||
font-style: normal;
|
||||
font-display: swap;
|
||||
}
|
||||
|
||||
/* 正文中文字体:LXGW WenKai(自托管,GB2312 + 常用符号子集) */
|
||||
@font-face {
|
||||
font-family: 'LXGW WenKai';
|
||||
src: url('/fonts/LXGWWenKai-Regular.woff2') format('woff2');
|
||||
font-weight: 400;
|
||||
font-style: normal;
|
||||
font-display: swap;
|
||||
}
|
||||
|
||||
@font-face {
|
||||
font-family: 'LXGW WenKai';
|
||||
src: url('/fonts/LXGWWenKai-Bold.woff2') format('woff2');
|
||||
font-weight: 700;
|
||||
font-style: normal;
|
||||
font-display: swap;
|
||||
}
|
||||
|
||||
:root {
|
||||
--bg-color: #f7f7f8;
|
||||
/* 设计令牌:对齐 Alright 主题(nicetheme) */
|
||||
--theme-primary: #3858f6;
|
||||
--theme-primary-soft: rgba(56, 88, 246, 0.1);
|
||||
--theme-dark: #1d2129;
|
||||
--theme-secondary: #2c343c;
|
||||
--theme-muted: #4e5969;
|
||||
--theme-light: #c9cdd4;
|
||||
--theme-border: #eee;
|
||||
--theme-border-soft: #f6f6f6;
|
||||
--theme-bg: #f7f8fa;
|
||||
--theme-success: #4abd86;
|
||||
--theme-danger: #ff3e3e;
|
||||
--theme-warning: #ff8338;
|
||||
|
||||
--bg-color: var(--theme-bg);
|
||||
--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;
|
||||
--text-main: var(--theme-dark);
|
||||
--text-muted: var(--theme-muted);
|
||||
--text-light: var(--theme-light);
|
||||
--primary: var(--theme-primary);
|
||||
--primary-hover: #2f49d6;
|
||||
--primary-soft: var(--theme-primary-soft);
|
||||
--border-color: var(--theme-border-soft);
|
||||
--badge-bg: var(--theme-border-soft);
|
||||
--radius-sm: 6px;
|
||||
--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);
|
||||
--radius-lg: 24px;
|
||||
--shadow-sm: 0 2px 8px rgba(27, 26, 49, 0.02);
|
||||
--shadow-card: 0 6px 16px 0 rgba(27, 26, 49, 0.03);
|
||||
--shadow-modal: 0 16px 48px rgba(27, 26, 49, 0.08);
|
||||
--background: var(--bg-color);
|
||||
--surface: var(--card-bg);
|
||||
--surface-muted: #fafafa;
|
||||
--surface-muted: #f7f8fa;
|
||||
--text: var(--text-main);
|
||||
--text-secondary: var(--text-muted);
|
||||
--text-tertiary: #a4aab3;
|
||||
--border: #eceef1;
|
||||
--success: #10b981;
|
||||
--success-bg: #eefbf5;
|
||||
--danger: #f43f5e;
|
||||
--danger-bg: #fff1f2;
|
||||
--warning: #d97706;
|
||||
--warning-bg: #fffbeb;
|
||||
--text-tertiary: #86909c;
|
||||
--border: var(--theme-border);
|
||||
--success: var(--theme-success);
|
||||
--success-bg: rgba(74, 189, 134, 0.1);
|
||||
--success-border: rgba(74, 189, 134, 0.3);
|
||||
--danger: var(--theme-danger);
|
||||
--danger-bg: rgba(255, 62, 62, 0.08);
|
||||
--danger-border: rgba(255, 62, 62, 0.24);
|
||||
--warning: var(--theme-warning);
|
||||
--warning-bg: #fffbe8;
|
||||
--shadow: var(--shadow-card);
|
||||
--transition-base: 0.3s ease-in-out;
|
||||
--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;
|
||||
'LXGW WenKai', -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto,
|
||||
'Hiragino Sans GB', 'Microsoft YaHei UI', 'Microsoft YaHei',
|
||||
'Source Han Sans CN', sans-serif;
|
||||
--font-display: 'Outfit-Medium', var(--font-sans);
|
||||
--font-mono:
|
||||
SFMono-Regular, Menlo, Monaco, Consolas, 'Liberation Mono', 'Courier New',
|
||||
monospace;
|
||||
|
||||
color: var(--text);
|
||||
background: var(--background);
|
||||
@@ -42,6 +89,13 @@
|
||||
-moz-osx-font-smoothing: grayscale;
|
||||
}
|
||||
|
||||
h1,
|
||||
h2,
|
||||
h3 {
|
||||
font-family: var(--font-display);
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
* {
|
||||
box-sizing: border-box;
|
||||
}
|
||||
@@ -74,13 +128,17 @@ button:disabled {
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
::placeholder {
|
||||
color: var(--text-light);
|
||||
}
|
||||
|
||||
::selection {
|
||||
background: #111827;
|
||||
color: #fff;
|
||||
background: var(--theme-primary-soft);
|
||||
color: var(--theme-dark);
|
||||
}
|
||||
|
||||
:focus-visible {
|
||||
outline: 3px solid rgba(17, 24, 39, 0.25);
|
||||
outline: 3px solid rgba(56, 88, 246, 0.28);
|
||||
outline-offset: 3px;
|
||||
}
|
||||
|
||||
|
||||
@@ -17,6 +17,21 @@ export function formatDateTime(value: string | null | undefined): string {
|
||||
return Number.isNaN(date.getTime()) ? '--' : dateTimeFormatter.format(date)
|
||||
}
|
||||
|
||||
const shortDateTimeFormatter = new Intl.DateTimeFormat('zh-CN', {
|
||||
month: '2-digit',
|
||||
day: '2-digit',
|
||||
hour: '2-digit',
|
||||
minute: '2-digit',
|
||||
})
|
||||
|
||||
export function formatShortDateTime(value: string | null | undefined): string {
|
||||
if (!value) {
|
||||
return '--'
|
||||
}
|
||||
const date = new Date(value)
|
||||
return Number.isNaN(date.getTime()) ? '--' : shortDateTimeFormatter.format(date)
|
||||
}
|
||||
|
||||
export function formatDuration(value: number | null | undefined): string {
|
||||
if (value === null || value === undefined) {
|
||||
return '--'
|
||||
|
||||
@@ -10,6 +10,8 @@ import {
|
||||
createTask,
|
||||
deleteTask,
|
||||
fetchHealth,
|
||||
fetchSchedule,
|
||||
fetchStats,
|
||||
listTasks,
|
||||
runTask,
|
||||
setTaskEnabled,
|
||||
@@ -24,7 +26,7 @@ import {
|
||||
EnabledBadge,
|
||||
} from '../components/StatusBadge'
|
||||
import { TaskEditorModal } from '../components/TaskEditorModal'
|
||||
import { actionTypeLabel } from '../lib/format'
|
||||
import { actionTypeLabel, formatShortDateTime } from '../lib/format'
|
||||
import {
|
||||
type TaskFormValues,
|
||||
valuesToCreateRequest,
|
||||
@@ -75,23 +77,19 @@ export function TaskDashboard() {
|
||||
queryFn: ({ signal }) => listTasks(filters, signal),
|
||||
placeholderData: keepPreviousData,
|
||||
})
|
||||
const totalTasksQuery = useQuery({
|
||||
queryKey: ['tasks', 'summary', 'total'],
|
||||
queryFn: ({ signal }) => listTasks({ page: 1, page_size: 1 }, signal),
|
||||
const statsQuery = useQuery({
|
||||
queryKey: ['stats'],
|
||||
queryFn: ({ signal }) => fetchStats(signal),
|
||||
refetchInterval: 15_000,
|
||||
})
|
||||
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 scheduleQuery = useQuery({
|
||||
queryKey: ['schedule'],
|
||||
queryFn: ({ signal }) => fetchSchedule(signal),
|
||||
refetchInterval: 15_000,
|
||||
})
|
||||
const nextRuns = new Map(
|
||||
(scheduleQuery.data ?? []).map((entry) => [entry.task_id, entry.next_run_at]),
|
||||
)
|
||||
|
||||
const saveMutation = useMutation({
|
||||
mutationFn: ({
|
||||
@@ -232,18 +230,18 @@ export function TaskDashboard() {
|
||||
<section className="metrics-grid" aria-label="任务统计">
|
||||
<MetricCard
|
||||
label="已托管任务"
|
||||
value={totalTasksQuery.data?.total}
|
||||
loading={totalTasksQuery.isPending}
|
||||
value={statsQuery.data?.total}
|
||||
loading={statsQuery.isPending}
|
||||
/>
|
||||
<MetricCard
|
||||
label="正常运行中"
|
||||
value={enabledTasksQuery.data?.total}
|
||||
loading={enabledTasksQuery.isPending}
|
||||
label="运行中任务"
|
||||
value={statsQuery.data?.running}
|
||||
loading={statsQuery.isPending}
|
||||
/>
|
||||
<MetricCard
|
||||
label="可用脚本"
|
||||
value={scriptTasksQuery.data?.total}
|
||||
loading={scriptTasksQuery.isPending}
|
||||
value={statsQuery.data?.script}
|
||||
loading={statsQuery.isPending}
|
||||
/>
|
||||
<article className="metric-card">
|
||||
<span>Rust 引擎</span>
|
||||
@@ -407,6 +405,13 @@ export function TaskDashboard() {
|
||||
<td>
|
||||
<code>{task.cron}</code>
|
||||
<div className="cell-note">{task.timezone}</div>
|
||||
<div className="cell-note">
|
||||
{task.enabled
|
||||
? nextRuns.get(task.id)
|
||||
? `下次 ${formatShortDateTime(nextRuns.get(task.id))}`
|
||||
: '调度中…'
|
||||
: '未调度'}
|
||||
</div>
|
||||
</td>
|
||||
<td>
|
||||
<span className="type-badge">
|
||||
@@ -573,5 +578,7 @@ async function invalidateTaskData(queryClient: ReturnType<typeof useQueryClient>
|
||||
await Promise.all([
|
||||
queryClient.invalidateQueries({ queryKey: ['tasks'] }),
|
||||
queryClient.invalidateQueries({ queryKey: ['task-runs'] }),
|
||||
queryClient.invalidateQueries({ queryKey: ['stats'] }),
|
||||
queryClient.invalidateQueries({ queryKey: ['schedule'] }),
|
||||
])
|
||||
}
|
||||
|
||||
@@ -0,0 +1,60 @@
|
||||
"""TaskPilot 演示任务。
|
||||
|
||||
在 TaskPilot 里把「项目工作目录」指向本目录、解释器填 ``python.exe``、脚本填
|
||||
``main.py``,后端会自动关联同级的 ``.venv`` 并以虚拟环境解释器运行。
|
||||
|
||||
常用参数:
|
||||
--lines N 输出 N 行中文(默认 3)
|
||||
--sleep N 执行前等待 N 秒,用于验证超时与取消
|
||||
--fail 以退出码 3 结束,用于验证失败状态
|
||||
--append PATH 把一行结果追加到指定文件,用于验证副作用
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import os
|
||||
import sys
|
||||
import time
|
||||
from datetime import datetime, timezone
|
||||
from pathlib import Path
|
||||
|
||||
|
||||
def main() -> int:
|
||||
parser = argparse.ArgumentParser(description="TaskPilot python demo task")
|
||||
parser.add_argument("--lines", type=int, default=3)
|
||||
parser.add_argument("--sleep", type=float, default=0.0)
|
||||
parser.add_argument("--fail", action="store_true")
|
||||
parser.add_argument("--append", type=Path)
|
||||
args = parser.parse_args()
|
||||
|
||||
started = datetime.now(timezone.utc).isoformat(timespec="seconds")
|
||||
print(f"[demo] 启动时间 {started}")
|
||||
print(f"[demo] 解释器 {sys.executable}")
|
||||
print(f"[demo] VIRTUAL_ENV {os.environ.get('VIRTUAL_ENV', '<未设置>')}")
|
||||
print(f"[demo] 工作目录 {Path.cwd()}")
|
||||
print(f"[demo] 参数 lines={args.lines} sleep={args.sleep} fail={args.fail}")
|
||||
|
||||
for index in range(args.lines):
|
||||
print(f"[demo] 第 {index + 1} 行:中文输出与勾选 √")
|
||||
|
||||
if args.append is not None:
|
||||
args.append.parent.mkdir(parents=True, exist_ok=True)
|
||||
with args.append.open("a", encoding="utf-8") as handle:
|
||||
handle.write(f"{started} ok\n")
|
||||
print(f"[demo] 已追加写入 {args.append}")
|
||||
|
||||
if args.sleep > 0:
|
||||
print(f"[demo] 开始等待 {args.sleep} 秒", flush=True)
|
||||
time.sleep(args.sleep)
|
||||
|
||||
if args.fail:
|
||||
print("[demo] 按要求返回失败", file=sys.stderr)
|
||||
return 3
|
||||
|
||||
print("[demo] 完成")
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
raise SystemExit(main())
|
||||
@@ -0,0 +1,6 @@
|
||||
[project]
|
||||
name = "taskpilot-python-demo"
|
||||
version = "0.1.0"
|
||||
description = "TaskPilot 演示任务:验证虚拟环境、中文输出与退出码"
|
||||
requires-python = ">=3.10"
|
||||
dependencies = []
|
||||
@@ -1,6 +1,7 @@
|
||||
$ErrorActionPreference = 'Stop'
|
||||
|
||||
$projectRoot = Split-Path -Parent $PSScriptRoot
|
||||
$scratch = Join-Path $projectRoot '.tmp'
|
||||
|
||||
function Assert-ExitCode {
|
||||
param([string]$Step)
|
||||
@@ -10,6 +11,12 @@ function Assert-ExitCode {
|
||||
}
|
||||
}
|
||||
|
||||
# 测试统一使用仓库内的临时目录:不依赖系统 %TEMP% 的权限策略,
|
||||
# 也让清理、归档和排障都留在项目目录里。
|
||||
[void](New-Item -ItemType Directory -Path $scratch -Force)
|
||||
$env:TMP = $scratch
|
||||
$env:TEMP = $scratch
|
||||
|
||||
Push-Location $projectRoot
|
||||
try {
|
||||
pnpm --dir frontend lint
|
||||
@@ -31,4 +38,5 @@ try {
|
||||
}
|
||||
finally {
|
||||
Pop-Location
|
||||
Remove-Item -LiteralPath $scratch -Recurse -Force -ErrorAction SilentlyContinue
|
||||
}
|
||||
|
||||
@@ -0,0 +1,119 @@
|
||||
#!/usr/bin/env python3
|
||||
"""重新生成界面字体(字体二进制不入库)。
|
||||
|
||||
用法:
|
||||
uv run --with fonttools --with brotli tools/build-fonts.py
|
||||
|
||||
流程:从 lxgw-wenkai-webfont 的 CDN 下载 Regular / Bold 的全部分包 woff2 子集,
|
||||
合并成单个字体后裁剪到 GB2312 + Latin + 中文标点,写入 frontend/public/fonts/。
|
||||
生成的字体仅供本地构建与打包使用,不提交到仓库。
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import io
|
||||
import re
|
||||
import sys
|
||||
import tempfile
|
||||
import urllib.request
|
||||
from pathlib import Path
|
||||
|
||||
try:
|
||||
from fontTools.merge import Merger
|
||||
from fontTools.subset import Options, Subsetter
|
||||
from fontTools.ttLib import TTFont
|
||||
except ImportError: # pragma: no cover - 由 uv --with 保证
|
||||
sys.exit("缺少依赖:uv run --with fonttools --with brotli tools/build-fonts.py")
|
||||
|
||||
CDN = "https://cdn.staticfile.net/lxgw-wenkai-webfont/1.7.0/"
|
||||
WEIGHTS = {
|
||||
"Regular": "lxgwwenkai-regular.css",
|
||||
"Bold": "lxgwwenkai-bold.css",
|
||||
}
|
||||
OUT_DIR = Path(__file__).resolve().parent.parent / "frontend" / "public" / "fonts"
|
||||
|
||||
|
||||
def fetch(url: str) -> bytes:
|
||||
request = urllib.request.Request(
|
||||
url, headers={"User-Agent": "taskpilot-font-builder"}
|
||||
)
|
||||
with urllib.request.urlopen(request, timeout=60) as response:
|
||||
return response.read()
|
||||
|
||||
|
||||
def subset_urls(css_name: str) -> list[str]:
|
||||
css = fetch(CDN + css_name).decode("utf-8")
|
||||
# 形如:src: url('./files/lxgwwenkai-regular-subset-4.woff2')
|
||||
matches = re.findall(r"url\(\s*['\"]?([^'\")]+\.woff2)['\"]?\s*\)", css)
|
||||
urls = sorted({match.lstrip("./") for match in matches})
|
||||
if not urls:
|
||||
raise RuntimeError(f"未能从 {css_name} 解析出字体子集,CDN 格式可能已变更")
|
||||
return urls
|
||||
|
||||
|
||||
def build_codepoints() -> set[int]:
|
||||
points: set[int] = set()
|
||||
for start, end in (
|
||||
(0x20, 0x7F), # ASCII
|
||||
(0xA0, 0x100), # Latin-1
|
||||
(0x2000, 0x2070), # 通用标点
|
||||
(0x25A0, 0x2600), # 几何图形
|
||||
(0x3000, 0x3040), # 中文标点
|
||||
(0xFF00, 0xFFF0), # 全角字符
|
||||
):
|
||||
points.update(range(start, end))
|
||||
points.update(
|
||||
[0x00D7, 0x00F7, 0x2190, 0x2191, 0x2192, 0x2193, 0x2713, 0x2717, 0x23F3]
|
||||
)
|
||||
# GB2312 汉字与符号行
|
||||
for high in range(0xA1, 0xF8):
|
||||
for low in range(0xA1, 0xFF):
|
||||
try:
|
||||
points.add(ord(bytes([high, low]).decode("gb2312")))
|
||||
except UnicodeDecodeError:
|
||||
continue
|
||||
return points
|
||||
|
||||
|
||||
def build(weight: str, css_name: str, points: set[int], workdir: Path) -> None:
|
||||
urls = subset_urls(css_name)
|
||||
print(f"[{weight}] 下载 {len(urls)} 个子集…")
|
||||
paths: list[str] = []
|
||||
for url in urls:
|
||||
data = fetch(CDN + url)
|
||||
font = TTFont(io.BytesIO(data))
|
||||
target = workdir / Path(url).name.replace(".woff2", ".ttf")
|
||||
font.save(target)
|
||||
font.close()
|
||||
paths.append(str(target))
|
||||
|
||||
print(f"[{weight}] 合并 {len(paths)} 个字体…")
|
||||
merged = Merger().merge(paths)
|
||||
|
||||
options = Options()
|
||||
options.flavor = "woff2"
|
||||
options.hinting = False
|
||||
options.layout_features = ["*"]
|
||||
options.name_IDs = ["*"]
|
||||
subsetter = Subsetter(options=options)
|
||||
subsetter.populate(unicodes=points)
|
||||
subsetter.subset(merged)
|
||||
|
||||
output = OUT_DIR / f"LXGWWenKai-{weight}.woff2"
|
||||
merged.save(output)
|
||||
print(f"[{weight}] 写出 {output} {output.stat().st_size / 1024 / 1024:.2f} MB")
|
||||
|
||||
|
||||
def main() -> int:
|
||||
OUT_DIR.mkdir(parents=True, exist_ok=True)
|
||||
points = build_codepoints()
|
||||
print(f"目标字符集:{len(points)} 个码位")
|
||||
with tempfile.TemporaryDirectory() as directory:
|
||||
workdir = Path(directory)
|
||||
for weight, css_name in WEIGHTS.items():
|
||||
build(weight, css_name, points, workdir)
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
sys.exit(main())
|
||||
+266
@@ -0,0 +1,266 @@
|
||||
#!/usr/bin/env python3
|
||||
"""TaskPilot 端到端自检(仅标准库)。
|
||||
|
||||
用法:
|
||||
uv run tools/verify.py
|
||||
uv run tools/verify.py --base-url http://127.0.0.1:3000 --keep
|
||||
|
||||
它会真实地:创建任务 → 立即运行 → 读回日志 → 校验中文输出与虚拟环境注入 →
|
||||
校验调度与统计接口 → 删除任务(--keep 可保留)。
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import json
|
||||
import sys
|
||||
import time
|
||||
import urllib.error
|
||||
import urllib.parse
|
||||
import urllib.request
|
||||
from dataclasses import dataclass, field
|
||||
from pathlib import Path
|
||||
|
||||
TASK_ID = "verify_python_demo"
|
||||
|
||||
|
||||
@dataclass
|
||||
class Check:
|
||||
name: str
|
||||
ok: bool
|
||||
detail: str = ""
|
||||
|
||||
def render(self) -> str:
|
||||
marker = "OK " if self.ok else "FAIL"
|
||||
suffix = f" {self.detail}" if self.detail else ""
|
||||
return f"[{marker}] {self.name}{suffix}"
|
||||
|
||||
|
||||
@dataclass
|
||||
class Api:
|
||||
base_url: str
|
||||
token: str | None = None
|
||||
checks: list[Check] = field(default_factory=list)
|
||||
|
||||
def request(
|
||||
self,
|
||||
method: str,
|
||||
path: str,
|
||||
body: dict | None = None,
|
||||
authenticated: bool = False,
|
||||
) -> tuple[int, object]:
|
||||
url = f"{self.base_url}{path}"
|
||||
data = None
|
||||
headers = {"Accept": "application/json"}
|
||||
if body is not None:
|
||||
data = json.dumps(body).encode("utf-8")
|
||||
headers["Content-Type"] = "application/json"
|
||||
if authenticated:
|
||||
headers["X-TaskPilot-Token"] = self.token or ""
|
||||
request = urllib.request.Request(url, data=data, headers=headers, method=method)
|
||||
try:
|
||||
with urllib.request.urlopen(request, timeout=15) as response:
|
||||
raw = response.read()
|
||||
status = response.status
|
||||
except urllib.error.HTTPError as error:
|
||||
raw = error.read()
|
||||
status = error.code
|
||||
except urllib.error.URLError as error:
|
||||
raise SystemExit(f"无法连接 {url}: {error.reason}") from error
|
||||
|
||||
if not raw:
|
||||
return status, None
|
||||
try:
|
||||
return status, json.loads(raw.decode("utf-8"))
|
||||
except json.JSONDecodeError:
|
||||
return status, raw.decode("utf-8", errors="replace")
|
||||
|
||||
def check(self, name: str, ok: bool, detail: str = "") -> bool:
|
||||
self.checks.append(Check(name, ok, detail))
|
||||
return ok
|
||||
|
||||
def session(self) -> str:
|
||||
status, body = self.request("GET", "/api/v1/session")
|
||||
if status != 200 or not isinstance(body, dict):
|
||||
raise SystemExit(f"获取会话令牌失败: HTTP {status}")
|
||||
self.token = str(body["token"])
|
||||
return self.token
|
||||
|
||||
def poll_run(self, run_id: int, timeout: float) -> dict:
|
||||
deadline = time.monotonic() + timeout
|
||||
terminal = {"succeeded", "failed", "timed_out", "cancelled", "skipped"}
|
||||
while time.monotonic() < deadline:
|
||||
status, body = self.request("GET", f"/api/v1/runs/{run_id}")
|
||||
if status != 200 or not isinstance(body, dict):
|
||||
raise SystemExit(f"读取运行记录失败: HTTP {status}")
|
||||
if body.get("status") in terminal:
|
||||
return body
|
||||
time.sleep(0.2)
|
||||
raise SystemExit(f"运行 {run_id} 在 {timeout} 秒内未结束")
|
||||
|
||||
def read_logs(self, run_id: int, timeout: float) -> str:
|
||||
deadline = time.monotonic() + timeout
|
||||
content = ""
|
||||
offset = 0
|
||||
while time.monotonic() < deadline:
|
||||
status, body = self.request(
|
||||
"GET", f"/api/v1/runs/{run_id}/logs?offset={offset}"
|
||||
)
|
||||
if status != 200 or not isinstance(body, dict):
|
||||
raise SystemExit(f"读取运行日志失败: HTTP {status}")
|
||||
content += str(body.get("content", ""))
|
||||
offset = int(body.get("next_offset", offset))
|
||||
if body.get("complete"):
|
||||
return content
|
||||
time.sleep(0.1)
|
||||
return content
|
||||
|
||||
|
||||
def main() -> int:
|
||||
parser = argparse.ArgumentParser(description="TaskPilot end-to-end self check")
|
||||
parser.add_argument("--base-url", default="http://127.0.0.1:3000")
|
||||
parser.add_argument(
|
||||
"--working-dir",
|
||||
default=str(Path(__file__).resolve().parent.parent / "samples" / "python-task"),
|
||||
help="包含 .venv 的演示任务目录",
|
||||
)
|
||||
parser.add_argument("--keep", action="store_true", help="检查完成后保留任务")
|
||||
parser.add_argument("--timeout", type=float, default=60.0)
|
||||
args = parser.parse_args()
|
||||
|
||||
working_dir = Path(args.working_dir).resolve()
|
||||
api = Api(args.base_url.rstrip("/"))
|
||||
|
||||
# 1. 健康检查
|
||||
status, health = api.request("GET", "/api/v1/health")
|
||||
api.check(
|
||||
"health endpoint",
|
||||
status == 200 and isinstance(health, dict) and health.get("status") == "ok",
|
||||
f"HTTP {status}",
|
||||
)
|
||||
|
||||
# 2. 会话令牌
|
||||
token = api.session()
|
||||
api.check("session token", bool(token), f"{len(token)} chars")
|
||||
|
||||
# 3. 目录列举能看到演示目录下的 .venv
|
||||
query = urllib.parse.urlencode({"path": str(working_dir)})
|
||||
status, listing = api.request("GET", f"/api/v1/system/directories?{query}")
|
||||
names = [entry["name"] for entry in (listing or {}).get("entries", [])]
|
||||
api.check(
|
||||
"directory listing sees .venv",
|
||||
status == 200 and ".venv" in names,
|
||||
f"entries={names}",
|
||||
)
|
||||
|
||||
# 4. 虚拟环境探测识别出 .venv\Scripts\python.exe
|
||||
status, probe = api.request(
|
||||
"POST",
|
||||
"/api/v1/python/venv",
|
||||
{"working_dir": str(working_dir), "interpreter": "python.exe", "venv": None},
|
||||
authenticated=True,
|
||||
)
|
||||
probe = probe if isinstance(probe, dict) else {}
|
||||
display_path = str(probe.get("display_path") or "")
|
||||
api.check(
|
||||
"venv probe links the demo environment",
|
||||
status == 200
|
||||
and probe.get("status") == "linked"
|
||||
and display_path.endswith(r"Scripts\python.exe"),
|
||||
f"status={probe.get('status')} path={display_path}",
|
||||
)
|
||||
|
||||
# 5. 创建任务(启用 → 自动注册调度)
|
||||
payload = {
|
||||
"id": TASK_ID,
|
||||
"name": "Python 演示任务",
|
||||
"cron": "0 0 3 * * *",
|
||||
"timezone": "Asia/Shanghai",
|
||||
"enabled": True,
|
||||
"timeout_secs": 30,
|
||||
"action": {
|
||||
"type": "script",
|
||||
"working_dir": str(working_dir),
|
||||
"interpreter": "python.exe",
|
||||
"script": "main.py",
|
||||
"args": ["--lines", "2"],
|
||||
"env": {},
|
||||
},
|
||||
}
|
||||
api.request("DELETE", f"/api/v1/tasks/{TASK_ID}", authenticated=True)
|
||||
status, created = api.request(
|
||||
"POST", "/api/v1/tasks", payload, authenticated=True
|
||||
)
|
||||
api.check("create task", status == 201, f"HTTP {status}")
|
||||
|
||||
# 6. 调度接口给出下次触发时间
|
||||
status, schedule = api.request("GET", "/api/v1/schedule")
|
||||
entries = schedule if isinstance(schedule, list) else []
|
||||
entry = next((item for item in entries if item.get("task_id") == TASK_ID), None)
|
||||
api.check(
|
||||
"task is registered in the schedule",
|
||||
status == 200 and entry is not None and bool(entry.get("next_run_at")),
|
||||
f"next_run_at={(entry or {}).get('next_run_at')}",
|
||||
)
|
||||
|
||||
# 7. 立即运行并等待结束
|
||||
status, run = api.request(
|
||||
"POST", f"/api/v1/tasks/{TASK_ID}/run", authenticated=True
|
||||
)
|
||||
run = run if isinstance(run, dict) else {}
|
||||
run_id = int(run.get("id", 0))
|
||||
api.check("run accepted", status == 202 and run_id > 0, f"run={run_id}")
|
||||
|
||||
finished = api.poll_run(run_id, args.timeout)
|
||||
api.check(
|
||||
"run succeeded",
|
||||
finished.get("status") == "succeeded" and finished.get("exit_code") == 0,
|
||||
f"status={finished.get('status')} exit={finished.get('exit_code')}",
|
||||
)
|
||||
|
||||
# 8. 日志里能看到中文、虚拟环境注入与真实解释器路径
|
||||
log = api.read_logs(run_id, args.timeout)
|
||||
api.check("log contains Chinese output", "中文输出" in log)
|
||||
api.check(
|
||||
"log shows the venv interpreter",
|
||||
".venv" in log.lower() or "Scripts" in log,
|
||||
)
|
||||
api.check(
|
||||
"log shows VIRTUAL_ENV injection",
|
||||
"VIRTUAL_ENV" in log and "<未设置>" not in log,
|
||||
)
|
||||
api.check("log has no replacement characters", "\ufffd" not in log)
|
||||
|
||||
# 9. 统计接口口径正确
|
||||
status, stats = api.request("GET", "/api/v1/stats")
|
||||
stats = stats if isinstance(stats, dict) else {}
|
||||
api.check(
|
||||
"stats endpoint reports the task",
|
||||
status == 200 and int(stats.get("total", 0)) >= 1 and int(stats.get("running", -1)) == 0,
|
||||
f"total={stats.get('total')} enabled={stats.get('enabled')} running={stats.get('running')}",
|
||||
)
|
||||
|
||||
# 10. 清理
|
||||
if args.keep:
|
||||
api.check("cleanup skipped (--keep)", True)
|
||||
else:
|
||||
status, _ = api.request(
|
||||
"DELETE", f"/api/v1/tasks/{TASK_ID}", authenticated=True
|
||||
)
|
||||
api.check("delete task", status == 204, f"HTTP {status}")
|
||||
|
||||
failures = [check for check in api.checks if not check.ok]
|
||||
print(f"\nTaskPilot self check against {args.base_url}\n")
|
||||
for check in api.checks:
|
||||
print(check.render())
|
||||
print(f"\n{len(api.checks) - len(failures)}/{len(api.checks)} checks passed")
|
||||
if failures:
|
||||
print("\n失败的检查:")
|
||||
for check in failures:
|
||||
print(f" - {check.name}: {check.detail}")
|
||||
return 1
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
sys.exit(main())
|
||||
Reference in New Issue
Block a user