Compare commits

...
11 Commits
Author SHA1 Message Date
steven 33f4ce42e4 Merge branch 'master' of github.com:oventh/TaskPulse 2026-06-20 12:09:19 +08:00
steven 4215137f26 fix: dayjs UTC import, API pagination, execution list endpoint 2026-06-20 12:09:00 +08:00
oventhandGitHub 78df16f770 Update README.en.md 2026-06-19 16:09:38 +08:00
steven 4cdccf904a Merge branch 'master' of github.com:oventh/TaskPulse 2026-06-19 16:07:29 +08:00
steven d82e1c9946 docs: add English README (README.en.md) with language switcher 2026-06-19 16:06:46 +08:00
oventhandGitHub e8f931b4a1 Update README.md 2026-06-18 00:38:20 +08:00
oventhandGitHub c54cc82612 Update README.md 2026-06-17 20:38:17 +08:00
oventhandGitHub 56dfa5b418 Update README.md 2026-06-17 20:37:04 +08:00
steven ad51078975 docs: rewrite README with comprehensive project introduction and feature overview 2026-06-17 20:36:03 +08:00
steven 1e539b1c25 Merge branch 'master' of https://gitea.tatta.cn/Tatta/TaskPulse 2026-06-15 23:35:52 +08:00
steven b04c0ce321 feat: agent avatars, task tags, search, edit/delete agents, cron readability, timezone fix
- Agent avatars: first-letter + deterministic color avatar in all views
- Agent management: edit (name/description) and delete with confirmation
- Execution API: validates agent existence, returns 410 if deleted
- Task tags: JSON array field, editable with tag pills UI
- Task search: by name (?q=) and by tags (?tags=) and by status
- Task edit: simplified to name/tags/description only (not agent params)
- Cron display: human-readable Chinese (e.g. '每5分钟', '每天 09:00')
- Timezone fix: UTC→local via dayjs.utc().local() across all pages
- Content area: full width (removed max-width:1200px constraints)
- Login page: standalone layout without sidebar
2026-06-15 23:33:28 +08:00
20 changed files with 1246 additions and 370 deletions
+266
View File
@@ -0,0 +1,266 @@
<picture src="https://img.shields.io/badge/python-3.11%2B-blue" alt="Python" /><picture src="https://img.shields.io/badge/vue-3.4%2B-4FC08D" alt="Vue" /><picture src="https://img.shields.io/badge/license-MIT-green" alt="License" />
# TaskPulse — AI Agent Scheduled Task Monitor
> **Let your AI agents self-report. You just glance at the dashboard.**
TaskPulse is a tracking and monitoring platform designed for AI agents' scheduled tasks. Unlike traditional CRUD-heavy admin panels, TaskPulse is **AI-native**: give your agents an API spec, and they autonomously register themselves, report execution results, and update their status — no manual form-filling required.
[📖 中文文档](./README.md)
---
## Philosophy
```
┌─────────────────────┐
│ TaskPulse Dashboard│
│ Unified Status View│
└──────┬──────────┬───┘
│ │
┌─────────────────┘ └─────────────────┐
▼ ▼
┌─────────────────┐ ┌──────────────────────┐
│ AI Agent A │ register tasks → execute │ AI Agent B │
│ ├─ data sync │ ← report result + log │ ├─ order fetch │
│ └─ report gen │ │ └─ inventory sync │
└─────────────────┘ └──────────────────────┘
```
**AI-Native by Design**
Traditional schedulers require humans to fill forms and manage cron expressions. But AI agents can read API docs and act autonomously:
1. **Self-Register** — One API call to register both the agent and all its tasks
2. **Report Results** — After each execution, POST back the result with logs and duration
3. **Auto-Alert** — If a task misses its window, the system alerts automatically
The only thing humans need to do: **open the dashboard and see the big picture.**
---
## Features
### 🤖 AI-Native Onboarding
- **Batch Registration**: `POST /api/agents/register-with-tasks` — agent + tasks in one shot
- **API Key Auth**: Each agent gets its own key for reporting
- **Re-registration**: Deleted agents can re-register anytime
- **Agent Avatars**: First-letter + deterministic color avatars for quick visual identification
### 📊 Unified Dashboard
- **Summary Cards**: agent count, task count, 24h executions, pending alerts
- **Global Task View**: all tasks from all agents in one place
- **Task Tags**: label tasks (e.g. "data-sync", "critical"), filter by tags
- **Human-Readable Cron**: `*/5 * * * *` → "Every 5 minutes"
- **Localized Time**: all timestamps auto-converted from UTC to browser timezone
### ⏱ Execution Tracking
- **Live Reporting**: agents POST results immediately after execution
- **Execution History**: full timeline per task, paginated
- **Log Viewer**: dark terminal-style viewer for debugging
- **Search & Filter**: by task name, tags, and status
### 🚨 Timeout Alerts & Notifications
- **Auto-Scan**: background scheduler checks overdue tasks every 60s
- **Grace Window**: per-task tolerance to avoid false alerts
- **Multi-Channel**: Feishu (Lark) webhook, email SMTP, generic webhook
- **Alert Acknowledgment**: mark alerts as handled for closed-loop management
### 🎨 Modern UI
- **Dark Theme**: gradient backgrounds + neon accents
- **Full-Width Layout**: maximizes screen usage
- **Responsive**: adapts to different screen sizes
---
## Tech Stack
| Layer | Technology |
|---|---|
| Backend | Python FastAPI (async) |
| Production Server | Gunicorn + Uvicorn Worker |
| Frontend | Vue 3 + Element Plus |
| Build Tool | Vite |
| Database | MySQL 8.0 |
| Validation | Pydantic v2 |
| ORM | SQLAlchemy 2.0 (async) |
| Auth | JWT (python-jose) + bcrypt |
| Scheduler | APScheduler (async loop) |
| Deployment | Monolithic (FastAPI serves SPA) |
---
## Quick Start
### Local Development
```bash
# 1. Clone
git clone https://github.com/oventh/TaskPulse.git
cd TaskPulse
# 2. Backend
cd backend
python -m venv .venv
source .venv/bin/activate # Windows: .venv\Scripts\activate
pip install -r requirements.txt
cp .env.example .env # edit .env with your DB config
uvicorn app.main:app --reload --port 8000
# 3. Frontend (new terminal)
cd frontend
npm install
npm run dev # http://localhost:3000
```
> Frontend dev server proxies `/api` to `localhost:8000`.
### Production Deployment
```bash
# 1. Build frontend
cd frontend && npm install && npm run build
# 2. Deploy to server
rsync -avz --exclude='.venv' --exclude='node_modules' --exclude='.git' \
./ user@server:/home/app/taskpulse/
# 3. Install deps + start with Gunicorn
cd /home/app/taskpulse/backend
python3 -m venv .venv && .venv/bin/pip install -r requirements.txt
.venv/bin/gunicorn app.main:app \
-k uvicorn.workers.UvicornWorker \
-b 0.0.0.0:8000 -w 2 --preload --daemon
```
---
## Agent Integration
Let your AI agent self-register in 3 steps:
```python
import requests
BASE = "https://your-domain.com"
# 1. Register agent + all its tasks (one API call)
resp = requests.post(f"{BASE}/api/agents/register-with-tasks", json={
"name": "my-agent",
"description": "My AI Agent",
"tasks": [
{"name": "sync-data", "cron_expression": "*/5 * * * *"},
{"name": "daily-report", "cron_expression": "0 9 * * *"}
]
})
data = resp.json()
API_KEY = data["agent"]["api_key"] # ← save this
AGENT_ID = data["agent"]["id"]
# 2. Report execution result after each run
requests.post(f"{BASE}/api/tasks/{TASK_ID}/executions",
headers={"Authorization": f"Bearer {API_KEY}"},
json={"status": "success", "duration_ms": 1500, "log": "Task completed"})
```
> For curl, batch registration, and more examples, see the Chinese doc [AGENT_GUIDE.md](./AGENT_GUIDE.md).
---
## Configuration
| Variable | Default | Description |
|----------|---------|-------------|
| `TASKPULSE_DB_HOST` | localhost | MySQL host |
| `TASKPULSE_DB_PORT` | 3306 | MySQL port |
| `TASKPULSE_DB_USER` | dbuser | Database user |
| `TASKPULSE_DB_PASSWORD` | - | Database password |
| `TASKPULSE_DB_NAME` | taskpulse | Database name |
| `TASKPULSE_DEBUG` | true | Debug mode (false in production) |
| `TASKPULSE_SECRET_KEY` | - | JWT signing key |
| `TASKPULSE_SMTP_*` | - | Email notification config |
See `backend/.env.example` for the full list.
---
## API Overview
### Agents
| Method | Path | Description |
|--------|------|-------------|
| POST | `/api/agents` | Register an agent (returns API Key) |
| **POST** | **`/api/agents/register-with-tasks`** | **🌟 Batch register agent + tasks** |
| GET | `/api/agents` | List all agents |
| PUT | `/api/agents/{id}` | Update agent name/description |
| DELETE | `/api/agents/{id}` | Delete agent (cascades to tasks) |
### Tasks
| Method | Path | Description |
|--------|------|-------------|
| POST | `/api/tasks?agent_id=X` | Create a scheduled task |
| GET | `/api/tasks` | List tasks (`?q=&tags=&status=` filters) |
| PUT | `/api/tasks/{id}` | Update task name/tags/description |
| DELETE | `/api/tasks/{id}` | Delete a task |
### Execution Reporting
| Method | Path | Description |
|--------|------|-------------|
| POST | `/api/tasks/{id}/executions` | **Agent reports execution result** |
| GET | `/api/tasks/{id}/executions` | View execution history (paginated) |
| GET | `/api/tasks/{id}/executions/{eid}` | View single execution detail |
### Dashboard & Config
| Method | Path | Description |
|--------|------|-------------|
| GET | `/api/dashboard/summary` | Dashboard summary stats |
| GET | `/api/dashboard/tasks` | Dashboard full task view |
| GET | `/api/system/config` | Get system config |
| PUT | `/api/system/config` | Update system config (Base URL) |
### Alerts & Notifications
| Method | Path | Description |
|--------|------|-------------|
| GET | `/api/alerts` | List alerts |
| POST | `/api/alerts/{id}/acknowledge` | Acknowledge an alert |
| POST | `/api/notification-channels` | Add notification channel |
Full OpenAPI docs at `/docs` (Swagger UI).
---
## Project Structure
```
taskpulse/
├── backend/
│ ├── app/
│ │ ├── main.py # FastAPI entry + SPA static files + lifecycle
│ │ ├── config.py # Environment variables (pydantic-settings)
│ │ ├── database.py # Async MySQL connection
│ │ ├── models/ # SQLAlchemy models (6 tables)
│ │ ├── schemas/ # Pydantic request/response models
│ │ ├── api/ # REST API routes (7 modules)
│ │ └── services/ # Business logic layer
│ │ ├── scheduler.py # ⏰ Background scheduler (timeout detection)
│ │ └── notification.py # 📢 Feishu/Email/Webhook sender
│ ├── alembic/ # DB migrations
│ └── requirements.txt
├── frontend/
│ ├── src/
│ │ ├── views/ # 6 pages (dark theme)
│ │ ├── router/ # Route guards + login redirect
│ │ ├── api/ # Axios client + 401 interceptor
│ │ └── assets/ # Global styles + Element Plus overrides
│ └── package.json
├── AGENT_GUIDE.md # Agent API guide (Chinese)
├── README.md # Documentation (Chinese)
└── README.en.md # Documentation (English)
```
---
## License
MIT License © 2026
+191 -112
View File
@@ -1,28 +1,96 @@
# TaskPulse — AI Agent Task Monitor <picture src="https://img.shields.io/badge/python-3.11%2B-blue" alt="Python" /><picture src="https://img.shields.io/badge/vue-3.4%2B-4FC08D" alt="Vue" /><picture src="https://img.shields.io/badge/license-MIT-green" alt="License" />
统一看板,监控 AI Agent 定时任务的运行状态、执行历史,并在任务超时未执行时发送告警通知。 # TaskPulse — AI Agent 定时任务监控系统
**线上地址**: https://task.pags.cn > **让 AI Agent 自己汇报工作,你只需要打开看板看一眼。**
TaskPulse 是一个面向 AI Agent 的定时任务跟踪与监控平台。它不是传统的"你去填表创建任务"的后台管理系统,而是**给 AI Agent 一份 API 说明书,让 Agent 自主注册、自动汇报**——你只需要在统一的 Dashboard 上看全局状态。
## 系统截图
<img src="https://pub-44c5bd2a850e4bc7aab6f5f8701493fd.r2.dev/images/111.png"></img>
[🌐 English](./README.en.md)
---
## 设计理念
```
┌─────────────────────┐
│ TaskPulse Dashboard│
│ 统一状态看板 │
└──────┬──────────┬───┘
│ │
┌─────────────────┘ └─────────────────┐
▼ ▼
┌─────────────────┐ ┌──────────────────────┐
│ AI Agent A │ 注册任务 → 定时执行 │ AI Agent B │
│ └─ 数据同步 │ ← 汇报结果 + 日志 │ └─ 订单采集 │
│ └─ 报表生成 │ │ └─ 库存同步 │
└─────────────────┘ └──────────────────────┘
```
**核心理念:AI 原生接入**
传统定时任务管理需要用户手动填写 cron 表达式、配置参数。但 AI Agent 本身就具备理解和执行能力——只需给 Agent 一份 API 文档,它就能:
1. **自主注册** — 通过一条 API 把自己和所有定时任务登记到系统
2. **按时汇报** — 每次任务执行完毕,主动 POST 结果(含日志、耗时、状态)
3. **自动纠错** — 如果某个任务到点没执行,系统自动告警
人类要做的只有一件事:**打开看板,看一眼全局状态。**
--- ---
## 核心功能 ## 核心功能
- **AI 原生接入** — AI Agent 通过 API 一次性注册自己 + 所有定时任务,无需手动填写表单 ### 🤖 AI 原生接入
- **统一状态看板** — 所有定时任务的运行状态、最近运行时间、结果一目了然 - **一次性批量注册**`POST /api/agents/register-with-tasks` — Agent 在一分钟内完成注册 + 所有任务登记
- **执行汇报** — Agent 执行完任务后主动汇报结果,含详细日志 - **API Key 鉴权**:每个 Agent 独立密钥,汇报时自动校验
- **超时告警** — 任务未按时执行时自动告警 - **无缝重注册**:删除后可重新注册,获取新 Key 继续工作
- **多渠道通知** — 支持飞书 Webhook、邮件、通用 Webhook - **Agent 头像**:名称首字 + 确定性色彩头像,多 Agent 一目了然
- **Base URL 可配置** — 系统设置页面可配置公网访问地址,所有接入指令自动更新
### 📊 统一状态看板
- **概览卡片**:Agent 数量、任务总数、24h 执行量、待处理告警
- **全任务视图**:所有 Agent 的任务集中展示,状态、最近运行、结果一目了然
- **任务标签**:可对任务打标签(如"数据同步"、"重要"),按标签筛选
- **Cron 人类可读**`*/5 * * * *` 自动显示为 "每5分钟"
- **时间本地化**:所有时间自动从 UTC 转为浏览器本地时区
### ⏱ 执行汇报与追踪
- **实时汇报**Agent 执行完任务后 POST 结果,系统记录执行日志
- **执行历史**:每个任务都有完整的时间线,支持分页查看
- **日志查看器**:暗色终端风格,方便排查问题
- **搜索过滤**:按任务名称、标签、状态快速筛选
### 🚨 超时告警与通知
- **自动检测**:后台调度器每 60 秒扫描未按时执行的任务
- **宽容窗口**:每个任务可单独配置容忍时间,避免误报
- **多渠道通知**:飞书 Webhook、邮件 SMTP、通用 Webhook
- **告警确认**:已处理的告警可标记确认,闭环管理
### 🎨 现代化界面
- **暗色主题**:深色渐变背景 + 霓虹色点缀,降低视觉疲劳
- **全宽布局**:充分利用屏幕空间
- **响应式**:适配不同分辨率
---
## 技术栈 ## 技术栈
| 层 | 技术 | | 层 | 技术 |
|---|---| |---|---|
| 后端 | Python FastAPI (异步) + Gunicorn (生产) | | 后端框架 | Python FastAPI (异步) |
| 前端 | Vue3 + Element Plus (暗色主题) | | 生产服务 | Gunicorn + Uvicorn Worker |
| 前端框架 | Vue 3 + Element Plus |
| 构建工具 | Vite |
| 数据库 | MySQL 8.0 | | 数据库 | MySQL 8.0 |
| 部署 | 单体应用(FastAPI serve Vue3 构建产物) | | 数据校验 | Pydantic v2 |
| ORM | SQLAlchemy 2.0 (异步) |
| 认证 | JWT (python-jose) + bcrypt |
| 定时检测 | APScheduler (异步) |
| 部署形态 | 单体应用 (FastAPI serve 前端静态文件) |
---
## 快速开始 ## 快速开始
@@ -30,128 +98,138 @@
```bash ```bash
# 1. 克隆项目 # 1. 克隆项目
git clone https://gitea.tatta.cn/Tatta/TaskPulse.git git clone https://github.com/oventh/TaskPulse.git
cd TaskPulse cd TaskPulse
# 2. 后端 - 创建虚拟环境并安装依赖 # 2. 后端
cd backend cd backend
python -m venv .venv python -m venv .venv
# Windows: .venv\Scripts\activate source .venv/bin/activate # Windows: .venv\Scripts\activate
# Linux: source .venv/bin/activate
pip install -r requirements.txt pip install -r requirements.txt
cp .env.example .env # 编辑 .env 配置数据库
# 3. 复制环境变量并修改数据库配置
cp .env.example .env
# 编辑 .env,配置 MySQL 连接信息
# 4. 启动后端开发服务器
uvicorn app.main:app --reload --port 8000 uvicorn app.main:app --reload --port 8000
# 5. 前端(新终端) # 3. 前端(新终端)
cd frontend cd frontend
npm install npm install
npm run dev # 访问 http://localhost:3000 npm run dev # http://localhost:3000
``` ```
> 前端 dev server 自动代理 `/api` 请求到 `localhost:8000` > 前端 `npm run dev` 会代理 `/api` 到 `localhost:8000`
### 生产部署 ### 生产部署
```bash ```bash
# 1. 构建前端 # 1. 构建前端
cd frontend cd frontend && npm install && npm run build
npm install && npm run build
# 2. 部署到服务器 # 2. 部署到服务器
# 将整个项目复制到服务器(排除 .venv 和 node_modules
rsync -avz --exclude='.venv' --exclude='node_modules' --exclude='.git' \ rsync -avz --exclude='.venv' --exclude='node_modules' --exclude='.git' \
./ user@server:/home/openclaw/app/taskpulse/ ./ user@server:/home/app/taskpulse/
# 3. 在服务器上创建虚拟环境并安装依赖 # 3. 安装依赖 + Gunicorn 启动
cd /home/openclaw/app/taskpulse/backend cd /home/app/taskpulse/backend
python3 -m venv .venv python3 -m venv .venv && .venv/bin/pip install -r requirements.txt
.venv/bin/pip install -r requirements.txt
# 4. 使用 Gunicorn 启动
.venv/bin/gunicorn app.main:app \ .venv/bin/gunicorn app.main:app \
-k uvicorn.workers.UvicornWorker \ -k uvicorn.workers.UvicornWorker \
-b 0.0.0.0:8000 \ -b 0.0.0.0:8000 -w 2 --preload --daemon
-w 2 \
--access-logfile /var/log/taskpulse/access.log \
--error-logfile /var/log/taskpulse/error.log \
--daemon
``` ```
## 环境变量 ---
复制 `backend/.env.example``backend/.env` 并按需修改: ## Agent 接入指南
| 变量 | 默认值 | 说明 | 让 AI Agent 自动接入只需 3 步:
|------|--------|------|
| TASKPULSE_DB_HOST | localhost | MySQL 主机 |
| TASKPULSE_DB_PORT | 3306 | MySQL 端口 |
| TASKPULSE_DB_USER | dbuser | 数据库用户 |
| TASKPULSE_DB_PASSWORD | - | 数据库密码 |
| TASKPULSE_DB_NAME | taskpulse | 数据库名 |
| TASKPULSE_DEBUG | true | 调试模式(生产环境设为 false) |
| TASKPULSE_FEISHU_WEBHOOK_URL | (空) | 飞书 Webhook 地址 |
| TASKPULSE_SMTP_HOST | (空) | SMTP 服务器 |
| TASKPULSE_SMTP_PORT | 587 | SMTP 端口 |
| TASKPULSE_SMTP_USER | (空) | SMTP 用户 |
| TASKPULSE_SMTP_PASSWORD | (空) | SMTP 密码 |
## Agent 接入
详细接入说明见 [AGENT_GUIDE.md](./AGENT_GUIDE.md)。
### 最小接入示例
```python ```python
import requests import requests
BASE = "https://task.pags.cn" # 替换为你的实际地址 BASE = "https://your-domain.com" # 替换为你的实际地址
# 1. AI Agent 注册自己 + 所有定时任务(一次性) # 1. Agent 一次性注册自己 + 所有定时任务
resp = requests.post(f"{BASE}/api/agents/register-with-tasks", json={ resp = requests.post(f"{BASE}/api/agents/register-with-tasks", json={
"name": "my-agent", "name": "my-agent",
"description": "我的 AI Agent", "description": "我的 AI Agent",
"tasks": [ "tasks": [
{"name": "sync-data", "cron_expression": "*/5 * * * *"}, {"name": "sync-data", "cron_expression": "*/5 * * * *"},
{"name": "daily-report", "cron_expression": "0 9 * * *"} {"name": "daily-report", "cron_expression": "0 9 * * *"}
] ]
}) })
agent = resp.json() data = resp.json()
API_KEY = agent["api_key"] # 保存此 Key API_KEY = data["agent"]["api_key"] # 保存此 Key
AGENT_ID = agent["agent"]["id"] AGENT_ID = data["agent"]["id"]
# 2. 每次执行汇报结果 # 2. 每次执行完任务后,汇报结果
requests.post(f"{BASE}/api/tasks/{TASK_ID}/executions", requests.post(f"{BASE}/api/tasks/{TASK_ID}/executions",
headers={"Authorization": f"Bearer {API_KEY}"}, headers={"Authorization": f"Bearer {API_KEY}"},
json={"status": "success", "duration_ms": 1200, "log": "done"}) json={"status": "success", "duration_ms": 1500, "log": "执行完毕"})
``` ```
> 完整接入说明见 [AGENT_GUIDE.md](./AGENT_GUIDE.md),含 curl / Python / 批量注册示例。
---
## 系统配置
| 变量 | 默认值 | 说明 |
|------|--------|------|
| `TASKPULSE_DB_HOST` | localhost | MySQL 主机 |
| `TASKPULSE_DB_PORT` | 3306 | MySQL 端口 |
| `TASKPULSE_DB_USER` | dbuser | 数据库用户 |
| `TASKPULSE_DB_PASSWORD` | - | 数据库密码 |
| `TASKPULSE_DB_NAME` | taskpulse | 数据库名 |
| `TASKPULSE_DEBUG` | true | 调试模式(生产 false |
| `TASKPULSE_SECRET_KEY` | - | JWT 签名密钥 |
| `TASKPULSE_SMTP_*` | - | 邮件通知配置 |
完整变量列表见 `backend/.env.example`
---
## API 概览 ## API 概览
### Agent 管理
| 方法 | 路径 | 说明 | | 方法 | 路径 | 说明 |
|------|------|------| |------|------|------|
| POST | /api/agents | 注册 Agent | | POST | `/api/agents` | 注册 Agent(返回 API Key |
| POST | /api/agents/register-with-tasks | **AI Agent 一次性注册自己 + 所有任务** | | **POST** | **`/api/agents/register-with-tasks`** | **🌟 Agent + 任务一次性批量注册** |
| GET | /api/agents | 列出所有 Agent | | GET | `/api/agents` | 列出所有 Agent |
| POST | /api/tasks?agent_id=X | 注册定时任务 | | PUT | `/api/agents/{id}` | 修改 Agent 名称/描述 |
| GET | /api/tasks | 列出所有任务 | | DELETE | `/api/agents/{id}` | 删除 Agent(关联任务一并删除) |
| DELETE | /api/tasks/{id} | 删除任务 |
| POST | /api/tasks/{id}/executions | 汇报执行结果 |
| GET | /api/tasks/{id}/executions | 查看执行历史 |
| GET | /api/dashboard/summary | 看板概览 |
| GET | /api/dashboard/tasks | 看板任务视图 |
| GET | /api/system/config | 读取系统配置(Base URL |
| PUT | /api/system/config | 更新系统配置 |
| GET | /api/alerts | 告警列表 |
| POST | /api/alerts/{id}/acknowledge | 确认告警 |
| POST | /api/notification-channels | 添加通知渠道 |
| GET | /api/notification-channels | 列出通知渠道 |
完整 API 文档见 `/docs`Swagger UI)。 ### 定时任务
| 方法 | 路径 | 说明 |
|------|------|------|
| POST | `/api/tasks?agent_id=X` | 注册定时任务 |
| GET | `/api/tasks` | 任务列表(支持 `?q=&tags=&status=` 搜索) |
| PUT | `/api/tasks/{id}` | 修改任务名称/标签/描述 |
| DELETE | `/api/tasks/{id}` | 删除任务 |
### 执行汇报
| 方法 | 路径 | 说明 |
|------|------|------|
| POST | `/api/tasks/{id}/executions` | Agent 汇报执行结果 |
| GET | `/api/tasks/{id}/executions` | 查看执行历史(分页) |
| GET | `/api/tasks/{id}/executions/{eid}` | 查看单次执行详情 |
### 看板与配置
| 方法 | 路径 | 说明 |
|------|------|------|
| GET | `/api/dashboard/summary` | 看板概览统计数据 |
| GET | `/api/dashboard/tasks` | 看板全任务视图 |
| GET | `/api/system/config` | 获取系统配置 |
| PUT | `/api/system/config` | 更新系统配置(Base URL |
### 告警与通知
| 方法 | 路径 | 说明 |
|------|------|------|
| GET | `/api/alerts` | 告警列表 |
| POST | `/api/alerts/{id}/acknowledge` | 确认告警 |
| POST | `/api/notification-channels` | 添加通知渠道(飞书/邮件/Webhook) |
完整 OpenAPI 文档在 `/docs`Swagger UI)查看。
---
## 项目结构 ## 项目结构
@@ -159,35 +237,36 @@ requests.post(f"{BASE}/api/tasks/{TASK_ID}/executions",
taskpulse/ taskpulse/
├── backend/ ├── backend/
│ ├── app/ │ ├── app/
│ │ ├── main.py # FastAPI 入口 + SPA 静态文件服务 │ │ ├── main.py # FastAPI 入口 + SPA 静态文件 + 生命周期
│ │ ├── config.py # 环境变量配置 │ │ ├── config.py # 环境变量配置 (pydantic-settings)
│ │ ├── database.py # 异步数据库连接 │ │ ├── database.py # 异步 MySQL 连接
│ │ ├── models/ # SQLAlchemy 数据模型 │ │ ├── models/ # SQLAlchemy 数据模型6 张表)
│ │ │ ├── agent.py # Agent(含 API Key 自动生成) │ │ │ ├── agent.py # Agent + API Key
│ │ │ ├── task.py # 定时任务cron、宽容窗口) │ │ │ ├── task.py # 定时任务 + cron + tags
│ │ │ ├── execution.py # 执行记录(日志、结果、耗时) │ │ │ ├── execution.py # 执行记录 + 日志
│ │ │ ├── notification.py # 通知渠道告警规则、告警历史 │ │ │ ├── notification.py # 通知渠道 + 告警规则 + 告警
│ │ │ ── system_config.py # 系统配置key-value │ │ │ ── system_config.py # 系统配置 (key-value)
│ │ ├── schemas/ # Pydantic 数据校验 │ │ │ └── user.py # 看板登录用户
│ │ ├── api/ # API 路由 │ │ ├── schemas/ # Pydantic 请求/响应模型
│ │ ├── agents.py # Agent CRUD + 批量注册 │ │ ├── api/ # REST API 路由(7 个模块)
│ │ │ ├── tasks.py # 任务 CRUD │ │ └── services/ # 业务逻辑层
│ │ ├── executions.py # 执行汇报 + 历史查询 │ │ ├── scheduler.py # ⏰ 后台调度器(超时检测)
│ │ ── notifications.py # 通知渠道 + 告警规则 + 告警确认 │ │ ── notification.py # 📢 飞书/邮件/Webhook 发送
│ │ │ ├── dashboard.py # 看板概览统计
│ │ │ └── system.py # 系统配置
│ │ └── services/ # 业务逻辑
│ │ ├── agent.py / task.py / execution.py
│ │ ├── scheduler.py # 后台扫描:检测超时任务→生成告警→通知
│ │ └── notification.py # 飞书 / 邮件 / Webhook 通知发送
│ ├── alembic/ # 数据库迁移 │ ├── alembic/ # 数据库迁移
│ └── requirements.txt │ └── requirements.txt
├── frontend/ ├── frontend/
│ ├── src/ │ ├── src/
│ │ ├── views/ # 页面组件(暗色主题) │ │ ├── views/ # 6 个页面(暗色主题)
│ │ ├── router/ # 路由 │ │ ├── router/ # 路由守卫 + 登录跳转
│ │ ── api/ # Axios API 客户端 │ │ ── api/ # Axios 客户端 + 401 拦截
│ │ └── assets/ # 全局样式 + Element Plus 覆盖
│ └── package.json │ └── package.json
├── AGENT_GUIDE.md # Agent 接入指南 ├── AGENT_GUIDE.md # Agent API 接入说明
└── README.md └── README.md
``` ```
---
## 许可证
MIT License © 2026
+1
View File
@@ -48,6 +48,7 @@ def upgrade() -> None:
sa.Column("cron_expression", sa.String(64), nullable=False), sa.Column("cron_expression", sa.String(64), nullable=False),
sa.Column("grace_period", sa.Integer, default=300), sa.Column("grace_period", sa.Integer, default=300),
sa.Column("status", sa.String(32), default="active"), sa.Column("status", sa.String(32), default="active"),
sa.Column("tags", sa.Text, nullable=True),
sa.Column("last_run_at", sa.DateTime, nullable=True), sa.Column("last_run_at", sa.DateTime, nullable=True),
sa.Column("last_run_result", sa.String(32), nullable=True), sa.Column("last_run_result", sa.String(32), nullable=True),
sa.Column("next_run_at", sa.DateTime, nullable=True), sa.Column("next_run_at", sa.DateTime, nullable=True),
+11
View File
@@ -59,6 +59,17 @@ async def heartbeat(agent_id: int, db: AsyncSession = Depends(get_db)):
return AgentOut(**{**agent.__dict__, "task_count": cnt}) return AgentOut(**{**agent.__dict__, "task_count": cnt})
@router.delete("/{agent_id}", status_code=204)
async def delete_agent(agent_id: int, db: AsyncSession = Depends(get_db)):
svc = AgentService(db)
agent = await svc.get(agent_id)
if not agent:
raise HTTPException(404, "Agent not found")
# Delete all tasks first, then the agent (cascade)
await svc.delete(agent_id)
await db.commit()
# ── Batch registration: AI agent self-registers with all its tasks ───── # ── Batch registration: AI agent self-registers with all its tasks ─────
@router.post("/register-with-tasks", response_model=AgentBatchRegisterResult, status_code=201) @router.post("/register-with-tasks", response_model=AgentBatchRegisterResult, status_code=201)
+34 -5
View File
@@ -1,15 +1,25 @@
"""API router — TaskExecution (reporting + query).""" """API router — TaskExecution (reporting + query)."""
from fastapi import APIRouter, Depends, HTTPException, Query from fastapi import APIRouter, Depends, HTTPException, Query
from pydantic import BaseModel
from sqlalchemy import select, func
from sqlalchemy.ext.asyncio import AsyncSession from sqlalchemy.ext.asyncio import AsyncSession
from app.database import get_db from app.database import get_db
from app.schemas import ExecutionOut, ExecutionReport from app.schemas import ExecutionOut, ExecutionReport
from app.services import ExecutionService, TaskService, AgentService from app.services import ExecutionService, TaskService, AgentService
from app.models import TaskExecution
router = APIRouter(prefix="/api/tasks/{task_id}/executions", tags=["executions"]) router = APIRouter(prefix="/api/tasks/{task_id}/executions", tags=["executions"])
class PaginatedExecutions(BaseModel):
items: list[ExecutionOut]
total: int
page: int
page_size: int
@router.post("", response_model=ExecutionOut, status_code=201) @router.post("", response_model=ExecutionOut, status_code=201)
async def report_execution(task_id: int, body: ExecutionReport, async def report_execution(task_id: int, body: ExecutionReport,
db: AsyncSession = Depends(get_db)): db: AsyncSession = Depends(get_db)):
@@ -20,6 +30,14 @@ async def report_execution(task_id: int, body: ExecutionReport,
if not task: if not task:
raise HTTPException(404, "Task not found") raise HTTPException(404, "Task not found")
# Verify agent exists and is active
agent_svc = AgentService(db)
agent = await agent_svc.get(task.agent_id)
if not agent:
raise HTTPException(410, "Agent已被删除,请重新注册")
if agent.status != "active":
raise HTTPException(403, "Agent已停用,无法汇报")
exec_svc = ExecutionService(db) exec_svc = ExecutionService(db)
record = await exec_svc.report_result( record = await exec_svc.report_result(
task_id=task_id, task_id=task_id,
@@ -38,16 +56,27 @@ async def report_execution(task_id: int, body: ExecutionReport,
return ExecutionOut(**record.__dict__) return ExecutionOut(**record.__dict__)
@router.get("", response_model=list[ExecutionOut]) @router.get("", response_model=PaginatedExecutions)
async def list_executions( async def list_executions(
task_id: int, task_id: int,
limit: int = Query(50, ge=1, le=200), page: int = Query(1, ge=1),
offset: int = Query(0, ge=0), page_size: int = Query(20, ge=1, le=100),
db: AsyncSession = Depends(get_db), db: AsyncSession = Depends(get_db),
): ):
exec_svc = ExecutionService(db) exec_svc = ExecutionService(db)
records = await exec_svc.get_executions(task_id, limit=limit, offset=offset) offset = (page - 1) * page_size
return [ExecutionOut(**r.__dict__) for r in records] records = await exec_svc.get_executions(task_id, limit=page_size, offset=offset)
# Get total count
count_stmt = select(func.count()).select_from(TaskExecution).where(TaskExecution.task_id == task_id)
total = (await db.execute(count_stmt)).scalar() or 0
return PaginatedExecutions(
items=[ExecutionOut(**r.__dict__) for r in records],
total=total,
page=page,
page_size=page_size,
)
@router.get("/recent", response_model=list[ExecutionOut]) @router.get("/recent", response_model=list[ExecutionOut])
+65 -14
View File
@@ -1,13 +1,28 @@
"""API router — Task management.""" """API router — Task management."""
from fastapi import APIRouter, Depends, HTTPException import json
from fastapi import APIRouter, Depends, HTTPException, Query
from sqlalchemy import select, or_
from sqlalchemy.ext.asyncio import AsyncSession from sqlalchemy.ext.asyncio import AsyncSession
from app.database import get_db from app.database import get_db
from app.schemas import TaskCreate, TaskOut, TaskUpdate from app.schemas import TaskCreate, TaskOut, TaskUpdate
from app.services import TaskService, AgentService from app.services import TaskService, AgentService
from app.models import ScheduledTask
router = APIRouter(prefix="/api/tasks", tags=["tasks"]) router = APIRouter(prefix="/api/tasks", tags=["tags"])
def _task_to_out(task, agent_name: str = "") -> dict:
"""Convert task model to TaskOut dict, handling tags JSON deserialization."""
data = {**task.__dict__}
try:
data["tags"] = json.loads(data.get("tags", "[]"))
except (json.JSONDecodeError, TypeError):
data["tags"] = []
data["agent_name"] = agent_name
return data
@router.post("", response_model=TaskOut, status_code=201) @router.post("", response_model=TaskOut, status_code=201)
@@ -25,23 +40,48 @@ async def create_task(body: TaskCreate, agent_id: int, db: AsyncSession = Depend
description=body.description, description=body.description,
grace_period=body.grace_period, grace_period=body.grace_period,
) )
return TaskOut(**{**task.__dict__, "agent_name": agent.name}) # Store tags
if body.tags:
task.tags = json.dumps(body.tags, ensure_ascii=False)
await db.flush()
await db.refresh(task)
return TaskOut(**_task_to_out(task, agent.name))
@router.get("", response_model=list[TaskOut]) @router.get("", response_model=list[TaskOut])
async def list_tasks(agent_id: int | None = None, db: AsyncSession = Depends(get_db)): async def list_tasks(
"""List all tasks, optionally filtered by agent_id.""" agent_id: int | None = Query(None),
q: str | None = Query(None, description="关键词搜索(任务名称)"),
tags: str | None = Query(None, description="标签筛选,逗号分隔"),
status: str | None = Query(None),
db: AsyncSession = Depends(get_db),
):
"""List all tasks, with optional search and filters."""
svc = TaskService(db) svc = TaskService(db)
agent_svc = AgentService(db) agent_svc = AgentService(db)
# Build query
stmt = select(ScheduledTask)
if agent_id: if agent_id:
tasks = await svc.list_by_agent(agent_id) stmt = stmt.where(ScheduledTask.agent_id == agent_id)
else: if status:
tasks = await svc.list_all() stmt = stmt.where(ScheduledTask.status == status)
result = [] if q:
stmt = stmt.where(ScheduledTask.name.like(f"%{q}%"))
if tags:
tag_list = [t.strip() for t in tags.split(",") if t.strip()]
for tag in tag_list:
stmt = stmt.where(ScheduledTask.tags.like(f'%"{tag}"%'))
stmt = stmt.order_by(ScheduledTask.id)
result = await db.execute(stmt)
tasks = list(result.scalars().all())
output = []
for t in tasks: for t in tasks:
agent = await agent_svc.get(t.agent_id) agent = await agent_svc.get(t.agent_id)
result.append(TaskOut(**{**t.__dict__, "agent_name": agent.name if agent else ""})) output.append(TaskOut(**_task_to_out(t, agent.name if agent else "")))
return result return output
@router.get("/{task_id}", response_model=TaskOut) @router.get("/{task_id}", response_model=TaskOut)
@@ -52,18 +92,29 @@ async def get_task(task_id: int, db: AsyncSession = Depends(get_db)):
if not task: if not task:
raise HTTPException(404, "Task not found") raise HTTPException(404, "Task not found")
agent = await agent_svc.get(task.agent_id) agent = await agent_svc.get(task.agent_id)
return TaskOut(**{**task.__dict__, "agent_name": agent.name if agent else ""}) return TaskOut(**_task_to_out(task, agent.name if agent else ""))
@router.put("/{task_id}", response_model=TaskOut) @router.put("/{task_id}", response_model=TaskOut)
async def update_task(task_id: int, body: TaskUpdate, db: AsyncSession = Depends(get_db)): async def update_task(task_id: int, body: TaskUpdate, db: AsyncSession = Depends(get_db)):
svc = TaskService(db) svc = TaskService(db)
agent_svc = AgentService(db) agent_svc = AgentService(db)
task = await svc.update(task_id, **body.model_dump(exclude_none=True))
# Handle tags separately (JSON serialization)
update_data = body.model_dump(exclude_none=True)
tags_list = update_data.pop("tags", None)
task = await svc.update(task_id, **update_data)
if not task: if not task:
raise HTTPException(404, "Task not found") raise HTTPException(404, "Task not found")
if tags_list is not None:
task.tags = json.dumps(tags_list, ensure_ascii=False)
await db.flush()
await db.refresh(task)
agent = await agent_svc.get(task.agent_id) agent = await agent_svc.get(task.agent_id)
return TaskOut(**{**task.__dict__, "agent_name": agent.name if agent else ""}) return TaskOut(**_task_to_out(task, agent.name if agent else ""))
@router.delete("/{task_id}", status_code=204) @router.delete("/{task_id}", status_code=204)
+1
View File
@@ -18,6 +18,7 @@ class ScheduledTask(Base):
cron_expression: Mapped[str] = mapped_column(String(64), nullable=False, comment="Cron 表达式, e.g. */5 * * * *") cron_expression: Mapped[str] = mapped_column(String(64), nullable=False, comment="Cron 表达式, e.g. */5 * * * *")
grace_period: Mapped[int] = mapped_column(Integer, default=300, comment="容忍秒数, 超时未执行则告警") grace_period: Mapped[int] = mapped_column(Integer, default=300, comment="容忍秒数, 超时未执行则告警")
status: Mapped[str] = mapped_column(String(32), default="active", comment="active / paused / stopped") status: Mapped[str] = mapped_column(String(32), default="active", comment="active / paused / stopped")
tags: Mapped[str | None] = mapped_column(Text, nullable=True, comment="标签 JSON 数组, e.g. [\"数据同步\",\"重要\"]")
# 冗余字段方便查询 # 冗余字段方便查询
last_run_at: Mapped[datetime | None] = mapped_column(DateTime, nullable=True, comment="最近一次执行时间") last_run_at: Mapped[datetime | None] = mapped_column(DateTime, nullable=True, comment="最近一次执行时间")
+3
View File
@@ -10,6 +10,7 @@ class TaskCreate(BaseModel):
description: str = Field(default="") description: str = Field(default="")
cron_expression: str = Field(..., max_length=64) cron_expression: str = Field(..., max_length=64)
grace_period: int = Field(default=300, ge=0) grace_period: int = Field(default=300, ge=0)
tags: list[str] = Field(default_factory=list)
class TaskUpdate(BaseModel): class TaskUpdate(BaseModel):
@@ -18,6 +19,7 @@ class TaskUpdate(BaseModel):
cron_expression: str | None = None cron_expression: str | None = None
grace_period: int | None = None grace_period: int | None = None
status: str | None = None # active / paused / stopped status: str | None = None # active / paused / stopped
tags: list[str] | None = None
class TaskOut(BaseModel): class TaskOut(BaseModel):
@@ -29,6 +31,7 @@ class TaskOut(BaseModel):
cron_expression: str cron_expression: str
grace_period: int grace_period: int
status: str status: str
tags: list[str] = Field(default_factory=list)
last_run_at: datetime | None = None last_run_at: datetime | None = None
last_run_result: str | None = None last_run_result: str | None = None
next_run_at: datetime | None = None next_run_at: datetime | None = None
+8
View File
@@ -54,3 +54,11 @@ class AgentService:
stmt = select(func.count(ScheduledTask.id)).where(ScheduledTask.agent_id == agent_id) stmt = select(func.count(ScheduledTask.id)).where(ScheduledTask.agent_id == agent_id)
result = await self.db.execute(stmt) result = await self.db.execute(stmt)
return result.scalar() or 0 return result.scalar() or 0
async def delete(self, agent_id: int) -> bool:
agent = await self.get(agent_id)
if agent is None:
return False
await self.db.delete(agent)
await self.db.flush()
return True
+10
View File
@@ -10,6 +10,7 @@
"dependencies": { "dependencies": {
"@element-plus/icons-vue": "^2.3.1", "@element-plus/icons-vue": "^2.3.1",
"axios": "^1.7.0", "axios": "^1.7.0",
"cronstrue": "^3.14.0",
"dayjs": "^1.11.0", "dayjs": "^1.11.0",
"echarts": "^5.5.0", "echarts": "^5.5.0",
"element-plus": "^2.7.0", "element-plus": "^2.7.0",
@@ -1079,6 +1080,15 @@
"node": ">= 0.8" "node": ">= 0.8"
} }
}, },
"node_modules/cronstrue": {
"version": "3.14.0",
"resolved": "https://registry.npmjs.org/cronstrue/-/cronstrue-3.14.0.tgz",
"integrity": "sha512-XnW4vuK/jPJjmTyDWiej1Zq36Od7ITwxaV2O1pzHZuyMVvdy7NAvyvIBzybt+idqSpfqYuoDG7uf/ocGtJVWxA==",
"license": "MIT",
"bin": {
"cronstrue": "bin/cli.js"
}
},
"node_modules/csstype": { "node_modules/csstype": {
"version": "3.2.3", "version": "3.2.3",
"license": "MIT" "license": "MIT"
+5 -4
View File
@@ -9,14 +9,15 @@
"preview": "vite preview" "preview": "vite preview"
}, },
"dependencies": { "dependencies": {
"vue": "^3.4.0",
"vue-router": "^4.3.0",
"element-plus": "^2.7.0",
"@element-plus/icons-vue": "^2.3.1", "@element-plus/icons-vue": "^2.3.1",
"axios": "^1.7.0", "axios": "^1.7.0",
"cronstrue": "^3.14.0",
"dayjs": "^1.11.0", "dayjs": "^1.11.0",
"echarts": "^5.5.0", "echarts": "^5.5.0",
"vue-echarts": "^6.7.0" "element-plus": "^2.7.0",
"vue": "^3.4.0",
"vue-echarts": "^6.7.0",
"vue-router": "^4.3.0"
}, },
"devDependencies": { "devDependencies": {
"@vitejs/plugin-vue": "^5.0.0", "@vitejs/plugin-vue": "^5.0.0",
+2 -1
View File
@@ -42,9 +42,10 @@ export const listAgents = () => api.get('/agents')
export const getAgent = (id) => api.get(`/agents/${id}`) export const getAgent = (id) => api.get(`/agents/${id}`)
export const createAgent = (data) => api.post('/agents', data) export const createAgent = (data) => api.post('/agents', data)
export const updateAgent = (id, data) => api.put(`/agents/${id}`, data) export const updateAgent = (id, data) => api.put(`/agents/${id}`, data)
export const deleteAgent = (id) => api.delete(`/agents/${id}`)
// Tasks // Tasks
export const listTasks = (agentId) => api.get('/tasks', { params: { agent_id: agentId } }) export const listTasks = (params) => api.get('/tasks', { params })
export const getTask = (id) => api.get(`/tasks/${id}`) export const getTask = (id) => api.get(`/tasks/${id}`)
export const createTask = (agentId, data) => api.post('/tasks', data, { params: { agent_id: agentId } }) export const createTask = (agentId, data) => api.post('/tasks', data, { params: { agent_id: agentId } })
export const updateTask = (id, data) => api.put(`/tasks/${id}`, data) export const updateTask = (id, data) => api.put(`/tasks/${id}`, data)
+30
View File
@@ -230,3 +230,33 @@ html, body, #app {
border: 1px solid var(--border-color) !important; border: 1px solid var(--border-color) !important;
backdrop-filter: blur(12px) !important; backdrop-filter: blur(12px) !important;
} }
/* Agent Avatar */
.agent-avatar {
display: inline-flex;
align-items: center;
justify-content: center;
width: 28px;
height: 28px;
border-radius: 50%;
color: #fff;
font-size: 12px;
font-weight: 600;
flex-shrink: 0;
vertical-align: middle;
margin-right: 6px;
}
.agent-avatar-sm {
display: inline-flex;
align-items: center;
justify-content: center;
width: 22px;
height: 22px;
border-radius: 50%;
color: #fff;
font-size: 10px;
font-weight: 600;
flex-shrink: 0;
vertical-align: middle;
margin-right: 4px;
}
+117
View File
@@ -3,10 +3,15 @@ import ElementPlus from 'element-plus'
import 'element-plus/dist/index.css' import 'element-plus/dist/index.css'
import * as ElementPlusIconsVue from '@element-plus/icons-vue' import * as ElementPlusIconsVue from '@element-plus/icons-vue'
import zhCn from 'element-plus/es/locale/lang/zh-cn' import zhCn from 'element-plus/es/locale/lang/zh-cn'
import dayjs from 'dayjs'
import utc from 'dayjs/plugin/utc'
import App from './App.vue' import App from './App.vue'
import router from './router' import router from './router'
import './assets/style.css' import './assets/style.css'
// Extend dayjs with UTC plugin for timezone conversion
dayjs.extend(utc)
const app = createApp(App) const app = createApp(App)
// Register all Element Plus icons // Register all Element Plus icons
@@ -14,6 +19,118 @@ for (const [key, component] of Object.entries(ElementPlusIconsVue)) {
app.component(key, component) app.component(key, component)
} }
// Global helper: cron expression to readable Chinese
app.config.globalProperties.$cron = (expr) => {
if (!expr) return ''
try {
const parts = expr.trim().split(/\s+/)
if (parts.length !== 5) return expr
const [min, hour, dom, month, dow] = parts
// Helper: describe a cron field
const descField = (val, unit) => {
if (val === '*') return `${unit}`
if (val.startsWith('*/')) return `${val.slice(2)}${unit}`
if (val.includes(',')) {
const items = val.split(',').map(v => v.padStart(2, '0')).join('分、')
return `${items}`
}
if (val.includes('-')) {
const [from, to] = val.split('-')
return `${from.padStart(2,'0')}分到${to.padStart(2,'0')}`
}
return `${val.padStart(2,'0')}${unit}`
}
const allStar = (...args) => args.every(v => v === '*')
// Every minute
if (min === '*' && hour === '*' && allStar(dom, month, dow)) return '每分钟'
// Every N minutes (always)
if (min.startsWith('*/') && hour === '*' && allStar(dom, month, dow)) {
return `${min.slice(2)}分钟`
}
// Every N hours (at min 0)
if (min === '0' && hour.startsWith('*/') && allStar(dom, month, dow)) {
return `${hour.slice(2)}小时`
}
// Specific minutes every hour
if (min !== '*' && !min.startsWith('*/') && hour === '*' && allStar(dom, month, dow)) {
if (min.includes(',')) {
const items = min.split(',').map(v => v.padStart(2, '0'))
return `每小时 ${items.join('、')}`
}
return `每小时 第${min.padStart(2,'0')}分钟`
}
// Daily at specific time(s)
if (allStar(dom, month, dow)) {
if (min.startsWith('*/')) {
const interval = min.slice(2)
if (hour.includes('-')) {
const [hFrom, hTo] = hour.split('-').map(h => h.padStart(2,'0'))
return `${interval}分钟(${hFrom}:00-${hTo}:00`
}
if (hour !== '*') return `${interval}分钟(${hour.padStart(2,'0')}点)`
return `${interval}分钟`
}
if (hour.includes(',')) {
const times = hour.split(',').map(h => `${h.padStart(2,'0')}:${min.padStart(2,'0')}`)
return `每天 ${times.join('、')}`
}
if (hour.includes('-')) {
return `每天 ${hour.split('-')[0].padStart(2,'0')}:${min.padStart(2,'0')}${hour.split('-')[1].padStart(2,'0')}:${min.padStart(2,'0')}`
}
return `每天 ${hour.padStart(2,'0')}:${min.padStart(2,'0')}`
}
// Weekly
const dowNames = ['日', '一', '二', '三', '四', '五', '六', '日']
if (allStar(dom, month) && dow !== '*') {
if (dow.includes(',')) {
const days = dow.split(',').map(d => `${dowNames[parseInt(d)]}`)
return `${days.join('、')} ${hour.padStart(2,'0')}:${min.padStart(2,'0')}`
}
if (dow.includes('-')) {
const [from, to] = dow.split('-')
return `每周${dowNames[parseInt(from)]}${dowNames[parseInt(to)]} ${hour.padStart(2,'0')}:${min.padStart(2,'0')}`
}
return `每周${dowNames[parseInt(dow)]} ${hour.padStart(2,'0')}:${min.padStart(2,'0')}`
}
// Every N minutes within specific hours (e.g., */15 9-18 * * *)
if (min.startsWith('*/') && !allStar(dom, month, dow)) {
const interval = min.slice(2)
let hourDesc = ''
if (hour !== '*') hourDesc = descField(hour, '点')
return `${interval}分钟${hourDesc ? ` (${hourDesc})` : ''}`
}
return expr
} catch {
return expr
}
}
// Global helper: agent name → avatar { letter, bg color }
const AVATAR_COLORS = [
'#667eea', '#22c55e', '#f59e0b', '#ef4444', '#ec4899',
'#8b5cf6', '#06b6d4', '#f97316', '#14b8a6', '#a855f7',
'#6366f1', '#84cc16', '#e11d48', '#0ea5e9',
]
app.config.globalProperties.$avatar = (name) => {
if (!name) return { letter: '?', bg: AVATAR_COLORS[0] }
const letter = name.trim()[0]
// Deterministic color from name
let hash = 0
for (let i = 0; i < name.length; i++) hash = ((hash << 5) - hash) + name.charCodeAt(i)
const idx = Math.abs(hash) % AVATAR_COLORS.length
return { letter, bg: AVATAR_COLORS[idx] }
}
app.use(ElementPlus, { locale: zhCn }) app.use(ElementPlus, { locale: zhCn })
app.use(router) app.use(router)
app.mount('#app') app.mount('#app')
+184 -5
View File
@@ -43,7 +43,10 @@
<tbody> <tbody>
<tr v-for="a in agents" :key="a.id"> <tr v-for="a in agents" :key="a.id">
<td class="cell-mono">#{{ a.id }}</td> <td class="cell-mono">#{{ a.id }}</td>
<td class="cell-name">{{ a.name }}</td> <td>
<span class="agent-avatar" :style="{background: $avatar(a.name).bg}">{{ $avatar(a.name).letter }}</span>
<span class="cell-name">{{ a.name }}</span>
</td>
<td class="cell-muted">{{ a.description || '—' }}</td> <td class="cell-muted">{{ a.description || '—' }}</td>
<td> <td>
<span class="tag" :class="a.status === 'active' ? 'tag-green' : 'tag-gray'"> <span class="tag" :class="a.status === 'active' ? 'tag-green' : 'tag-gray'">
@@ -51,7 +54,7 @@
</span> </span>
</td> </td>
<td class="cell-mono">{{ a.task_count }}</td> <td class="cell-mono">{{ a.task_count }}</td>
<td class="cell-mono">{{ a.last_heartbeat_at ? beijing(a.last_heartbeat_at) : '—' }}</td> <td class="cell-mono">{{ a.last_heartbeat_at ? dayjs.utc(a.last_heartbeat_at).local().format('MM-DD HH:mm') : '—' }}</td>
<td> <td>
<code class="cell-key">{{ a.api_key.substring(0, 16) }}...</code> <code class="cell-key">{{ a.api_key.substring(0, 16) }}...</code>
<button class="btn-icon" @click="copyKey(a.api_key)" title="复制 API Key"> <button class="btn-icon" @click="copyKey(a.api_key)" title="复制 API Key">
@@ -60,6 +63,8 @@
</td> </td>
<td> <td>
<router-link to="/tasks" class="cell-link">任务</router-link> <router-link to="/tasks" class="cell-link">任务</router-link>
<button class="cell-link-btn" style="color:var(--accent-1)" @click="showEdit(a)">编辑</button>
<button class="cell-link-btn" style="color:var(--danger)" @click="confirmDelete(a)">删除</button>
</td> </td>
</tr> </tr>
<tr v-if="agents.length === 0"> <tr v-if="agents.length === 0">
@@ -70,12 +75,65 @@
</div> </div>
</div> </div>
</div> </div>
<!-- Edit Agent Modal -->
<div v-if="editAgent" class="modal-overlay" @click.self="editAgent = null">
<div class="modal">
<div class="modal-header">
<h4>编辑 Agent</h4>
<button class="modal-close" @click="editAgent = null">
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><line x1="18" y1="6" x2="6" y2="18"/><line x1="6" y1="6" x2="18" y2="18"/></svg>
</button>
</div>
<div class="modal-body">
<div class="field">
<label>Agent 名称</label>
<div class="field-avatar-row">
<span class="agent-avatar-lg" :style="{background: $avatar(editForm.name).bg}">{{ $avatar(editForm.name).letter }}</span>
<input v-model="editForm.name" class="input" placeholder="Agent 名称" />
</div>
</div>
<div class="field">
<label>描述</label>
<input v-model="editForm.description" class="input" placeholder="描述" />
</div>
</div>
<div class="modal-footer">
<button class="btn-secondary" @click="editAgent = null">取消</button>
<button class="btn-primary" @click="handleEdit">保存</button>
</div>
</div>
</div>
<!-- Delete Confirm Modal -->
<div v-if="deleteTarget" class="modal-overlay" @click.self="deleteTarget = null">
<div class="modal" style="width:400px">
<div class="modal-header">
<h4>删除 Agent</h4>
<button class="modal-close" @click="deleteTarget = null">
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><line x1="18" y1="6" x2="6" y2="18"/><line x1="6" y1="6" x2="18" y2="18"/></svg>
</button>
</div>
<div class="modal-body">
<p style="color:var(--text-secondary);font-size:14px;line-height:1.6">
确定要删除 Agent <strong style="color:var(--text-primary)">{{ deleteTarget.name }}</strong>
</p>
<p style="color:var(--text-muted);font-size:12px;margin-top:8px">
其关联的定时任务也将一并删除Agent 后续仍可重新注册
</p>
</div>
<div class="modal-footer">
<button class="btn-secondary" @click="deleteTarget = null">取消</button>
<button class="btn-danger" @click="handleDelete">确认删除</button>
</div>
</div>
</div>
</template> </template>
<script setup> <script setup>
import { ref, onMounted, computed } from 'vue' import { ref, onMounted, computed } from 'vue'
import { beijing } from '../utils/time.js' import dayjs from 'dayjs'
import { listAgents, getSystemConfig } from '../api/index.js' import { listAgents, updateAgent, deleteAgent, getSystemConfig } from '../api/index.js'
const agents = ref([]) const agents = ref([])
const tab = ref('curl') const tab = ref('curl')
@@ -90,6 +148,40 @@ const loadData = async () => {
baseUrl.value = cfgRes.data.base_url baseUrl.value = cfgRes.data.base_url
} }
// Edit
const editAgent = ref(null)
const editForm = ref({ name: '', description: '' })
const showEdit = (agent) => {
editForm.value = { name: agent.name, description: agent.description }
editAgent.value = agent
}
const handleEdit = async () => {
try {
await updateAgent(editAgent.value.id, editForm.value)
editAgent.value = null
await loadData()
const { ElMessage } = await import('element-plus')
ElMessage.success('已更新')
} catch (e) {
console.error(e)
}
}
// Delete
const deleteTarget = ref(null)
const confirmDelete = (agent) => { deleteTarget.value = agent }
const handleDelete = async () => {
try {
await deleteAgent(deleteTarget.value.id)
deleteTarget.value = null
await loadData()
const { ElMessage } = await import('element-plus')
ElMessage.success('已删除')
} catch (e) {
console.error(e)
}
}
const copyKey = async (key) => { const copyKey = async (key) => {
try { try {
await navigator.clipboard.writeText(key) await navigator.clipboard.writeText(key)
@@ -122,7 +214,7 @@ onMounted(loadData)
</script> </script>
<style scoped> <style scoped>
.agents-page { max-width: 1200px; } .agents-page {}
.mb-24 { margin-bottom: 24px; } .mb-24 { margin-bottom: 24px; }
.section { .section {
@@ -234,4 +326,91 @@ onMounted(loadData)
vertical-align: middle; vertical-align: middle;
} }
.btn-icon:hover { color: var(--accent-1); } .btn-icon:hover { color: var(--accent-1); }
/* Modal */
.modal-overlay {
position: fixed; inset: 0; z-index: 1000;
background: rgba(0,0,0,0.6);
backdrop-filter: blur(4px);
display: flex; align-items: center; justify-content: center;
}
.modal {
width: 480px; max-width: 90vw;
background: var(--bg-card);
border: 1px solid var(--border-color);
border-radius: 14px;
overflow: hidden;
}
.modal-header {
display: flex; justify-content: space-between; align-items: center;
padding: 16px 20px; border-bottom: 1px solid var(--border-color);
}
.modal-header h4 { font-size: 15px; font-weight: 600; color: #e0e0f0; margin: 0; }
.modal-close { background: none; border: none; color: var(--text-muted); cursor: pointer; }
.modal-close:hover { color: #fff; }
.modal-body { padding: 20px; display: flex; flex-direction: column; gap: 16px; }
.modal-footer {
display: flex; justify-content: flex-end; gap: 8px;
padding: 16px 20px; border-top: 1px solid var(--border-color);
}
.field { display: flex; flex-direction: column; gap: 6px; }
.field label { font-size: 12px; color: var(--text-secondary); }
.field-avatar-row { display: flex; align-items: center; gap: 12px; }
.input {
flex: 1;
padding: 8px 12px;
border-radius: 8px;
border: 1px solid var(--border-color);
background: rgba(255,255,255,0.04);
color: var(--text-primary);
font-size: 13px;
outline: none;
}
.input:focus { border-color: var(--accent-1); }
.agent-avatar-lg {
display: inline-flex;
align-items: center;
justify-content: center;
width: 40px;
height: 40px;
border-radius: 50%;
color: #fff;
font-size: 18px;
font-weight: 600;
flex-shrink: 0;
}
.btn-primary {
padding: 7px 16px; border-radius: 8px;
border: none;
background: linear-gradient(135deg, #667eea, #764ba2);
color: #fff; font-size: 13px; font-weight: 500;
cursor: pointer;
}
.btn-primary:hover { opacity: 0.9; }
.btn-secondary {
padding: 7px 16px; border-radius: 8px;
border: 1px solid var(--border-color);
background: transparent;
color: var(--text-secondary); font-size: 13px;
cursor: pointer;
}
.btn-secondary:hover { color: #fff; border-color: rgba(255,255,255,0.15); }
.btn-danger {
padding: 7px 16px; border-radius: 8px;
border: none;
background: rgba(239,68,68,0.8);
color: #fff; font-size: 13px; font-weight: 500;
cursor: pointer;
}
.btn-danger:hover { background: #ef4444; }
.cell-link-btn {
background: none; border: none;
font-size: 13px; cursor: pointer;
margin-left: 8px;
}
.cell-link-btn:hover { text-decoration: underline; }
</style> </style>
+3 -3
View File
@@ -20,7 +20,7 @@
{{ a.alert_type === 'missed_run' ? '未按时执行' : '执行失败' }} {{ a.alert_type === 'missed_run' ? '未按时执行' : '执行失败' }}
</span> </span>
<span class="alert-task">{{ a.task_name || '未知任务' }}</span> <span class="alert-task">{{ a.task_name || '未知任务' }}</span>
<span class="alert-time">{{ beijing(a.created_at) }}</span> <span class="alert-time">{{ dayjs.utc(a.created_at).local().format('MM-DD HH:mm') }}</span>
</div> </div>
<div class="alert-msg">{{ a.message }}</div> <div class="alert-msg">{{ a.message }}</div>
<div class="alert-bottom"> <div class="alert-bottom">
@@ -42,7 +42,7 @@
<script setup> <script setup>
import { ref, onMounted } from 'vue' import { ref, onMounted } from 'vue'
import { beijing } from '../utils/time.js' import dayjs from 'dayjs'
import { listAlerts, acknowledgeAlert } from '../api/index.js' import { listAlerts, acknowledgeAlert } from '../api/index.js'
const alerts = ref([]) const alerts = ref([])
@@ -68,7 +68,7 @@ onMounted(loadData)
</script> </script>
<style scoped> <style scoped>
.alerts-page { max-width: 1200px; } .alerts-page {}
.section { .section {
background: var(--bg-card); background: var(--bg-card);
+7 -5
View File
@@ -117,15 +117,18 @@
</thead> </thead>
<tbody> <tbody>
<tr v-for="t in tasks" :key="t.id"> <tr v-for="t in tasks" :key="t.id">
<td><span class="cell-badge">{{ t.agent_name }}</span></td> <td>
<span class="agent-avatar" :style="{background: $avatar(t.agent_name).bg}">{{ $avatar(t.agent_name).letter }}</span>
<span class="cell-badge">{{ t.agent_name }}</span>
</td>
<td class="cell-name">{{ t.name }}</td> <td class="cell-name">{{ t.name }}</td>
<td><code class="cell-code">{{ t.cron_expression }}</code></td> <td><code class="cell-code" :title="t.cron_expression">{{ $cron(t.cron_expression) }}</code></td>
<td> <td>
<span class="tag" :class="t.status === 'active' ? 'tag-green' : 'tag-gray'"> <span class="tag" :class="t.status === 'active' ? 'tag-green' : 'tag-gray'">
{{ t.status === 'active' ? '运行中' : t.status === 'paused' ? '已暂停' : '已停止' }} {{ t.status === 'active' ? '运行中' : t.status === 'paused' ? '已暂停' : '已停止' }}
</span> </span>
</td> </td>
<td class="cell-mono">{{ t.last_run_at ? beijing(t.last_run_at) : '—' }}</td> <td class="cell-mono">{{ t.last_run_at ? dayjs.utc(t.last_run_at).local().format('MM-DD HH:mm') : '—' }}</td>
<td> <td>
<span v-if="t.last_run_result" class="tag" :class="t.last_run_result === 'success' ? 'tag-green' : 'tag-red'"> <span v-if="t.last_run_result" class="tag" :class="t.last_run_result === 'success' ? 'tag-green' : 'tag-red'">
{{ t.last_run_result === 'success' ? '成功' : '失败' }} {{ t.last_run_result === 'success' ? '成功' : '失败' }}
@@ -149,7 +152,7 @@
<script setup> <script setup>
import { ref, onMounted, computed } from 'vue' import { ref, onMounted, computed } from 'vue'
import { beijing } from '../utils/time.js' import dayjs from 'dayjs'
import { getDashboardSummary, getDashboardTasks, getSystemConfig } from '../api/index.js' import { getDashboardSummary, getDashboardTasks, getSystemConfig } from '../api/index.js'
const summary = ref({ const summary = ref({
@@ -343,7 +346,6 @@ const copyScript = async () => {
<style scoped> <style scoped>
.dashboard { .dashboard {
max-width: 1200px;
} }
/* ── Onboarding Panel ─────────────────────────── */ /* ── Onboarding Panel ─────────────────────────── */
+1 -1
View File
@@ -246,7 +246,7 @@ onMounted(loadData)
</script> </script>
<style scoped> <style scoped>
.settings-page { max-width: 1200px; } .settings-page {}
.mb-24 { margin-bottom: 24px; } .mb-24 { margin-bottom: 24px; }
.section { .section {
+89 -12
View File
@@ -13,10 +13,16 @@
</span> </span>
</div> </div>
<div class="detail-grid"> <div class="detail-grid">
<div class="detail-item"><span class="detail-label">Agent</span><span class="detail-value">{{ task.agent_name }}</span></div> <div class="detail-item">
<div class="detail-item"><span class="detail-label">Cron</span><code class="detail-code">{{ task.cron_expression }}</code></div> <span class="detail-label">Agent</span>
<span class="detail-value">
<span class="agent-avatar-sm" :style="{background: $avatar(task.agent_name).bg}">{{ $avatar(task.agent_name).letter }}</span>
{{ task.agent_name }}
</span>
</div>
<div class="detail-item"><span class="detail-label">Cron</span><code class="detail-code" :title="task.cron_expression">{{ $cron(task.cron_expression) }}</code></div>
<div class="detail-item"><span class="detail-label">容忍窗口</span><span class="detail-value">{{ task.grace_period }}s</span></div> <div class="detail-item"><span class="detail-label">容忍窗口</span><span class="detail-value">{{ task.grace_period }}s</span></div>
<div class="detail-item"><span class="detail-label">最近运行</span><span class="detail-value mono">{{ task.last_run_at ? beijing(task.last_run_at, 'YYYY-MM-DD HH:mm:ss') : '—' }}</span></div> <div class="detail-item"><span class="detail-label">最近运行</span><span class="detail-value mono">{{ task.last_run_at ? dayjs.utc(task.last_run_at).local().format('YYYY-MM-DD HH:mm:ss') : '—' }}</span></div>
<div class="detail-item"> <div class="detail-item">
<span class="detail-label">最近结果</span> <span class="detail-label">最近结果</span>
<span v-if="task.last_run_result" class="tag" :class="task.last_run_result === 'success' ? 'tag-green' : 'tag-red'"> <span v-if="task.last_run_result" class="tag" :class="task.last_run_result === 'success' ? 'tag-green' : 'tag-red'">
@@ -25,7 +31,7 @@
<span v-else class="detail-value"></span> <span v-else class="detail-value"></span>
</div> </div>
<div class="detail-item"><span class="detail-label">执行次数</span><span class="detail-value mono">{{ task.total_run_count }}</span></div> <div class="detail-item"><span class="detail-label">执行次数</span><span class="detail-value mono">{{ task.total_run_count }}</span></div>
<div class="detail-item"><span class="detail-label">预计下次</span><span class="detail-value mono">{{ task.next_run_at ? beijing(task.next_run_at, 'YYYY-MM-DD HH:mm:ss') : '待计算' }}</span></div> <div class="detail-item"><span class="detail-label">预计下次</span><span class="detail-value mono">{{ task.next_run_at ? dayjs.utc(task.next_run_at).local().format('YYYY-MM-DD HH:mm:ss') : '待计算' }}</span></div>
<div class="detail-item"><span class="detail-label">描述</span><span class="detail-value">{{ task.description || '无' }}</span></div> <div class="detail-item"><span class="detail-label">描述</span><span class="detail-value">{{ task.description || '无' }}</span></div>
</div> </div>
</div> </div>
@@ -55,7 +61,7 @@
{{ e.status === 'success' ? '成功' : e.status === 'failed' ? '失败' : '运行中' }} {{ e.status === 'success' ? '成功' : e.status === 'failed' ? '失败' : '运行中' }}
</span> </span>
</td> </td>
<td class="cell-mono">{{ beijing(e.started_at, 'MM-DD HH:mm:ss') }}</td> <td class="cell-mono">{{ dayjs.utc(e.started_at).local().format('MM-DD HH:mm:ss') }}</td>
<td class="cell-mono">{{ e.duration_ms ?? '—' }}</td> <td class="cell-mono">{{ e.duration_ms ?? '—' }}</td>
<td class="cell-muted cell-ellipsis">{{ e.result || '—' }}</td> <td class="cell-muted cell-ellipsis">{{ e.result || '—' }}</td>
<td><button class="cell-link-btn" @click="showLog(e)">日志</button></td> <td><button class="cell-link-btn" @click="showLog(e)">日志</button></td>
@@ -66,6 +72,20 @@
</tbody> </tbody>
</table> </table>
</div> </div>
<!-- Pagination -->
<div class="pagination" v-if="total > pageSize && totalPages > 1">
<span class="page-info"> {{ total }} </span>
<div class="page-btns">
<button class="page-btn" :disabled="currentPage <= 1" @click="goPage(currentPage - 1)">上一页</button>
<template v-for="p in (totalPages || 1)" :key="p">
<button v-if="Math.abs(p - currentPage) <= 2 || p === 1 || p === totalPages"
class="page-btn" :class="{ active: p === currentPage }"
@click="goPage(p)">{{ p }}</button>
<span v-else-if="p === currentPage - 3 || p === currentPage + 3" class="page-ellipsis"></span>
</template>
<button class="page-btn" :disabled="currentPage >= totalPages" @click="goPage(currentPage + 1)">下一页</button>
</div>
</div>
</div> </div>
<!-- Log Dialog --> <!-- Log Dialog -->
@@ -84,9 +104,9 @@
</template> </template>
<script setup> <script setup>
import { ref, onMounted } from 'vue' import { ref, onMounted, computed } from 'vue'
import { useRoute } from 'vue-router' import { useRoute } from 'vue-router'
import { beijing } from '../utils/time.js' import dayjs from 'dayjs'
import { getTask, listExecutions } from '../api/index.js' import { getTask, listExecutions } from '../api/index.js'
const route = useRoute() const route = useRoute()
@@ -97,14 +117,37 @@ const logVisible = ref(false)
const currentLog = ref('') const currentLog = ref('')
const logExecution = ref(null) const logExecution = ref(null)
// Pagination
const currentPage = ref(1)
const pageSize = ref(20)
const total = ref(0)
const totalPages = computed(() => Math.ceil(total.value / pageSize.value) || 1)
const loadExecutions = async (page) => {
try {
const res = await listExecutions(taskId, { page, page_size: pageSize.value })
const data = res.data || {}
executions.value = data.items || []
total.value = data.total || 0
currentPage.value = data.page || page
} catch (e) {
console.error(e)
executions.value = []
}
}
const goPage = (p) => {
if (p < 1 || p > totalPages.value) return
loadExecutions(p)
}
onMounted(async () => { onMounted(async () => {
try { try {
const [taskRes, execRes] = await Promise.all([ const [taskRes] = await Promise.all([
getTask(taskId), getTask(taskId),
listExecutions(taskId, { limit: 100 }),
]) ])
task.value = taskRes.data task.value = taskRes.data
executions.value = execRes.data await loadExecutions(1)
} catch (e) { } catch (e) {
console.error(e) console.error(e)
} }
@@ -118,8 +161,6 @@ const showLog = (row) => {
</script> </script>
<style scoped> <style scoped>
.task-detail { max-width: 1200px; }
.back-btn { .back-btn {
display: inline-flex; display: inline-flex;
align-items: center; align-items: center;
@@ -239,4 +280,40 @@ const showLog = (row) => {
word-break: break-all; word-break: break-all;
margin: 0; margin: 0;
} }
/* ── Pagination ──────────────────────────── */
.pagination {
display: flex;
justify-content: space-between;
align-items: center;
padding: 12px 20px;
border-top: 1px solid var(--border-color);
}
.page-info {
font-size: 12px;
color: var(--text-muted);
}
.page-btns {
display: flex;
align-items: center;
gap: 4px;
}
.page-btn {
padding: 4px 10px;
border-radius: 6px;
border: 1px solid var(--border-color);
background: transparent;
color: var(--text-secondary);
font-size: 12px;
cursor: pointer;
transition: all 0.15s;
}
.page-btn:hover { border-color: var(--accent-1); color: var(--accent-1); }
.page-btn.active {
background: var(--accent-1);
border-color: var(--accent-1);
color: #fff;
}
.page-btn:disabled { opacity: 0.3; cursor: not-allowed; }
.page-ellipsis { color: var(--text-muted); font-size: 12px; padding: 0 2px; }
</style> </style>
+218 -208
View File
@@ -1,36 +1,29 @@
<template> <template>
<div class="tasks-page"> <div class="tasks-page">
<!-- Search & Filter Bar -->
<div class="section mb-24"> <div class="section mb-24">
<div class="section-header collapsible" @click="showGuide = !showGuide"> <div class="section-header">
<h3>定时任务 AI Agent 自动管理</h3> <h3>所有任务</h3>
<div class="section-toggle">
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"
:style="{ transform: showGuide ? 'rotate(180deg)' : 'rotate(0deg)', transition: 'transform 0.2s' }">
<polyline points="6 9 12 15 18 9"/>
</svg>
<span class="toggle-label">{{ showGuide ? '收起指令' : '展开指令' }}</span>
</div>
</div> </div>
<div v-if="showGuide" class="onboard-body"> <div class="search-bar">
<p>将下方指令发送给您的 AI Agent它会自动汇报执行结果并管理定时任务</p> <div class="search-input-wrap">
<div class="code-block"> <svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><circle cx="11" cy="11" r="8"/><line x1="21" y1="21" x2="16.65" y2="16.65"/></svg>
<div class="code-tabs"> <input v-model="searchQ" class="search-input" placeholder="搜索任务名称..." @input="loadData" />
<button :class="{ active: tab === 'report' }" @click="tab = 'report'">汇报执行</button>
<button :class="{ active: tab === 'register' }" @click="tab = 'register'">注册任务</button>
<button :class="{ active: tab === 'batch' }" @click="tab = 'batch'">批量注册</button>
</div>
<div class="code-body">
<pre>{{ codes[tab] }}</pre>
</div>
<button class="btn-copy" @click="copyCode">复制</button>
</div> </div>
<input v-model="searchTags" class="search-input" style="width:200px" placeholder="标签筛选(逗号分隔)" @input="loadData" />
<select v-model="searchStatus" class="search-input" style="width:130px" @change="loadData">
<option value="">全部状态</option>
<option value="active">运行中</option>
<option value="paused">已暂停</option>
<option value="stopped">已停止</option>
</select>
</div> </div>
</div> </div>
<!-- Task list --> <!-- Task list -->
<div class="section"> <div class="section">
<div class="section-header"> <div class="section-header">
<h3>所有任务</h3> <h3>任务列表{{ tasks.length }}</h3>
</div> </div>
<div class="table-wrap"> <div class="table-wrap">
<table class="data-table"> <table class="data-table">
@@ -40,6 +33,7 @@
<th>Agent</th> <th>Agent</th>
<th>任务名称</th> <th>任务名称</th>
<th>Cron</th> <th>Cron</th>
<th>标签</th>
<th>状态</th> <th>状态</th>
<th>最近运行</th> <th>最近运行</th>
<th>结果</th> <th>结果</th>
@@ -50,15 +44,22 @@
<tbody> <tbody>
<tr v-for="t in tasks" :key="t.id"> <tr v-for="t in tasks" :key="t.id">
<td class="cell-mono">#{{ t.id }}</td> <td class="cell-mono">#{{ t.id }}</td>
<td><span class="cell-badge">{{ t.agent_name }}</span></td> <td>
<span class="agent-avatar" :style="{background: $avatar(t.agent_name).bg}">{{ $avatar(t.agent_name).letter }}</span>
<span class="cell-badge">{{ t.agent_name }}</span>
</td>
<td class="cell-name">{{ t.name }}</td> <td class="cell-name">{{ t.name }}</td>
<td><code class="cell-code">{{ t.cron_expression }}</code></td> <td><code class="cell-code" :title="t.cron_expression">{{ $cron(t.cron_expression) }}</code></td>
<td>
<span v-for="tag in (t.tags || [])" :key="tag" class="tag-pill">{{ tag }}</span>
<span v-if="!t.tags || t.tags.length === 0" class="cell-muted"></span>
</td>
<td> <td>
<span class="tag" :class="t.status === 'active' ? 'tag-green' : 'tag-gray'"> <span class="tag" :class="t.status === 'active' ? 'tag-green' : 'tag-gray'">
{{ t.status === 'active' ? '运行中' : t.status === 'paused' ? '已暂停' : '已停止' }} {{ t.status === 'active' ? '运行中' : t.status === 'paused' ? '已暂停' : '已停止' }}
</span> </span>
</td> </td>
<td class="cell-mono">{{ t.last_run_at ? beijing(t.last_run_at) : '—' }}</td> <td class="cell-mono">{{ t.last_run_at ? dayjs.utc(t.last_run_at).local().format('MM-DD HH:mm') : '—' }}</td>
<td> <td>
<span v-if="t.last_run_result" class="tag" :class="t.last_run_result === 'success' ? 'tag-green' : 'tag-red'"> <span v-if="t.last_run_result" class="tag" :class="t.last_run_result === 'success' ? 'tag-green' : 'tag-red'">
{{ t.last_run_result === 'success' ? '成功' : '失败' }} {{ t.last_run_result === 'success' ? '成功' : '失败' }}
@@ -68,182 +69,121 @@
<td class="cell-mono">{{ t.total_run_count }}</td> <td class="cell-mono">{{ t.total_run_count }}</td>
<td> <td>
<router-link :to="`/tasks/${t.id}`" class="cell-link">详情</router-link> <router-link :to="`/tasks/${t.id}`" class="cell-link">详情</router-link>
<button class="btn-icon" @click="handleDelete(t)" title="删除"> <button class="cell-link-btn" style="color:var(--accent-1);margin-left:6px" @click="showEdit(t)">编辑</button>
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><polyline points="3 6 5 6 21 6"/><path d="M19 6v14a2 2 0 0 1-2 2H7a2 2 0 0 1-2-2V6m3 0V4a2 2 0 0 1 2-2h4a2 2 0 0 1 2 2v2"/></svg> <button class="cell-link-btn" style="color:var(--danger);margin-left:4px" @click="handleDelete(t)">删除</button>
</button>
</td> </td>
</tr> </tr>
<tr v-if="tasks.length === 0"> <tr v-if="tasks.length === 0">
<td colspan="9" class="cell-empty">暂无任务 Agent 注册后会自动出现在这里</td> <td colspan="10" class="cell-empty">暂无任务 Agent 注册后会自动出现在这里</td>
</tr> </tr>
</tbody> </tbody>
</table> </table>
</div> </div>
</div> </div>
<!-- Edit Task Modal -->
<div v-if="editTask" class="modal-overlay" @click.self="editTask = null">
<div class="modal modal-wide">
<div class="modal-header">
<h4>编辑任务</h4>
<button class="modal-close" @click="editTask = null">
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><line x1="18" y1="6" x2="6" y2="18"/><line x1="6" y1="6" x2="18" y2="18"/></svg>
</button>
</div>
<div class="modal-body">
<div class="form-fields">
<div class="field">
<label>任务名称</label>
<input v-model="editForm.name" class="input" />
</div>
<div class="field">
<label>标签回车添加</label>
<div class="tags-edit">
<span v-for="(tag, i) in editForm.tags" :key="i" class="tag-pill">
{{ tag }}
<button class="tag-remove" @click="editForm.tags.splice(i, 1)">
<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><line x1="18" y1="6" x2="6" y2="18"/><line x1="6" y1="6" x2="18" y2="18"/></svg>
</button>
</span>
<input v-model="tagInput" class="tag-input" placeholder="输入标签后回车" @keydown.enter.prevent="addTag" @keydown.,.prevent="addTag" />
</div>
</div>
<div class="field">
<label>描述</label>
<input v-model="editForm.description" class="input" placeholder="任务描述(选填)" />
</div>
</div>
</div>
<div class="modal-footer">
<button class="btn-secondary" @click="editTask = null">取消</button>
<button class="btn-primary" @click="handleEdit">保存</button>
</div>
</div>
</div>
</div> </div>
</template> </template>
<script setup> <script setup>
import { ref, onMounted, computed } from 'vue' import { ref, onMounted } from 'vue'
import { beijing } from '../utils/time.js' import dayjs from 'dayjs'
import { listTasks, deleteTask, getSystemConfig } from '../api/index.js' import { listTasks, deleteTask, updateTask } from '../api/index.js'
const tasks = ref([]) const tasks = ref([])
const tab = ref('report') const searchQ = ref('')
const showGuide = ref(false) const searchTags = ref('')
const baseUrl = ref(window.location.origin) const searchStatus = ref('')
const loadData = async () => { const loadData = async () => {
const [taskRes, cfgRes] = await Promise.all([ const params = {}
listTasks(), if (searchQ.value) params.q = searchQ.value
getSystemConfig(), if (searchTags.value) params.tags = searchTags.value
]) if (searchStatus.value) params.status = searchStatus.value
tasks.value = taskRes.data const res = await listTasks(params)
baseUrl.value = cfgRes.data.base_url tasks.value = res.data
}
// Edit
const editTask = ref(null)
const editForm = ref({ name: '', description: '', tags: [] })
const tagInput = ref('')
const addTag = () => {
const val = tagInput.value.replace(/,/g, '').trim()
if (val && !editForm.value.tags.includes(val)) editForm.value.tags.push(val)
tagInput.value = ''
}
const showEdit = (t) => {
editForm.value = {
name: t.name,
description: t.description || '',
tags: [...(t.tags || [])],
}
editTask.value = t
}
const handleEdit = async () => {
try {
await updateTask(editTask.value.id, editForm.value)
editTask.value = null
await loadData()
const { ElMessage } = await import('element-plus')
ElMessage.success('已保存')
} catch (e) { console.error(e) }
} }
const handleDelete = async (row) => { const handleDelete = async (row) => {
try { try {
await deleteTask(row.id) await deleteTask(row.id)
await loadData() await loadData()
} catch (e) { } catch (e) { console.error(e) }
console.error(e)
}
}
const codes = computed(() => {
const b = baseUrl.value
return {
report: `# TaskPulse — 汇报执行结果
你已注册到 TaskPulse。每次任务运行后(无论成功失败),主动汇报执行结果。
## 汇报成功
curl -X POST ${b}/api/tasks/<TASK_ID>/executions \\
-H "Content-Type: application/json" \\
-H "Authorization: Bearer <API_KEY>" \\
-d '{"status":"success","duration_ms":2500,"log":"[INFO] 任务执行完成,同步了100条记录"}'
## 汇报失败
curl -X POST ${b}/api/tasks/<TASK_ID>/executions \\
-H "Content-Type: application/json" \\
-H "Authorization: Bearer <API_KEY>" \\
-d '{"status":"failed","duration_ms":1200,"log":"[ERROR] 连接超时","error_message":"TimeoutError: connection to DB timed out"}'
## 修改你的代码自动汇报
在你的每个定时任务函数末尾,加上汇报逻辑。参考以下实现:
import time
import requests
def run_task(task_func, task_id, api_key):
"""包裹你的任务函数,自动汇报执行结果"""
start = time.time()
try:
result = task_func()
elapsed = int((time.time() - start) * 1000)
requests.post(f"${b}/api/tasks/{task_id}/executions",
headers={"Authorization": f"Bearer {api_key}"},
json={"status": "success", "duration_ms": elapsed, "log": str(result)})
except Exception as e:
elapsed = int((time.time() - start) * 1000)
requests.post(f"${b}/api/tasks/{task_id}/executions",
headers={"Authorization": f"Bearer {api_key}"},
json={"status": "failed", "duration_ms": elapsed, "error_message": str(e)})
# 在你的循环/调度中这样使用:
run_task(my_task_func, TASK_ID, API_KEY)`,
register: `# TaskPulse — 注册新任务
你需要注册自己管理的每一个定时任务,系统才知道你的执行计划。
## 单个注册
curl -X POST "${b}/api/tasks?agent_id=<AGENT_ID>" \\
-H "Content-Type: application/json" \\
-H "Authorization: Bearer <API_KEY>" \\
-d '{"name":"数据同步","cron_expression":"*/5 * * * *","description":"每5分钟同步一次数据","grace_period":300}'
参数说明:
- name: 任务名称(必填)
- cron_expression: Cron 表达式,格式:分 时 日 月 周(必填)
- description: 任务描述(可选)
- grace_period: 容忍窗口(秒),任务超时N秒未执行则告警(默认300)
## 查看已注册的任务
curl -X GET "${b}/api/tasks" \\
-H "Authorization: Bearer <API_KEY>"
## 更新现有任务(修改 cron 等)
curl -X PUT "${b}/api/tasks/<TASK_ID>" \\
-H "Content-Type: application/json" \\
-H "Authorization: Bearer <API_KEY>" \\
-d '{"cron_expression":"0 * * * *","status":"active"}'
## 删除任务
curl -X DELETE "${b}/api/tasks/<TASK_ID>" \\
-H "Authorization: Bearer <API_KEY>"`,
batch: `# TaskPulse — 批量注册(推荐)
如果你有多个定时任务,推荐使用批量注册接口,一次完成 Agent 注册 + 所有任务登记。
## 批量注册 Agent + 全部任务
curl -X POST ${b}/api/agents/register-with-tasks \\
-H "Content-Type: application/json" \\
-d '{
"name": "<你的Agent名称>",
"description": "<你的职责描述>",
"tasks": [
{"name": "数据同步", "cron_expression": "*/5 * * * *", "description": "定时同步数据", "grace_period": 300},
{"name": "日报通知", "cron_expression": "0 9 * * *", "description": "每天早上9点生成报告", "grace_period": 600},
{"name": "周报汇总", "cron_expression": "0 10 * * 1", "description": "每周一早10点汇总", "grace_period": 900}
]
}'
响应中包含 agent 信息和任务创建数量。返回的 api_key 请保存好。
## Python 示例
import requests
resp = requests.post(f"${b}/api/agents/register-with-tasks", json={
"name": "data-agent",
"description": "数据相关的全部定时任务",
"tasks": [
{"name": "sync-data", "cron_expression": "*/5 * * * *"},
{"name": "daily-report", "cron_expression": "0 9 * * *"}
]
})
result = resp.json()
print(f"Agent ID: {result['agent']['id']}, 任务数: {result['tasks_created']}")
API_KEY = result["agent"]["api_key"]
---
注册完成后,回到仪表盘页面,将「汇报执行结果」的指令一并发送给 AI Agent,它就知道如何自动向你汇报运行状态。`,
}
})
const copyCode = async () => {
try {
await navigator.clipboard.writeText(codes.value[tab.value])
const { ElMessage } = await import('element-plus')
ElMessage.success('已复制')
} catch {}
} }
onMounted(loadData) onMounted(loadData)
</script> </script>
<style scoped> <style scoped>
.tasks-page { max-width: 1200px; } .tasks-page {}
.mb-24 { margin-bottom: 24px; } .mb-24 { margin-bottom: 24px; }
.section { .section {
@@ -265,37 +205,32 @@ onMounted(loadData)
} }
.section-toggle:hover { color: var(--text-secondary); } .section-toggle:hover { color: var(--text-secondary); }
.onboard-body { padding: 20px; } /* Search */
.onboard-body > p { font-size: 13px; color: var(--text-secondary); margin-bottom: 16px; } .search-bar {
display: flex; gap: 10px; padding: 12px 20px;
.code-block { align-items: center;
position: relative; }
background: rgba(0,0,0,0.3); .search-input-wrap {
border-radius: 10px; display: flex; align-items: center; gap: 8px; flex: 1;
overflow: hidden; padding: 0 12px;
} border-radius: 8px;
.code-tabs { display: flex; border-bottom: 1px solid rgba(255,255,255,0.06); } border: 1px solid var(--border-color);
.code-tabs button { background: rgba(255,255,255,0.04);
padding: 8px 16px; background: none; border: none; color: var(--text-muted);
color: rgba(255,255,255,0.4); font-size: 12px; cursor: pointer; }
border-bottom: 2px solid transparent; .search-input-wrap svg { flex-shrink: 0; }
} .search-input {
.code-tabs button.active { color: #a8b4ff; border-bottom-color: #667eea; } flex: 1;
.code-body { padding: 16px; overflow-x: auto; } padding: 8px 0;
.code-body pre { background: none;
font-family: 'JetBrains Mono', 'Fira Code', monospace; border: none;
font-size: 12px; line-height: 1.7; color: rgba(255,255,255,0.75); outline: none;
white-space: pre; margin: 0; color: var(--text-primary);
} font-size: 13px;
.btn-copy { }
position: absolute; top: 44px; right: 12px; .search-input::placeholder { color: var(--text-muted); }
padding: 4px 12px; border-radius: 6px;
border: 1px solid rgba(255,255,255,0.1);
background: rgba(255,255,255,0.06);
color: rgba(255,255,255,0.5); font-size: 11px; cursor: pointer;
}
.btn-copy:hover { background: rgba(255,255,255,0.1); color: #fff; }
/* Table */
.table-wrap { overflow-x: auto; } .table-wrap { overflow-x: auto; }
.data-table { width: 100%; border-collapse: collapse; } .data-table { width: 100%; border-collapse: collapse; }
.data-table th { .data-table th {
@@ -322,17 +257,92 @@ onMounted(loadData)
.cell-mono { font-family: 'JetBrains Mono', monospace; font-size: 12px; color: var(--text-secondary); } .cell-mono { font-family: 'JetBrains Mono', monospace; font-size: 12px; color: var(--text-secondary); }
.cell-muted { color: var(--text-muted); } .cell-muted { color: var(--text-muted); }
.cell-empty { text-align: center; color: var(--text-muted); padding: 40px 16px !important; } .cell-empty { text-align: center; color: var(--text-muted); padding: 40px 16px !important; }
.cell-link { color: var(--accent-1); text-decoration: none; font-size: 13px; margin-right: 8px; } .cell-link { color: var(--accent-1); text-decoration: none; font-size: 13px; }
.cell-link:hover { text-decoration: underline; } .cell-link:hover { text-decoration: underline; }
.cell-link-btn { background: none; border: none; font-size: 12px; cursor: pointer; }
.cell-link-btn:hover { text-decoration: underline; }
.tag { display: inline-block; padding: 2px 10px; border-radius: 20px; font-size: 12px; font-weight: 500; } .tag { display: inline-block; padding: 2px 10px; border-radius: 20px; font-size: 12px; font-weight: 500; }
.tag-green { background: rgba(34,197,94,0.12); color: #4ade80; } .tag-green { background: rgba(34,197,94,0.12); color: #4ade80; }
.tag-red { background: rgba(239,68,68,0.12); color: #f87171; } .tag-red { background: rgba(239,68,68,0.12); color: #f87171; }
.tag-gray { background: rgba(255,255,255,0.05); color: var(--text-secondary); } .tag-gray { background: rgba(255,255,255,0.05); color: var(--text-secondary); }
.btn-icon { /* Tag Pills */
background: none; border: none; color: var(--text-muted); .tag-pill {
cursor: pointer; padding: 4px; vertical-align: middle; display: inline-block;
padding: 2px 8px;
border-radius: 4px;
font-size: 11px;
background: rgba(102,126,234,0.12);
color: #a8b4ff;
margin-right: 4px;
margin-bottom: 2px;
white-space: nowrap;
} }
.btn-icon:hover { color: var(--danger); }
/* Modal */
.modal-overlay {
position: fixed; inset: 0; z-index: 1000;
background: rgba(0,0,0,0.6); backdrop-filter: blur(4px);
display: flex; align-items: center; justify-content: center;
}
.modal {
width: 480px; max-width: 90vw;
background: var(--bg-card);
border: 1px solid var(--border-color);
border-radius: 14px;
overflow: hidden;
}
.modal-wide { width: 560px; }
.modal-header {
display: flex; justify-content: space-between; align-items: center;
padding: 16px 20px; border-bottom: 1px solid var(--border-color);
}
.modal-header h4 { font-size: 15px; font-weight: 600; color: #e0e0f0; margin: 0; }
.modal-close { background: none; border: none; color: var(--text-muted); cursor: pointer; }
.modal-close:hover { color: #fff; }
.modal-body { padding: 20px; }
.modal-footer {
display: flex; justify-content: flex-end; gap: 8px;
padding: 16px 20px; border-top: 1px solid var(--border-color);
}
.form-fields { display: flex; flex-direction: column; gap: 16px; }
.field { display: flex; flex-direction: column; gap: 6px; }
.field label { font-size: 12px; color: var(--text-secondary); }
.input {
padding: 8px 12px;
border-radius: 8px;
border: 1px solid var(--border-color);
background: rgba(255,255,255,0.04);
color: var(--text-primary);
font-size: 13px;
outline: none;
}
.input:focus { border-color: var(--accent-1); }
select.input { cursor: pointer; }
.tags-edit {
display: flex; flex-wrap: wrap; gap: 4px;
padding: 6px 8px;
border-radius: 8px;
border: 1px solid var(--border-color);
background: rgba(255,255,255,0.04);
min-height: 36px;
align-items: center;
}
.tag-remove { background: none; border: none; color: inherit; cursor: pointer; padding: 0; margin-left: 2px; opacity: 0.6; }
.tag-remove:hover { opacity: 1; }
.tag-input {
flex: 1; min-width: 100px;
background: none; border: none; outline: none;
color: var(--text-primary);
font-size: 12px;
}
.tag-input::placeholder { color: var(--text-muted); }
.btn-primary { padding: 7px 16px; border-radius: 8px; border: none; background: linear-gradient(135deg,#667eea,#764ba2); color: #fff; font-size: 13px; font-weight: 500; cursor: pointer; }
.btn-primary:hover { opacity: 0.9; }
.btn-secondary { padding: 7px 16px; border-radius: 8px; border: 1px solid var(--border-color); background: transparent; color: var(--text-secondary); font-size: 13px; cursor: pointer; }
.btn-secondary:hover { color: #fff; border-color: rgba(255,255,255,0.15); }
</style> </style>