feat: initial TaskPulse release

AI Agent 定时任务跟踪、监控、状态看板系统。

- FastAPI + Vue3 单体应用
- AI Agent 一键接入(批量注册 API)
- 暗色主题 Dashboard
- 后台调度器 + 超时告警
- 飞书/邮件/Webhook 多渠道通知
- Base URL 可配置
- Gunicorn 生产部署
This commit is contained in:
Steven 2026-06-14 22:39:23 +08:00
commit 521cf0269c
54 changed files with 6328 additions and 0 deletions

29
.gitignore vendored Normal file
View File

@ -0,0 +1,29 @@
# Python
__pycache__/
*.py[cod]
*.egg-info/
.venv/
venv/
# Node
node_modules/
frontend/dist/
# IDE
.idea/
.vscode/
*.swp
# OS
.DS_Store
Thumbs.db
# Env
.env
# Tooling
.codegraph/
.reasonix/
*.db
*.db-shm
*.db-wal

126
AGENT_GUIDE.md Normal file
View File

@ -0,0 +1,126 @@
# Agent 接入指南
本文档说明 AI Agent 如何接入 TaskPulse 系统。
## 一、注册 Agent
首先向 TaskPulse 注册你的 Agent获取 API Key
```bash
curl -X POST http://localhost:8000/api/agents \
-H "Content-Type: application/json" \
-d '{"name": "my-agent", "description": "我的第一个 Agent"}'
```
返回示例:
```json
{
"id": 1,
"name": "my-agent",
"api_key": "tp_a1b2c3d4e5f6...",
"status": "active",
"task_count": 0,
...
}
```
**请妥善保存 `api_key`**,后续所有操作都需要通过这个 Key 鉴权。
## 二、注册定时任务
Agent 注册成功后,注册它所管理的定时任务:
```bash
curl -X POST "http://localhost:8000/api/tasks?agent_id=1" \
-H "Content-Type: application/json" \
-d '{
"name": "data-sync",
"cron_expression": "0 */1 * * *",
"description": "每小时同步数据",
"grace_period": 300
}'
```
| 参数 | 说明 |
|------|------|
| `name` | 任务名称 |
| `cron_expression` | Cron 表达式(分 时 日 月 周) |
| `grace_period` | 容忍窗口超过预定时间N秒未执行则触发告警 |
| `description` | 任务描述(可选) |
## 三、执行后汇报结果
每次任务执行完成后,向 TaskPulse 汇报执行结果:
```bash
curl -X POST http://localhost:8000/api/tasks/1/executions \
-H "Content-Type: application/json" \
-H "Authorization: Bearer tp_a1b2c3d4e5f6..." \
-d '{
"status": "success",
"duration_ms": 1523,
"result": "{\"records_synced\": 100}",
"log": "[2025-01-01 10:00:00] Starting sync...\n[2025-01-01 10:00:01] Synced 100 records",
"error_message": null
}'
```
| 参数 | 必填 | 说明 |
|------|------|------|
| `status` | 是 | `success``failed` |
| `finished_at` | 否 | 结束时间ISO 格式),默认当前时间 |
| `duration_ms` | 否 | 执行耗时(毫秒) |
| `result` | 否 | 执行结果摘要JSON 字符串) |
| `log` | 否 | 执行日志文本 |
| `error_message` | 否 | 错误信息(仅失败时填写) |
## 四、Python SDK 示例
```python
import requests
BASE_URL = "http://localhost:8000"
API_KEY = "tp_a1b2c3d4e5f6..."
headers = {"Authorization": f"Bearer {API_KEY}"}
# 1. 注册 Agent
def register_agent(name: str, description: str = "") -> dict:
resp = requests.post(f"{BASE_URL}/api/agents", json={
"name": name, "description": description
})
resp.raise_for_status()
return resp.json()
# 2. 注册定时任务
def register_task(agent_id: int, name: str, cron: str) -> dict:
resp = requests.post(f"{BASE_URL}/api/tasks", params={"agent_id": agent_id}, json={
"name": name, "cron_expression": cron
})
resp.raise_for_status()
return resp.json()
# 3. 汇报执行结果
def report_execution(task_id: int, status: str, log: str = "", duration_ms: int = 0):
resp = requests.post(
f"{BASE_URL}/api/tasks/{task_id}/executions",
headers=headers,
json={
"status": status,
"duration_ms": duration_ms,
"log": log,
"result": "{}",
}
)
resp.raise_for_status()
return resp.json()
# 使用示例
agent = register_agent("data-agent", "数据同步 Agent")
task = register_task(agent["id"], "hourly-sync", "0 * * * *")
report_execution(task["id"], "success", log="Sync completed", duration_ms=2500)
```
## 五、查看看板
打开浏览器访问 `http://localhost:8000` 即可查看统一的 Dashboard。

193
README.md Normal file
View File

@ -0,0 +1,193 @@
# TaskPulse — AI Agent Task Monitor
统一看板,监控 AI Agent 定时任务的运行状态、执行历史,并在任务超时未执行时发送告警通知。
**线上地址**: https://task.pags.cn
---
## 核心功能
- **AI 原生接入** — AI Agent 通过 API 一次性注册自己 + 所有定时任务,无需手动填写表单
- **统一状态看板** — 所有定时任务的运行状态、最近运行时间、结果一目了然
- **执行汇报** — Agent 执行完任务后主动汇报结果,含详细日志
- **超时告警** — 任务未按时执行时自动告警
- **多渠道通知** — 支持飞书 Webhook、邮件、通用 Webhook
- **Base URL 可配置** — 系统设置页面可配置公网访问地址,所有接入指令自动更新
## 技术栈
| 层 | 技术 |
|---|---|
| 后端 | Python FastAPI (异步) + Gunicorn (生产) |
| 前端 | Vue3 + Element Plus (暗色主题) |
| 数据库 | MySQL 8.0 |
| 部署 | 单体应用FastAPI serve Vue3 构建产物) |
## 快速开始
### 本地开发
```bash
# 1. 克隆项目
git clone https://gitea.tatta.cn/Tatta/TaskPulse.git
cd TaskPulse
# 2. 后端 - 创建虚拟环境并安装依赖
cd backend
python -m venv .venv
# Windows: .venv\Scripts\activate
# Linux: source .venv/bin/activate
pip install -r requirements.txt
# 3. 复制环境变量并修改数据库配置
cp .env.example .env
# 编辑 .env配置 MySQL 连接信息
# 4. 启动后端开发服务器
uvicorn app.main:app --reload --port 8000
# 5. 前端(新开终端)
cd frontend
npm install
npm run dev # 访问 http://localhost:3000
```
> 前端 dev server 自动代理 `/api` 请求到 `localhost:8000`
### 生产部署
```bash
# 1. 构建前端
cd frontend
npm install && npm run build
# 2. 部署到服务器
# 将整个项目复制到服务器(排除 .venv 和 node_modules
rsync -avz --exclude='.venv' --exclude='node_modules' --exclude='.git' \
./ user@server:/home/openclaw/app/taskpulse/
# 3. 在服务器上创建虚拟环境并安装依赖
cd /home/openclaw/app/taskpulse/backend
python3 -m venv .venv
.venv/bin/pip install -r requirements.txt
# 4. 使用 Gunicorn 启动
.venv/bin/gunicorn app.main:app \
-k uvicorn.workers.UvicornWorker \
-b 0.0.0.0:8000 \
-w 2 \
--access-logfile /var/log/taskpulse/access.log \
--error-logfile /var/log/taskpulse/error.log \
--daemon
```
## 环境变量
复制 `backend/.env.example``backend/.env` 并按需修改:
| 变量 | 默认值 | 说明 |
|------|--------|------|
| 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
import requests
BASE = "https://task.pags.cn" # 替换为你的实际地址
# 1. AI Agent 注册自己 + 所有定时任务(一次性)
resp = requests.post(f"{BASE}/api/agents/register-with-tasks", json={
"name": "my-agent",
"description": "我的 AI Agent",
"tasks": [
{"name": "sync-data", "cron_expression": "*/5 * * * *"},
{"name": "daily-report", "cron_expression": "0 9 * * *"}
]
})
agent = resp.json()
API_KEY = agent["api_key"] # 保存此 Key
AGENT_ID = agent["agent"]["id"]
# 2. 每次执行后汇报结果
requests.post(f"{BASE}/api/tasks/{TASK_ID}/executions",
headers={"Authorization": f"Bearer {API_KEY}"},
json={"status": "success", "duration_ms": 1200, "log": "done"})
```
## API 概览
| 方法 | 路径 | 说明 |
|------|------|------|
| POST | /api/agents | 注册 Agent |
| POST | /api/agents/register-with-tasks | **AI Agent 一次性注册自己 + 所有任务** |
| GET | /api/agents | 列出所有 Agent |
| POST | /api/tasks?agent_id=X | 注册定时任务 |
| GET | /api/tasks | 列出所有任务 |
| 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
## 项目结构
```
taskpulse/
├── backend/
│ ├── app/
│ │ ├── main.py # FastAPI 入口 + SPA 静态文件服务
│ │ ├── config.py # 环境变量配置
│ │ ├── database.py # 异步数据库连接
│ │ ├── models/ # SQLAlchemy 数据模型
│ │ │ ├── agent.py # Agent含 API Key 自动生成)
│ │ │ ├── task.py # 定时任务cron、宽容窗口
│ │ │ ├── execution.py # 执行记录(日志、结果、耗时)
│ │ │ ├── notification.py # 通知渠道、告警规则、告警历史
│ │ │ └── system_config.py # 系统配置key-value
│ │ ├── schemas/ # Pydantic 数据校验
│ │ ├── api/ # API 路由
│ │ │ ├── agents.py # Agent CRUD + 批量注册
│ │ │ ├── tasks.py # 任务 CRUD
│ │ │ ├── executions.py # 执行汇报 + 历史查询
│ │ │ ├── notifications.py # 通知渠道 + 告警规则 + 告警确认
│ │ │ ├── dashboard.py # 看板概览统计
│ │ │ └── system.py # 系统配置
│ │ └── services/ # 业务逻辑
│ │ ├── agent.py / task.py / execution.py
│ │ ├── scheduler.py # 后台扫描:检测超时任务→生成告警→通知
│ │ └── notification.py # 飞书 / 邮件 / Webhook 通知发送
│ ├── alembic/ # 数据库迁移
│ └── requirements.txt
├── frontend/
│ ├── src/
│ │ ├── views/ # 页面组件(暗色主题)
│ │ ├── router/ # 路由
│ │ └── api/ # Axios API 客户端
│ └── package.json
├── AGENT_GUIDE.md # Agent 接入指南
└── README.md
```

18
backend/.env.example Normal file
View File

@ -0,0 +1,18 @@
# TaskPulse — AI Agent Task Monitor
# Database
TASKPULSE_DB_HOST=localhost
TASKPULSE_DB_PORT=3306
TASKPULSE_DB_USER=dbuser
TASKPULSE_DB_PASSWORD=your_password_here
TASKPULSE_DB_NAME=taskpulse
TASKPULSE_SECRET_KEY=change-me-in-production
TASKPULSE_DEBUG=true
# Notification (optional)
TASKPULSE_FEISHU_WEBHOOK_URL=
TASKPULSE_SMTP_HOST=
TASKPULSE_SMTP_PORT=587
TASKPULSE_SMTP_USER=
TASKPULSE_SMTP_PASSWORD=
TASKPULSE_SMTP_FROM=taskpulse@example.com

17
backend/Dockerfile Normal file
View File

@ -0,0 +1,17 @@
FROM python:3.12-slim
WORKDIR /app
COPY requirements.txt .
RUN pip install --no-cache-dir -r requirements.txt
COPY . .
# Build frontend if present
RUN if [ -d "../frontend" ]; then \
cd ../frontend && npm install && npm run build; \
fi
EXPOSE 8000
CMD ["uvicorn", "app.main:app", "--host", "0.0.0.0", "--port", "8000"]

36
backend/alembic.ini Normal file
View File

@ -0,0 +1,36 @@
[alembic]
script_location = alembic
sqlalchemy.url = mysql+pymysql://dbuser:Tata%401234@192.168.8.160:3306/taskpulse?charset=utf8mb4
[loggers]
keys = root,sqlalchemy,alembic
[handlers]
keys = console
[formatters]
keys = generic
[logger_root]
level = WARN
handlers = console
[logger_sqlalchemy]
level = WARN
handlers =
qualname = sqlalchemy.engine
[logger_alembic]
level = INFO
handlers =
qualname = alembic
[handler_console]
class = StreamHandler
args = (sys.stderr,)
level = NOTSET
formatter = generic
[formatter_generic]
format = %(levelname)-5.5s [%(name)s] %(message)s
datefmt = %H:%M:%S

53
backend/alembic/env.py Normal file
View File

@ -0,0 +1,53 @@
"""Alembic environment config (async)."""
import asyncio
from logging.config import fileConfig
from alembic import context
from sqlalchemy import pool
from sqlalchemy.ext.asyncio import async_engine_from_config
from app.config import settings
from app.database import Base
# Import all models so Alembic can detect them
import app.models # noqa: F401
config = context.config
config.set_main_option("sqlalchemy.url", settings.DATABASE_URL_SYNC)
if config.config_file_name is not None:
fileConfig(config.config_file_name)
target_metadata = Base.metadata
def run_migrations_offline() -> None:
url = config.get_main_option("sqlalchemy.url")
context.configure(url=url, target_metadata=target_metadata, literal_binds=True)
with context.begin_transaction():
context.run_migrations()
def do_run_migrations(connection):
context.configure(connection=connection, target_metadata=target_metadata)
with context.begin_transaction():
context.run_migrations()
async def run_async_migrations() -> None:
from sqlalchemy.ext.asyncio import create_async_engine
connectable = create_async_engine(settings.DATABASE_URL, poolclass=pool.NullPool)
async with connectable.connect() as connection:
await connection.run_sync(do_run_migrations)
await connectable.dispose()
def run_migrations_online() -> None:
asyncio.run(run_async_migrations())
if context.is_offline_mode():
run_migrations_offline()
else:
run_migrations_online()

View File

@ -0,0 +1,111 @@
"""Alembic generic migration script."""
"""
Revision ID: 0001
Revises:
Create Date: 2025-01-01
"""
from typing import Sequence, Union
from alembic import op
import sqlalchemy as sa
revision: str = "0001"
down_revision: Union[str, None] = None
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
def upgrade() -> None:
op.create_table(
"agents",
sa.Column("id", sa.Integer, primary_key=True, autoincrement=True),
sa.Column("name", sa.String(128), unique=True, nullable=False),
sa.Column("description", sa.Text, default=""),
sa.Column("api_key", sa.String(128), unique=True),
sa.Column("status", sa.String(32), default="active"),
sa.Column("last_heartbeat_at", sa.DateTime, nullable=True),
sa.Column("created_at", sa.DateTime, server_default=sa.func.now()),
sa.Column("updated_at", sa.DateTime, server_default=sa.func.now()),
)
op.create_table(
"notification_channels",
sa.Column("id", sa.Integer, primary_key=True, autoincrement=True),
sa.Column("name", sa.String(128), nullable=False),
sa.Column("channel_type", sa.String(32), nullable=False),
sa.Column("config", sa.Text, nullable=False),
sa.Column("enabled", sa.Boolean, default=True),
sa.Column("created_at", sa.DateTime, server_default=sa.func.now()),
sa.Column("updated_at", sa.DateTime, server_default=sa.func.now()),
)
op.create_table(
"scheduled_tasks",
sa.Column("id", sa.Integer, primary_key=True, autoincrement=True),
sa.Column("agent_id", sa.Integer, sa.ForeignKey("agents.id", ondelete="CASCADE"), nullable=False),
sa.Column("name", sa.String(256), nullable=False),
sa.Column("description", sa.Text, default=""),
sa.Column("cron_expression", sa.String(64), nullable=False),
sa.Column("grace_period", sa.Integer, default=300),
sa.Column("status", sa.String(32), default="active"),
sa.Column("last_run_at", sa.DateTime, nullable=True),
sa.Column("last_run_result", sa.String(32), nullable=True),
sa.Column("next_run_at", sa.DateTime, nullable=True),
sa.Column("total_run_count", sa.Integer, default=0),
sa.Column("created_at", sa.DateTime, server_default=sa.func.now()),
sa.Column("updated_at", sa.DateTime, server_default=sa.func.now()),
)
op.create_table(
"task_executions",
sa.Column("id", sa.Integer, primary_key=True, autoincrement=True),
sa.Column("task_id", sa.Integer, sa.ForeignKey("scheduled_tasks.id", ondelete="CASCADE"), nullable=False),
sa.Column("agent_id", sa.Integer, sa.ForeignKey("agents.id", ondelete="CASCADE"), nullable=False),
sa.Column("status", sa.String(32), default="running"),
sa.Column("started_at", sa.DateTime, server_default=sa.func.now()),
sa.Column("finished_at", sa.DateTime, nullable=True),
sa.Column("duration_ms", sa.Integer, nullable=True),
sa.Column("result", sa.Text, nullable=True),
sa.Column("log", sa.Text, nullable=True),
sa.Column("error_message", sa.Text, nullable=True),
sa.Column("created_at", sa.DateTime, server_default=sa.func.now()),
)
op.create_table(
"alert_rules",
sa.Column("id", sa.Integer, primary_key=True, autoincrement=True),
sa.Column("task_id", sa.Integer, sa.ForeignKey("scheduled_tasks.id", ondelete="CASCADE"), nullable=False),
sa.Column("channel_id", sa.Integer, sa.ForeignKey("notification_channels.id", ondelete="CASCADE"), nullable=False),
sa.Column("alert_type", sa.String(32), default="missed_run"),
sa.Column("enabled", sa.Boolean, default=True),
sa.Column("created_at", sa.DateTime, server_default=sa.func.now()),
)
op.create_table(
"alerts",
sa.Column("id", sa.Integer, primary_key=True, autoincrement=True),
sa.Column("task_id", sa.Integer, sa.ForeignKey("scheduled_tasks.id", ondelete="SET NULL"), nullable=True),
sa.Column("alert_type", sa.String(32), nullable=False),
sa.Column("message", sa.Text, nullable=False),
sa.Column("status", sa.String(32), default="pending"),
sa.Column("acknowledged_at", sa.DateTime, nullable=True),
sa.Column("created_at", sa.DateTime, server_default=sa.func.now()),
)
op.create_table(
"system_config",
sa.Column("key", sa.String(128), primary_key=True),
sa.Column("value", sa.Text, nullable=False),
sa.Column("description", sa.String(256), default=""),
sa.Column("updated_at", sa.DateTime, server_default=sa.func.now()),
)
def downgrade() -> None:
op.drop_table("system_config")
op.drop_table("alerts")
op.drop_table("alert_rules")
op.drop_table("task_executions")
op.drop_table("scheduled_tasks")
op.drop_table("notification_channels")
op.drop_table("agents")

0
backend/app/__init__.py Normal file
View File

View File

@ -0,0 +1,25 @@
"""API __init__ + shared dependencies."""
from fastapi import Header, HTTPException
from sqlalchemy.ext.asyncio import AsyncSession
from app.database import get_db
from app.models import Agent
from app.services import AgentService
async def verify_api_key(
authorization: str = Header("", alias="Authorization"),
db: AsyncSession = None, # injected via Depends in router
) -> Agent:
"""Dependency: verify API Key in Authorization header."""
if not authorization.startswith("Bearer "):
raise HTTPException(status_code=401, detail="Missing or invalid Authorization header")
api_key = authorization[7:]
svc = AgentService(db)
agent = await svc.get_by_api_key(api_key)
if agent is None:
raise HTTPException(status_code=401, detail="Invalid API Key")
if agent.status != "active":
raise HTTPException(status_code=403, detail="Agent is inactive")
return agent

87
backend/app/api/agents.py Normal file
View File

@ -0,0 +1,87 @@
"""API router — Agent management."""
from fastapi import APIRouter, Depends, HTTPException
from sqlalchemy.ext.asyncio import AsyncSession
from app.database import get_db
from app.schemas import AgentBatchRegister, AgentBatchRegisterResult, AgentCreate, AgentHeartbeat, AgentOut, AgentUpdate
from app.services import AgentService, TaskService
router = APIRouter(prefix="/api/agents", tags=["agents"])
@router.post("", response_model=AgentOut, status_code=201)
async def register_agent(body: AgentCreate, db: AsyncSession = Depends(get_db)):
svc = AgentService(db)
agent = await svc.create(name=body.name, description=body.description)
task_count = await svc.get_task_count(agent.id)
return AgentOut(**{**agent.__dict__, "task_count": task_count})
@router.get("", response_model=list[AgentOut])
async def list_agents(db: AsyncSession = Depends(get_db)):
svc = AgentService(db)
agents = await svc.list_all()
result = []
for a in agents:
cnt = await svc.get_task_count(a.id)
result.append(AgentOut(**{**a.__dict__, "task_count": cnt}))
return result
@router.get("/{agent_id}", response_model=AgentOut)
async def get_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")
cnt = await svc.get_task_count(agent.id)
return AgentOut(**{**agent.__dict__, "task_count": cnt})
@router.put("/{agent_id}", response_model=AgentOut)
async def update_agent(agent_id: int, body: AgentUpdate, db: AsyncSession = Depends(get_db)):
svc = AgentService(db)
agent = await svc.update(agent_id, **body.model_dump(exclude_none=True))
if not agent:
raise HTTPException(404, "Agent not found")
cnt = await svc.get_task_count(agent.id)
return AgentOut(**{**agent.__dict__, "task_count": cnt})
@router.post("/{agent_id}/heartbeat", response_model=AgentOut)
async def heartbeat(agent_id: int, body: AgentHeartbeat, db: AsyncSession = Depends(get_db)):
svc = AgentService(db)
agent = await svc.heartbeat(agent_id)
if not agent:
raise HTTPException(404, "Agent not found")
cnt = await svc.get_task_count(agent.id)
return AgentOut(**{**agent.__dict__, "task_count": cnt})
# ── Batch registration: AI agent self-registers with all its tasks ─────
@router.post("/register-with-tasks", response_model=AgentBatchRegisterResult, status_code=201)
async def register_agent_with_tasks(body: AgentBatchRegister, db: AsyncSession = Depends(get_db)):
"""AI Agent 一次性注册自己 + 所有定时任务。"""
agent_svc = AgentService(db)
task_svc = TaskService(db)
agent = await agent_svc.create(name=body.name, description=body.description)
tasks_created = 0
for t in body.tasks:
await task_svc.create(
agent_id=agent.id,
name=t.name,
cron_expression=t.cron_expression,
description=t.description,
grace_period=t.grace_period,
)
tasks_created += 1
await db.commit()
task_count = await agent_svc.get_task_count(agent.id)
return AgentBatchRegisterResult(
agent=AgentOut(**{**agent.__dict__, "task_count": task_count}),
tasks_created=tasks_created,
)

View File

@ -0,0 +1,115 @@
"""API router — Dashboard overview."""
from datetime import datetime, timedelta
from fastapi import APIRouter, Depends
from pydantic import BaseModel
from sqlalchemy import select, func
from sqlalchemy.ext.asyncio import AsyncSession
from app.database import get_db
from app.models import Agent, Alert, ScheduledTask, TaskExecution
router = APIRouter(prefix="/api/dashboard", tags=["dashboard"])
class DashboardSummary(BaseModel):
total_agents: int
active_agents: int
total_tasks: int
active_tasks: int
total_executions: int
recent_executions_ok: int
recent_executions_failed: int
pending_alerts: int
class DashboardTaskItem(BaseModel):
id: int
agent_name: str
name: str
cron_expression: str
status: str
last_run_at: datetime | None = None
last_run_result: str | None = None
next_run_at: datetime | None = None
total_run_count: int = 0
@router.get("/summary", response_model=DashboardSummary)
async def dashboard_summary(db: AsyncSession = Depends(get_db)):
# Agents
total_agents = (await db.execute(select(func.count(Agent.id)))).scalar() or 0
active_agents = (
await db.execute(select(func.count(Agent.id)).where(Agent.status == "active"))
).scalar() or 0
# Tasks
total_tasks = (await db.execute(select(func.count(ScheduledTask.id)))).scalar() or 0
active_tasks = (
await db.execute(
select(func.count(ScheduledTask.id)).where(ScheduledTask.status == "active")
)
).scalar() or 0
# Executions (last 24h)
since = datetime.utcnow() - timedelta(hours=24)
total_execs = (
await db.execute(
select(func.count(TaskExecution.id)).where(TaskExecution.created_at >= since)
)
).scalar() or 0
ok_execs = (
await db.execute(
select(func.count(TaskExecution.id)).where(
TaskExecution.created_at >= since, TaskExecution.status == "success"
)
)
).scalar() or 0
failed_execs = (
await db.execute(
select(func.count(TaskExecution.id)).where(
TaskExecution.created_at >= since, TaskExecution.status == "failed"
)
)
).scalar() or 0
# Alerts
pending_alerts = (
await db.execute(
select(func.count(Alert.id)).where(Alert.status == "pending")
)
).scalar() or 0
return DashboardSummary(
total_agents=total_agents,
active_agents=active_agents,
total_tasks=total_tasks,
active_tasks=active_tasks,
total_executions=total_execs,
recent_executions_ok=ok_execs,
recent_executions_failed=failed_execs,
pending_alerts=pending_alerts,
)
@router.get("/tasks", response_model=list[DashboardTaskItem])
async def dashboard_tasks(db: AsyncSession = Depends(get_db)):
"""Return all tasks with agent name for the unified view."""
stmt = select(ScheduledTask).order_by(ScheduledTask.id)
tasks = (await db.execute(stmt)).scalars().all()
result = []
for t in tasks:
agent = await db.get(Agent, t.agent_id)
result.append(DashboardTaskItem(
id=t.id,
agent_name=agent.name if agent else "",
name=t.name,
cron_expression=t.cron_expression,
status=t.status,
last_run_at=t.last_run_at,
last_run_result=t.last_run_result,
next_run_at=t.next_run_at,
total_run_count=t.total_run_count or 0,
))
return result

View File

@ -0,0 +1,70 @@
"""API router — TaskExecution (reporting + query)."""
from fastapi import APIRouter, Depends, HTTPException, Query
from sqlalchemy.ext.asyncio import AsyncSession
from app.database import get_db
from app.schemas import ExecutionOut, ExecutionReport
from app.services import ExecutionService, TaskService, AgentService
router = APIRouter(prefix="/api/tasks/{task_id}/executions", tags=["executions"])
@router.post("", response_model=ExecutionOut, status_code=201)
async def report_execution(task_id: int, body: ExecutionReport,
db: AsyncSession = Depends(get_db)):
"""Agent reports execution result for a task."""
# Verify task exists
task_svc = TaskService(db)
task = await task_svc.get(task_id)
if not task:
raise HTTPException(404, "Task not found")
exec_svc = ExecutionService(db)
record = await exec_svc.report_result(
task_id=task_id,
agent_id=task.agent_id,
status=body.status,
finished_at=body.finished_at,
duration_ms=body.duration_ms,
result=body.result,
log=body.log,
error_message=body.error_message,
)
# Update task's last_run info
await task_svc.mark_run(task_id, success=(body.status == "success"))
await db.commit()
return ExecutionOut(**record.__dict__)
@router.get("", response_model=list[ExecutionOut])
async def list_executions(
task_id: int,
limit: int = Query(50, ge=1, le=200),
offset: int = Query(0, ge=0),
db: AsyncSession = Depends(get_db),
):
exec_svc = ExecutionService(db)
records = await exec_svc.get_executions(task_id, limit=limit, offset=offset)
return [ExecutionOut(**r.__dict__) for r in records]
@router.get("/recent", response_model=list[ExecutionOut])
async def recent_executions(
limit: int = Query(100, ge=1, le=500),
db: AsyncSession = Depends(get_db),
):
"""Get recent executions across all tasks (for dashboard)."""
exec_svc = ExecutionService(db)
records = await exec_svc.get_recent_executions(limit=limit)
return [ExecutionOut(**r.__dict__) for r in records]
@router.get("/{execution_id}", response_model=ExecutionOut)
async def get_execution(execution_id: int, db: AsyncSession = Depends(get_db)):
exec_svc = ExecutionService(db)
record = await exec_svc.get_execution(execution_id)
if not record:
raise HTTPException(404, "Execution not found")
return ExecutionOut(**record.__dict__)

View File

@ -0,0 +1,158 @@
"""API router — Notifications & Alerts."""
from fastapi import APIRouter, Depends, HTTPException, Query
from sqlalchemy.ext.asyncio import AsyncSession
from app.database import get_db
from app.schemas import (
AlertAcknowledge,
AlertOut,
AlertRuleCreate,
AlertRuleOut,
NotificationChannelCreate,
NotificationChannelOut,
NotificationChannelUpdate,
)
from app.models import Alert, AlertRule, NotificationChannel, ScheduledTask
router = APIRouter(prefix="/api", tags=["notifications"])
# ── Notification Channels ─────────────────────────────────────────────
@router.post("/notification-channels", response_model=NotificationChannelOut, status_code=201)
async def create_channel(body: NotificationChannelCreate, db: AsyncSession = Depends(get_db)):
channel = NotificationChannel(
name=body.name,
channel_type=body.channel_type,
config=body.config,
)
db.add(channel)
await db.flush()
await db.refresh(channel)
await db.commit()
return NotificationChannelOut(**channel.__dict__)
@router.get("/notification-channels", response_model=list[NotificationChannelOut])
async def list_channels(db: AsyncSession = Depends(get_db)):
from sqlalchemy import select
result = await db.execute(select(NotificationChannel).order_by(NotificationChannel.id))
channels = result.scalars().all()
return [NotificationChannelOut(**c.__dict__) for c in channels]
@router.put("/notification-channels/{channel_id}", response_model=NotificationChannelOut)
async def update_channel(channel_id: int, body: NotificationChannelUpdate,
db: AsyncSession = Depends(get_db)):
channel = await db.get(NotificationChannel, channel_id)
if not channel:
raise HTTPException(404, "Channel not found")
for k, v in body.model_dump(exclude_none=True).items():
setattr(channel, k, v)
await db.flush()
await db.commit()
return NotificationChannelOut(**channel.__dict__)
@router.delete("/notification-channels/{channel_id}", status_code=204)
async def delete_channel(channel_id: int, db: AsyncSession = Depends(get_db)):
channel = await db.get(NotificationChannel, channel_id)
if not channel:
raise HTTPException(404, "Channel not found")
await db.delete(channel)
await db.flush()
await db.commit()
# ── Alert Rules ───────────────────────────────────────────────────────
@router.post("/alert-rules", response_model=AlertRuleOut, status_code=201)
async def create_alert_rule(body: AlertRuleCreate, db: AsyncSession = Depends(get_db)):
# Verify task & channel exist
task = await db.get(ScheduledTask, body.task_id)
if not task:
raise HTTPException(404, "Task not found")
channel = await db.get(NotificationChannel, body.channel_id)
if not channel:
raise HTTPException(404, "Channel not found")
rule = AlertRule(
task_id=body.task_id,
channel_id=body.channel_id,
alert_type=body.alert_type,
)
db.add(rule)
await db.flush()
await db.refresh(rule)
await db.commit()
return AlertRuleOut(**{**rule.__dict__, "channel_name": channel.name})
@router.get("/alert-rules", response_model=list[AlertRuleOut])
async def list_alert_rules(task_id: int | None = Query(None), db: AsyncSession = Depends(get_db)):
from sqlalchemy import select
stmt = select(AlertRule)
if task_id:
stmt = stmt.where(AlertRule.task_id == task_id)
result = await db.execute(stmt)
rules = result.scalars().all()
output = []
for r in rules:
ch = await db.get(NotificationChannel, r.channel_id)
output.append(AlertRuleOut(**{**r.__dict__, "channel_name": ch.name if ch else ""}))
return output
@router.delete("/alert-rules/{rule_id}", status_code=204)
async def delete_alert_rule(rule_id: int, db: AsyncSession = Depends(get_db)):
rule = await db.get(AlertRule, rule_id)
if not rule:
raise HTTPException(404, "Alert rule not found")
await db.delete(rule)
await db.flush()
await db.commit()
# ── Alerts ────────────────────────────────────────────────────────────
@router.get("/alerts", response_model=list[AlertOut])
async def list_alerts(
status: str | None = Query(None),
limit: int = Query(100, ge=1, le=500),
offset: int = Query(0, ge=0),
db: AsyncSession = Depends(get_db),
):
from sqlalchemy import select, desc
stmt = select(Alert).order_by(desc(Alert.id)).offset(offset).limit(limit)
if status:
stmt = stmt.where(Alert.status == status)
result = await db.execute(stmt)
alerts = result.scalars().all()
output = []
for a in alerts:
task_name = ""
if a.task_id:
task = await db.get(ScheduledTask, a.task_id)
task_name = task.name if task else ""
output.append(AlertOut(**{**a.__dict__, "task_name": task_name}))
return output
@router.post("/alerts/{alert_id}/acknowledge", response_model=AlertOut)
async def acknowledge_alert(alert_id: int, body: AlertAcknowledge,
db: AsyncSession = Depends(get_db)):
from datetime import datetime
alert = await db.get(Alert, alert_id)
if not alert:
raise HTTPException(404, "Alert not found")
alert.status = "acknowledged"
alert.acknowledged_at = datetime.utcnow()
await db.flush()
await db.commit()
task_name = ""
if alert.task_id:
task = await db.get(ScheduledTask, alert.task_id)
task_name = task.name if task else ""
return AlertOut(**{**alert.__dict__, "task_name": task_name})

77
backend/app/api/system.py Normal file
View File

@ -0,0 +1,77 @@
"""API router — System configuration (base_url, etc.)."""
from fastapi import APIRouter, Depends, Request
from pydantic import BaseModel
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
from app.database import get_db
from app.models.system_config import SystemConfig
router = APIRouter(prefix="/api/system", tags=["system"])
class SystemConfigOut(BaseModel):
base_url: str = ""
scheduler_check_interval: int = 60
class SystemConfigUpdate(BaseModel):
base_url: str = ""
@router.get("/config", response_model=SystemConfigOut)
async def get_system_config(request: Request, db: AsyncSession = Depends(get_db)):
"""返回系统配置。base_url 默认从请求 host 推断,也可从数据库读取用户配置。"""
# Try to get from DB first
stmt = select(SystemConfig).where(SystemConfig.key == "base_url")
result = await db.execute(stmt)
row = result.scalar_one_or_none()
if row and row.value:
base_url = row.value
else:
# Infer from request
scheme = request.headers.get("x-forwarded-proto", request.url.scheme)
host = request.headers.get("host", request.url.hostname)
base_url = f"{scheme}://{host}"
return SystemConfigOut(
base_url=base_url,
scheduler_check_interval=60,
)
@router.put("/config", response_model=SystemConfigOut)
async def update_system_config(body: SystemConfigUpdate, request: Request,
db: AsyncSession = Depends(get_db)):
"""更新系统配置并持久化到数据库。"""
# Upsert base_url
stmt = select(SystemConfig).where(SystemConfig.key == "base_url")
result = await db.execute(stmt)
row = result.scalar_one_or_none()
if body.base_url:
if row:
row.value = body.base_url
else:
row = SystemConfig(key="base_url", value=body.base_url,
description="系统公网访问地址")
db.add(row)
else:
# If clearing, remove from DB so we fall back to request host
if row:
await db.delete(row)
await db.flush()
await db.commit()
# Return current effective config
scheme = request.headers.get("x-forwarded-proto", request.url.scheme)
host = request.headers.get("host", request.url.hostname)
effective_base = body.base_url or f"{scheme}://{host}"
return SystemConfigOut(
base_url=effective_base,
scheduler_check_interval=60,
)

74
backend/app/api/tasks.py Normal file
View File

@ -0,0 +1,74 @@
"""API router — Task management."""
from fastapi import APIRouter, Depends, HTTPException
from sqlalchemy.ext.asyncio import AsyncSession
from app.database import get_db
from app.schemas import TaskCreate, TaskOut, TaskUpdate
from app.services import TaskService, AgentService
router = APIRouter(prefix="/api/tasks", tags=["tasks"])
@router.post("", response_model=TaskOut, status_code=201)
async def create_task(body: TaskCreate, agent_id: int, db: AsyncSession = Depends(get_db)):
"""Create a task for a specific agent. Pass agent_id as query param."""
svc = TaskService(db)
agent_svc = AgentService(db)
agent = await agent_svc.get(agent_id)
if not agent:
raise HTTPException(404, "Agent not found")
task = await svc.create(
agent_id=agent_id,
name=body.name,
cron_expression=body.cron_expression,
description=body.description,
grace_period=body.grace_period,
)
return TaskOut(**{**task.__dict__, "agent_name": agent.name})
@router.get("", response_model=list[TaskOut])
async def list_tasks(agent_id: int | None = None, db: AsyncSession = Depends(get_db)):
"""List all tasks, optionally filtered by agent_id."""
svc = TaskService(db)
agent_svc = AgentService(db)
if agent_id:
tasks = await svc.list_by_agent(agent_id)
else:
tasks = await svc.list_all()
result = []
for t in tasks:
agent = await agent_svc.get(t.agent_id)
result.append(TaskOut(**{**t.__dict__, "agent_name": agent.name if agent else ""}))
return result
@router.get("/{task_id}", response_model=TaskOut)
async def get_task(task_id: int, db: AsyncSession = Depends(get_db)):
svc = TaskService(db)
agent_svc = AgentService(db)
task = await svc.get(task_id)
if not task:
raise HTTPException(404, "Task not found")
agent = await agent_svc.get(task.agent_id)
return TaskOut(**{**task.__dict__, "agent_name": agent.name if agent else ""})
@router.put("/{task_id}", response_model=TaskOut)
async def update_task(task_id: int, body: TaskUpdate, db: AsyncSession = Depends(get_db)):
svc = TaskService(db)
agent_svc = AgentService(db)
task = await svc.update(task_id, **body.model_dump(exclude_none=True))
if not task:
raise HTTPException(404, "Task not found")
agent = await agent_svc.get(task.agent_id)
return TaskOut(**{**task.__dict__, "agent_name": agent.name if agent else ""})
@router.delete("/{task_id}", status_code=204)
async def delete_task(task_id: int, db: AsyncSession = Depends(get_db)):
svc = TaskService(db)
ok = await svc.delete(task_id)
if not ok:
raise HTTPException(404, "Task not found")

49
backend/app/config.py Normal file
View File

@ -0,0 +1,49 @@
"""Application configuration via environment variables."""
import urllib.parse
from pydantic_settings import BaseSettings
class Settings(BaseSettings):
# Database (internal network)
DB_HOST: str = "192.168.8.160"
DB_PORT: int = 3306
DB_USER: str = "dbuser"
DB_PASSWORD: str = "Tata@1234"
DB_NAME: str = "taskpulse"
# App
APP_HOST: str = "0.0.0.0"
APP_PORT: int = 8000
DEBUG: bool = True
# Secret
SECRET_KEY: str = "change-me-in-production"
# Scheduler
SCHEDULER_CHECK_INTERVAL: int = 60 # seconds
TASK_GRACE_PERIOD: int = 300 # seconds — how long after expected start before alerting
# Notifications
FEISHU_WEBHOOK_URL: str = ""
SMTP_HOST: str = ""
SMTP_PORT: int = 587
SMTP_USER: str = ""
SMTP_PASSWORD: str = ""
SMTP_FROM: str = "taskpulse@example.com"
@property
def DATABASE_URL(self) -> str:
pwd = urllib.parse.quote_plus(self.DB_PASSWORD)
return f"mysql+aiomysql://{self.DB_USER}:{pwd}@{self.DB_HOST}:{self.DB_PORT}/{self.DB_NAME}?charset=utf8mb4"
@property
def DATABASE_URL_SYNC(self) -> str:
pwd = urllib.parse.quote_plus(self.DB_PASSWORD)
return f"mysql+pymysql://{self.DB_USER}:{pwd}@{self.DB_HOST}:{self.DB_PORT}/{self.DB_NAME}?charset=utf8mb4"
model_config = {"env_prefix": "TASKPULSE_", "env_file": ".env"}
settings = Settings()

24
backend/app/database.py Normal file
View File

@ -0,0 +1,24 @@
"""Database engine and session management."""
from sqlalchemy.ext.asyncio import AsyncSession, async_sessionmaker, create_async_engine
from sqlalchemy.orm import DeclarativeBase
from app.config import settings
engine = create_async_engine(settings.DATABASE_URL, echo=settings.DEBUG)
async_session_factory = async_sessionmaker(engine, class_=AsyncSession, expire_on_commit=False)
class Base(DeclarativeBase):
pass
async def get_db() -> AsyncSession:
"""FastAPI dependency that yields a database session."""
async with async_session_factory() as session:
try:
yield session
await session.commit()
except Exception:
await session.rollback()
raise

80
backend/app/main.py Normal file
View File

@ -0,0 +1,80 @@
"""FastAPI application entry point."""
import asyncio
import logging
from pathlib import Path
from contextlib import asynccontextmanager
from fastapi import FastAPI, Request
from fastapi.responses import HTMLResponse
from fastapi.staticfiles import StaticFiles
from app.api.agents import router as agents_router
from app.api.tasks import router as tasks_router
from app.api.executions import router as executions_router
from app.api.notifications import router as notifications_router
from app.api.dashboard import router as dashboard_router
from app.api.system import router as system_router
from app.config import settings
from app.database import engine, Base
from app.services.scheduler import SchedulerService
logging.basicConfig(level=logging.INFO, format="%(asctime)s %(name)s %(levelname)s %(message)s")
logger = logging.getLogger("taskpulse")
scheduler = SchedulerService()
@asynccontextmanager
async def lifespan(app: FastAPI):
"""Startup: create tables + start background scheduler."""
async with engine.begin() as conn:
await conn.run_sync(Base.metadata.create_all)
logger.info("Database tables ensured")
# Start background scheduler
task = asyncio.create_task(scheduler.start())
yield
# Shutdown
await scheduler.stop()
task.cancel()
await engine.dispose()
app = FastAPI(
title="TaskPulse — AI Agent Task Monitor",
description="Unified dashboard for tracking, monitoring and alerting on AI agent scheduled tasks.",
version="1.0.0",
lifespan=lifespan,
)
# API routers
app.include_router(agents_router)
app.include_router(tasks_router)
app.include_router(executions_router)
app.include_router(notifications_router)
app.include_router(dashboard_router)
app.include_router(system_router)
# Serve Vue3 static files (SPA — monolithic deployment)
FRONTEND_DIST = Path(__file__).resolve().parent.parent.parent / "frontend" / "dist"
if FRONTEND_DIST.is_dir():
# Mount assets directory for JS/CSS/fonts
app.mount("/assets", StaticFiles(directory=str(FRONTEND_DIST / "assets")), name="assets")
@app.exception_handler(404)
async def spa_fallback(request: Request, exc):
"""Return index.html for any non-API route (Vue Router SPA)."""
if not request.url.path.startswith("/api"):
content = (FRONTEND_DIST / "index.html").read_text(encoding="utf-8")
return HTMLResponse(content=content, status_code=200)
raise exc
logger.info("Frontend SPA mounted from %s", FRONTEND_DIST)
else:
logger.info("Frontend dist not found at %s — API-only mode", FRONTEND_DIST)
@app.get("/api/health")
async def health():
return {"status": "ok", "service": "taskpulse"}

View File

@ -0,0 +1,9 @@
"""Model registry — all models are imported here so Alembic can discover them."""
from app.models.agent import Agent # noqa: F401
from app.models.task import ScheduledTask # noqa: F401
from app.models.execution import TaskExecution # noqa: F401
from app.models.notification import Alert, AlertRule, NotificationChannel # noqa: F401
from app.models.system_config import SystemConfig # noqa: F401
__all__ = ["Agent", "ScheduledTask", "TaskExecution", "NotificationChannel", "AlertRule", "Alert", "SystemConfig"]

View File

@ -0,0 +1,31 @@
"""SQLAlchemy models — Agent."""
import uuid
from datetime import datetime
from sqlalchemy import Boolean, DateTime, Float, String, Text, func
from sqlalchemy.orm import Mapped, mapped_column, relationship
from app.database import Base
def _gen_api_key() -> str:
return f"tp_{uuid.uuid4().hex}"
class Agent(Base):
__tablename__ = "agents"
id: Mapped[int] = mapped_column(primary_key=True, autoincrement=True)
name: Mapped[str] = mapped_column(String(128), unique=True, nullable=False, comment="Agent 名称")
description: Mapped[str] = mapped_column(Text, default="", comment="描述")
api_key: Mapped[str] = mapped_column(String(128), unique=True, default=_gen_api_key, comment="API Key")
status: Mapped[str] = mapped_column(String(32), default="active", comment="active / inactive")
last_heartbeat_at: Mapped[datetime | None] = mapped_column(DateTime, nullable=True, comment="最后心跳时间")
created_at: Mapped[datetime] = mapped_column(DateTime, server_default=func.now())
updated_at: Mapped[datetime] = mapped_column(DateTime, server_default=func.now(), onupdate=func.now())
tasks = relationship("ScheduledTask", back_populates="agent")
def __repr__(self) -> str:
return f"<Agent {self.name}>"

View File

@ -0,0 +1,30 @@
"""SQLAlchemy models — TaskExecution."""
from datetime import datetime
from sqlalchemy import DateTime, ForeignKey, Integer, String, Text, func
from sqlalchemy.orm import Mapped, mapped_column, relationship
from app.database import Base
class TaskExecution(Base):
__tablename__ = "task_executions"
id: Mapped[int] = mapped_column(primary_key=True, autoincrement=True)
task_id: Mapped[int] = mapped_column(ForeignKey("scheduled_tasks.id", ondelete="CASCADE"), nullable=False)
agent_id: Mapped[int] = mapped_column(ForeignKey("agents.id", ondelete="CASCADE"), nullable=False)
status: Mapped[str] = mapped_column(String(32), default="running", comment="running / success / failed")
started_at: Mapped[datetime] = mapped_column(DateTime, server_default=func.now(), comment="开始时间")
finished_at: Mapped[datetime | None] = mapped_column(DateTime, nullable=True, comment="结束时间")
duration_ms: Mapped[int | None] = mapped_column(Integer, nullable=True, comment="耗时(毫秒)")
result: Mapped[str | None] = mapped_column(Text, nullable=True, comment="执行结果摘要 (JSON)")
log: Mapped[str | None] = mapped_column(Text, nullable=True, comment="执行日志")
error_message: Mapped[str | None] = mapped_column(Text, nullable=True, comment="错误信息")
created_at: Mapped[datetime] = mapped_column(DateTime, server_default=func.now())
task = relationship("ScheduledTask", back_populates="executions")
agent = relationship("Agent")
def __repr__(self) -> str:
return f"<TaskExecution #{self.id} task={self.task_id} status={self.status}>"

View File

@ -0,0 +1,60 @@
"""SQLAlchemy models — NotificationChannel & AlertRule & AlertHistory."""
from datetime import datetime
from sqlalchemy import Boolean, DateTime, ForeignKey, Integer, String, Text, func
from sqlalchemy.orm import Mapped, mapped_column, relationship
from app.database import Base
class NotificationChannel(Base):
"""用户绑定的通知渠道"""
__tablename__ = "notification_channels"
id: Mapped[int] = mapped_column(primary_key=True, autoincrement=True)
name: Mapped[str] = mapped_column(String(128), nullable=False, comment="渠道名称, e.g. 我的飞书")
channel_type: Mapped[str] = mapped_column(String(32), nullable=False, comment="feishu_cli / email / webhook")
config: Mapped[str] = mapped_column(Text, nullable=False, comment="JSON 配置")
enabled: Mapped[bool] = mapped_column(default=True)
created_at: Mapped[datetime] = mapped_column(DateTime, server_default=func.now())
updated_at: Mapped[datetime] = mapped_column(DateTime, server_default=func.now(), onupdate=func.now())
alert_rules = relationship("AlertRule", back_populates="channel", cascade="all, delete-orphan")
def __repr__(self) -> str:
return f"<NotificationChannel {self.name} ({self.channel_type})>"
class AlertRule(Base):
"""告警规则:哪些任务触发哪些通知渠道"""
__tablename__ = "alert_rules"
id: Mapped[int] = mapped_column(primary_key=True, autoincrement=True)
task_id: Mapped[int] = mapped_column(ForeignKey("scheduled_tasks.id", ondelete="CASCADE"), nullable=False)
channel_id: Mapped[int] = mapped_column(ForeignKey("notification_channels.id", ondelete="CASCADE"), nullable=False)
alert_type: Mapped[str] = mapped_column(String(32), default="missed_run", comment="missed_run / failure")
enabled: Mapped[bool] = mapped_column(default=True)
created_at: Mapped[datetime] = mapped_column(DateTime, server_default=func.now())
task = relationship("ScheduledTask", back_populates="alert_rules")
channel = relationship("NotificationChannel", back_populates="alert_rules")
def __repr__(self) -> str:
return f"<AlertRule task={self.task_id} → channel={self.channel_id}>"
class Alert(Base):
"""告警历史"""
__tablename__ = "alerts"
id: Mapped[int] = mapped_column(primary_key=True, autoincrement=True)
task_id: Mapped[int] = mapped_column(ForeignKey("scheduled_tasks.id", ondelete="SET NULL"), nullable=True)
alert_type: Mapped[str] = mapped_column(String(32), nullable=False, comment="missed_run / failure / timeout")
message: Mapped[str] = mapped_column(Text, nullable=False, comment="告警内容")
status: Mapped[str] = mapped_column(String(32), default="pending", comment="pending / acknowledged / resolved")
acknowledged_at: Mapped[datetime | None] = mapped_column(DateTime, nullable=True, comment="确认时间")
created_at: Mapped[datetime] = mapped_column(DateTime, server_default=func.now())
def __repr__(self) -> str:
return f"<Alert #{self.id} type={self.alert_type} status={self.status}>"

View File

@ -0,0 +1,21 @@
"""SQLAlchemy models — SystemConfig (key-value store for system settings)."""
from datetime import datetime
from sqlalchemy import DateTime, String, Text, func
from sqlalchemy.orm import Mapped, mapped_column
from app.database import Base
class SystemConfig(Base):
"""System-level configuration stored as key-value pairs."""
__tablename__ = "system_config"
key: Mapped[str] = mapped_column(String(128), primary_key=True, comment="配置键名")
value: Mapped[str] = mapped_column(Text, nullable=False, comment="配置值 (JSON)")
description: Mapped[str] = mapped_column(String(256), default="", comment="描述")
updated_at: Mapped[datetime] = mapped_column(DateTime, server_default=func.now(), onupdate=func.now())
def __repr__(self) -> str:
return f"<SystemConfig {self.key}={self.value}>"

View File

@ -0,0 +1,36 @@
"""SQLAlchemy models — ScheduledTask."""
from datetime import datetime
from sqlalchemy import Boolean, DateTime, ForeignKey, Integer, String, Text, func
from sqlalchemy.orm import Mapped, mapped_column, relationship
from app.database import Base
class ScheduledTask(Base):
__tablename__ = "scheduled_tasks"
id: Mapped[int] = mapped_column(primary_key=True, autoincrement=True)
agent_id: Mapped[int] = mapped_column(ForeignKey("agents.id", ondelete="CASCADE"), nullable=False)
name: Mapped[str] = mapped_column(String(256), nullable=False, comment="任务名称")
description: Mapped[str] = mapped_column(Text, default="", comment="任务描述")
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="容忍秒数, 超时未执行则告警")
status: Mapped[str] = mapped_column(String(32), default="active", comment="active / paused / stopped")
# 冗余字段方便查询
last_run_at: Mapped[datetime | None] = mapped_column(DateTime, nullable=True, comment="最近一次执行时间")
last_run_result: Mapped[str | None] = mapped_column(String(32), nullable=True, comment="最近一次执行结果 success/failed")
next_run_at: Mapped[datetime | None] = mapped_column(DateTime, nullable=True, comment="预计下次执行时间")
total_run_count: Mapped[int] = mapped_column(Integer, default=0, comment="累计执行次数")
created_at: Mapped[datetime] = mapped_column(DateTime, server_default=func.now())
updated_at: Mapped[datetime] = mapped_column(DateTime, server_default=func.now(), onupdate=func.now())
agent = relationship("Agent", back_populates="tasks")
executions = relationship("TaskExecution", back_populates="task", cascade="all, delete-orphan")
alert_rules = relationship("AlertRule", back_populates="task", cascade="all, delete-orphan")
def __repr__(self) -> str:
return f"<ScheduledTask {self.name}>"

View File

@ -0,0 +1,23 @@
"""Pydantic schema registry."""
from app.schemas.agent import AgentBatchRegister, AgentBatchRegisterResult, AgentCreate, AgentHeartbeat, AgentOut, AgentUpdate # noqa: F401
from app.schemas.execution import ExecutionOut, ExecutionReport # noqa: F401
from app.schemas.notification import ( # noqa: F401
AlertAcknowledge,
AlertOut,
AlertRuleCreate,
AlertRuleOut,
NotificationChannelCreate,
NotificationChannelOut,
NotificationChannelUpdate,
)
from app.schemas.task import TaskCreate, TaskOut, TaskUpdate # noqa: F401
__all__ = [
"AgentCreate", "AgentUpdate", "AgentOut", "AgentHeartbeat",
"TaskCreate", "TaskUpdate", "TaskOut",
"ExecutionReport", "ExecutionOut",
"NotificationChannelCreate", "NotificationChannelUpdate", "NotificationChannelOut",
"AlertRuleCreate", "AlertRuleOut",
"AlertOut", "AlertAcknowledge",
]

View File

@ -0,0 +1,57 @@
"""Pydantic schemas — Agent."""
from datetime import datetime
from pydantic import BaseModel, Field
class AgentCreate(BaseModel):
name: str = Field(..., max_length=128)
description: str = Field(default="")
class AgentUpdate(BaseModel):
name: str | None = Field(None, max_length=128)
description: str | None = None
status: str | None = None # active / inactive
class AgentOut(BaseModel):
id: int
name: str
description: str
api_key: str
status: str
last_heartbeat_at: datetime | None = None
task_count: int = 0
created_at: datetime
updated_at: datetime
model_config = {"from_attributes": True}
class AgentHeartbeat(BaseModel):
pass
# ── Batch registration (AI agent self-registers with tasks) ───────
class TaskRegistrationItem(BaseModel):
"""A single task an agent wants to register."""
name: str = Field(..., max_length=256)
description: str = Field(default="")
cron_expression: str = Field(..., max_length=64)
grace_period: int = Field(default=300, ge=0)
class AgentBatchRegister(BaseModel):
"""AI agent sends this to register itself + all its tasks in one call."""
name: str = Field(..., max_length=128)
description: str = Field(default="")
tasks: list[TaskRegistrationItem] = Field(default_factory=list)
class AgentBatchRegisterResult(BaseModel):
"""Response for batch registration."""
agent: AgentOut
tasks_created: int

View File

@ -0,0 +1,31 @@
"""Pydantic schemas — TaskExecution."""
from datetime import datetime
from pydantic import BaseModel, Field
class ExecutionReport(BaseModel):
"""Agent 汇报执行结果"""
status: str = Field(..., pattern=r"^(success|failed)$", description="success 或 failed")
finished_at: datetime | None = None
duration_ms: int | None = None
result: str | None = Field(None, description="执行结果摘要 (JSON)")
log: str | None = Field(None, description="执行日志文本")
error_message: str | None = None
class ExecutionOut(BaseModel):
id: int
task_id: int
agent_id: int
status: str
started_at: datetime
finished_at: datetime | None = None
duration_ms: int | None = None
result: str | None = None
log: str | None = None
error_message: str | None = None
created_at: datetime
model_config = {"from_attributes": True}

View File

@ -0,0 +1,70 @@
"""Pydantic schemas — Notifications & Alerts."""
from datetime import datetime
from pydantic import BaseModel, Field
# ── Notification Channel ──────────────────────────────────────────────
class NotificationChannelCreate(BaseModel):
name: str = Field(..., max_length=128)
channel_type: str = Field(..., pattern=r"^(feishu_cli|email|webhook)$")
config: str = Field(..., description="JSON 配置字符串")
class NotificationChannelUpdate(BaseModel):
name: str | None = Field(None, max_length=128)
config: str | None = None
enabled: bool | None = None
class NotificationChannelOut(BaseModel):
id: int
name: str
channel_type: str
config: str
enabled: bool
created_at: datetime
updated_at: datetime
model_config = {"from_attributes": True}
# ── Alert Rule ────────────────────────────────────────────────────────
class AlertRuleCreate(BaseModel):
task_id: int
channel_id: int
alert_type: str = Field(default="missed_run", pattern=r"^(missed_run|failure)$")
class AlertRuleOut(BaseModel):
id: int
task_id: int
channel_id: int
channel_name: str = ""
alert_type: str
enabled: bool
created_at: datetime
model_config = {"from_attributes": True}
# ── Alert ─────────────────────────────────────────────────────────────
class AlertOut(BaseModel):
id: int
task_id: int | None = None
task_name: str = ""
alert_type: str
message: str
status: str
acknowledged_at: datetime | None = None
created_at: datetime
model_config = {"from_attributes": True}
class AlertAcknowledge(BaseModel):
pass

View File

@ -0,0 +1,39 @@
"""Pydantic schemas — ScheduledTask."""
from datetime import datetime
from pydantic import BaseModel, Field
class TaskCreate(BaseModel):
name: str = Field(..., max_length=256)
description: str = Field(default="")
cron_expression: str = Field(..., max_length=64)
grace_period: int = Field(default=300, ge=0)
class TaskUpdate(BaseModel):
name: str | None = Field(None, max_length=256)
description: str | None = None
cron_expression: str | None = None
grace_period: int | None = None
status: str | None = None # active / paused / stopped
class TaskOut(BaseModel):
id: int
agent_id: int
agent_name: str = ""
name: str
description: str
cron_expression: str
grace_period: int
status: str
last_run_at: datetime | None = None
last_run_result: str | None = None
next_run_at: datetime | None = None
total_run_count: int = 0
created_at: datetime
updated_at: datetime
model_config = {"from_attributes": True}

View File

@ -0,0 +1,15 @@
"""Service layer __init__."""
from app.services.agent import AgentService # noqa: F401
from app.services.task import TaskService # noqa: F401
from app.services.execution import ExecutionService # noqa: F401
from app.services.scheduler import SchedulerService # noqa: F401
from app.services.notification import NotificationService # noqa: F401
__all__ = [
"AgentService",
"TaskService",
"ExecutionService",
"SchedulerService",
"NotificationService",
]

View File

@ -0,0 +1,54 @@
"""Agent business logic."""
from sqlalchemy import select, func
from sqlalchemy.ext.asyncio import AsyncSession
from app.models import Agent, ScheduledTask
class AgentService:
def __init__(self, db: AsyncSession):
self.db = db
async def create(self, name: str, description: str = "") -> Agent:
agent = Agent(name=name, description=description)
self.db.add(agent)
await self.db.flush()
await self.db.refresh(agent)
return agent
async def get(self, agent_id: int) -> Agent | None:
return await self.db.get(Agent, agent_id)
async def get_by_api_key(self, api_key: str) -> Agent | None:
stmt = select(Agent).where(Agent.api_key == api_key)
result = await self.db.execute(stmt)
return result.scalar_one_or_none()
async def list_all(self) -> list[Agent]:
stmt = select(Agent).order_by(Agent.id)
result = await self.db.execute(stmt)
return list(result.scalars().all())
async def update(self, agent_id: int, **kwargs) -> Agent | None:
agent = await self.get(agent_id)
if agent is None:
return None
for k, v in kwargs.items():
if v is not None and hasattr(agent, k):
setattr(agent, k, v)
await self.db.flush()
return agent
async def heartbeat(self, agent_id: int) -> Agent | None:
from datetime import datetime
agent = await self.get(agent_id)
if agent:
agent.last_heartbeat_at = datetime.utcnow()
await self.db.flush()
return agent
async def get_task_count(self, agent_id: int) -> int:
stmt = select(func.count(ScheduledTask.id)).where(ScheduledTask.agent_id == agent_id)
result = await self.db.execute(stmt)
return result.scalar() or 0

View File

@ -0,0 +1,69 @@
"""TaskExecution business logic."""
from datetime import datetime
from sqlalchemy import select, desc
from sqlalchemy.ext.asyncio import AsyncSession
from app.models import TaskExecution, ScheduledTask, Agent
class ExecutionService:
def __init__(self, db: AsyncSession):
self.db = db
async def start_execution(self, task_id: int, agent_id: int) -> TaskExecution:
"""Agent 开始执行任务时创建一个 running 记录."""
exec_record = TaskExecution(task_id=task_id, agent_id=agent_id, status="running")
self.db.add(exec_record)
await self.db.flush()
await self.db.refresh(exec_record)
return exec_record
async def report_result(self, task_id: int, agent_id: int,
status: str, finished_at: datetime | None = None,
duration_ms: int | None = None,
result: str | None = None,
log: str | None = None,
error_message: str | None = None) -> TaskExecution | None:
"""Agent 汇报执行结果:创建一条完成记录并更新任务的 last_run 信息。"""
now = finished_at or datetime.utcnow()
exec_record = TaskExecution(
task_id=task_id,
agent_id=agent_id,
status=status,
started_at=now,
finished_at=now,
duration_ms=duration_ms,
result=result,
log=log,
error_message=error_message,
)
self.db.add(exec_record)
await self.db.flush()
await self.db.refresh(exec_record)
return exec_record
async def get_executions(self, task_id: int, limit: int = 50, offset: int = 0) -> list[TaskExecution]:
stmt = (
select(TaskExecution)
.where(TaskExecution.task_id == task_id)
.order_by(desc(TaskExecution.id))
.offset(offset)
.limit(limit)
)
result = await self.db.execute(stmt)
return list(result.scalars().all())
async def get_execution(self, execution_id: int) -> TaskExecution | None:
return await self.db.get(TaskExecution, execution_id)
async def get_recent_executions(self, limit: int = 100) -> list[TaskExecution]:
stmt = (
select(TaskExecution)
.order_by(desc(TaskExecution.id))
.limit(limit)
)
result = await self.db.execute(stmt)
return list(result.scalars().all())

View File

@ -0,0 +1,88 @@
"""Notification services — send alerts through configured channels."""
import json
import logging
from app.models.notification import NotificationChannel
logger = logging.getLogger("taskpulse.notification")
class NotificationService:
"""Dispatch alert messages through the appropriate channel provider."""
async def send(self, channel: NotificationChannel, message: str):
provider = self._get_provider(channel.channel_type)
config = json.loads(channel.config) if isinstance(channel.config, str) else channel.config
await provider(config, message)
def _get_provider(self, channel_type: str):
providers = {
"feishu_cli": self._send_feishu,
"email": self._send_email,
"webhook": self._send_webhook,
}
provider = providers.get(channel_type)
if not provider:
raise ValueError(f"Unsupported channel type: {channel_type}")
return provider
async def _send_feishu(self, config: dict, message: str):
"""Send via Feishu webhook (supports both CLI-style and webhook)."""
import httpx
webhook_url = config.get("webhook_url", "")
if not webhook_url:
logger.warning("Feishu webhook URL not configured")
return
payload = {
"msg_type": "text",
"content": {"text": message},
}
async with httpx.AsyncClient(timeout=10) as client:
resp = await client.post(webhook_url, json=payload)
resp.raise_for_status()
logger.info("Feishu notification sent: %s", resp.status_code)
async def _send_email(self, config: dict, message: str):
"""Send via SMTP."""
import smtplib
from email.mime.text import MIMEText
smtp_host = config.get("smtp_host", "")
smtp_port = config.get("smtp_port", 587)
smtp_user = config.get("smtp_user", "")
smtp_pass = config.get("smtp_password", "")
to_addr = config.get("to_address", "")
from_addr = config.get("from_address", smtp_user)
if not all([smtp_host, smtp_user, smtp_pass, to_addr]):
logger.warning("Email config incomplete, skipping")
return
msg = MIMEText(message, "plain", "utf-8")
msg["Subject"] = "[TaskPulse] 定时任务告警"
msg["From"] = from_addr
msg["To"] = to_addr
with smtplib.SMTP(smtp_host, smtp_port) as server:
server.starttls()
server.login(smtp_user, smtp_pass)
server.send_message(msg)
logger.info("Email notification sent to %s", to_addr)
async def _send_webhook(self, config: dict, message: str):
"""Send via generic webhook."""
import httpx
url = config.get("url", "")
if not url:
logger.warning("Webhook URL not configured")
return
payload = {"text": message, "source": "taskpulse"}
async with httpx.AsyncClient(timeout=10) as client:
resp = await client.post(url, json=payload)
resp.raise_for_status()
logger.info("Webhook notification sent: %s", resp.status_code)

View File

@ -0,0 +1,103 @@
"""Scheduler — periodic check for overdue tasks."""
import asyncio
import logging
from datetime import datetime
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
from app.config import settings
from app.database import async_session_factory
from app.models import ScheduledTask, Alert, AlertRule, NotificationChannel
logger = logging.getLogger("taskpulse.scheduler")
class SchedulerService:
"""Background checker that scans for overdue tasks and triggers alerts."""
def __init__(self):
self._running = False
async def start(self):
self._running = True
logger.info("Scheduler started (interval=%ss)", settings.SCHEDULER_CHECK_INTERVAL)
while self._running:
try:
await self._check_overdue()
except Exception as exc:
logger.exception("Scheduler check error: %s", exc)
await asyncio.sleep(settings.SCHEDULER_CHECK_INTERVAL)
async def stop(self):
self._running = False
logger.info("Scheduler stopped")
async def _check_overdue(self):
"""Find active tasks past their next_run_at + grace_period."""
async with async_session_factory() as db:
now = datetime.utcnow()
stmt = select(ScheduledTask).where(
ScheduledTask.status == "active",
ScheduledTask.next_run_at.isnot(None),
ScheduledTask.next_run_at < now,
)
result = await db.execute(stmt)
tasks = list(result.scalars().all())
for task in tasks:
grace_end = task.next_run_at.timestamp() + task.grace_period
if now.timestamp() <= grace_end:
continue # still within grace window
# Check if we already alerted recently for this task
recent = select(Alert).where(
Alert.task_id == task.id,
Alert.alert_type == "missed_run",
Alert.status == "pending",
)
existing = (await db.execute(recent)).scalar_one_or_none()
if existing:
continue # already has pending alert
alert = Alert(
task_id=task.id,
alert_type="missed_run",
message=(
f"任务 [{task.name}](ID={task.id}) 超过预定时间未执行。\n"
f"预期执行时间: {task.next_run_at}\n"
f"容忍窗口: {task.grace_period}s\n"
f"当前时间: {now}"
),
status="pending",
)
db.add(alert)
await db.flush()
# Send notifications
await self._notify_for_task(db, task, alert)
await db.commit()
async def _notify_for_task(self, db: AsyncSession, task: ScheduledTask, alert: Alert):
"""Send notifications through all active channels for this task."""
from app.services.notification import NotificationService
rules_q = select(AlertRule).where(
AlertRule.task_id == task.id,
AlertRule.enabled == True,
AlertRule.alert_type == "missed_run",
)
rules = (await db.execute(rules_q)).scalars().all()
notifier = NotificationService()
for rule in rules:
channel = await db.get(NotificationChannel, rule.channel_id)
if channel and channel.enabled:
try:
await notifier.send(channel, alert.message)
logger.info("Alert sent for task %s via %s", task.name, channel.name)
except Exception as exc:
logger.error("Failed to send alert for task %s via %s: %s",
task.name, channel.name, exc)

View File

@ -0,0 +1,102 @@
"""ScheduledTask business logic."""
from datetime import datetime
from croniter import croniter
from sqlalchemy import select, func
from sqlalchemy.ext.asyncio import AsyncSession
from app.models import ScheduledTask, Agent
class TaskService:
def __init__(self, db: AsyncSession):
self.db = db
async def create(self, agent_id: int, name: str, cron_expression: str,
description: str = "", grace_period: int = 300) -> ScheduledTask:
now = datetime.utcnow()
next_run = self._calc_next_run(cron_expression, now)
task = ScheduledTask(
agent_id=agent_id,
name=name,
description=description,
cron_expression=cron_expression,
grace_period=grace_period,
next_run_at=next_run,
)
self.db.add(task)
await self.db.flush()
await self.db.refresh(task)
return task
async def get(self, task_id: int) -> ScheduledTask | None:
return await self.db.get(ScheduledTask, task_id)
async def list_by_agent(self, agent_id: int) -> list[ScheduledTask]:
stmt = select(ScheduledTask).where(ScheduledTask.agent_id == agent_id).order_by(ScheduledTask.id)
result = await self.db.execute(stmt)
return list(result.scalars().all())
async def list_all(self, status: str | None = None) -> list[ScheduledTask]:
stmt = select(ScheduledTask)
if status:
stmt = stmt.where(ScheduledTask.status == status)
stmt = stmt.order_by(ScheduledTask.id)
result = await self.db.execute(stmt)
return list(result.scalars().all())
async def update(self, task_id: int, **kwargs) -> ScheduledTask | None:
task = await self.get(task_id)
if task is None:
return None
for k, v in kwargs.items():
if v is not None and hasattr(task, k):
setattr(task, k, v)
# Recalculate next_run if cron changed
if kwargs.get("cron_expression"):
task.next_run_at = self._calc_next_run(task.cron_expression, datetime.utcnow())
await self.db.flush()
await self.db.refresh(task)
return task
async def delete(self, task_id: int) -> bool:
task = await self.get(task_id)
if task is None:
return False
await self.db.delete(task)
await self.db.flush()
return True
async def mark_run(self, task_id: int, success: bool) -> ScheduledTask | None:
"""Mark that a task just ran: update last_run, total_count, next_run."""
task = await self.get(task_id)
if task is None:
return None
now = datetime.utcnow()
task.last_run_at = now
task.last_run_result = "success" if success else "failed"
task.total_run_count = (task.total_run_count or 0) + 1
task.next_run_at = self._calc_next_run(task.cron_expression, now)
await self.db.flush()
await self.db.refresh(task)
return task
async def get_overdue_tasks(self, grace_seconds: int) -> list[ScheduledTask]:
"""Find active tasks whose next_run_at + grace_period is in the past."""
now = datetime.utcnow()
stmt = select(ScheduledTask).where(
ScheduledTask.status == "active",
ScheduledTask.next_run_at.isnot(None),
ScheduledTask.next_run_at + func.make_interval(0, 0, 0, 0, 0, 0, grace_seconds) < now,
)
result = await self.db.execute(stmt)
return list(result.scalars().all())
@staticmethod
def _calc_next_run(cron_expression: str, base: datetime | None = None) -> datetime | None:
try:
cron = croniter(cron_expression, base or datetime.utcnow())
return cron.get_next(datetime)
except (ValueError, KeyError):
return None

14
backend/requirements.txt Normal file
View File

@ -0,0 +1,14 @@
fastapi==0.115.0
uvicorn[standard]==0.30.6
sqlalchemy==2.0.35
aiomysql==0.2.0
pymysql==1.1.1
alembic==1.13.2
pydantic==2.9.2
pydantic-settings==2.5.2
python-multipart==0.0.9
croniter==2.0.7
apscheduler==3.10.4
httpx==0.27.2
pycryptodome==3.20.0
gunicorn==23.0.0

18
docker-compose.yml Normal file
View File

@ -0,0 +1,18 @@
version: "3.8"
services:
backend:
build:
context: ./backend
dockerfile: Dockerfile
container_name: taskpulse-backend
restart: unless-stopped
ports:
- "8000:8000"
environment:
TASKPULSE_DB_HOST: als-01.tatta.cn
TASKPULSE_DB_PORT: 8036
TASKPULSE_DB_USER: dbuser
TASKPULSE_DB_PASSWORD: Tata@1234
TASKPULSE_DB_NAME: taskpulse
TASKPULSE_DEBUG: "false"

12
frontend/index.html Normal file
View File

@ -0,0 +1,12 @@
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>TaskPulse — AI Agent Task Monitor</title>
</head>
<body>
<div id="app"></div>
<script type="module" src="/src/main.js"></script>
</body>
</html>

1705
frontend/package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff

25
frontend/package.json Normal file
View File

@ -0,0 +1,25 @@
{
"name": "taskpulse-frontend",
"private": true,
"version": "1.0.0",
"type": "module",
"scripts": {
"dev": "vite",
"build": "vite build",
"preview": "vite preview"
},
"dependencies": {
"vue": "^3.4.0",
"vue-router": "^4.3.0",
"element-plus": "^2.7.0",
"@element-plus/icons-vue": "^2.3.1",
"axios": "^1.7.0",
"dayjs": "^1.11.0",
"echarts": "^5.5.0",
"vue-echarts": "^6.7.0"
},
"devDependencies": {
"@vitejs/plugin-vue": "^5.0.0",
"vite": "^5.4.0"
}
}

204
frontend/src/App.vue Normal file
View File

@ -0,0 +1,204 @@
<template>
<div id="app-container">
<!-- Sidebar -->
<aside class="sidebar">
<div class="logo">
<div class="logo-icon">
<svg width="28" height="28" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<circle cx="12" cy="12" r="10"/>
<polyline points="12 6 12 12 16 14"/>
</svg>
</div>
<span class="logo-text">TaskPulse</span>
</div>
<nav class="nav-menu">
<router-link to="/" class="nav-item" :class="{ active: currentRoute === '/' }">
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><rect x="3" y="3" width="7" height="7"/><rect x="14" y="3" width="7" height="7"/><rect x="14" y="14" width="7" height="7"/><rect x="3" y="14" width="7" height="7"/></svg>
<span>仪表盘</span>
</router-link>
<router-link to="/tasks" class="nav-item" :class="{ active: currentRoute.startsWith('/tasks') }">
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><line x1="8" y1="6" x2="21" y2="6"/><line x1="8" y1="12" x2="21" y2="12"/><line x1="8" y1="18" x2="21" y2="18"/><line x1="3" y1="6" x2="3.01" y2="6"/><line x1="3" y1="12" x2="3.01" y2="12"/><line x1="3" y1="18" x2="3.01" y2="18"/></svg>
<span>所有任务</span>
</router-link>
<router-link to="/agents" class="nav-item" :class="{ active: currentRoute.startsWith('/agents') }">
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M20 21v-2a4 4 0 0 0-4-4H8a4 4 0 0 0-4 4v2"/><circle cx="12" cy="7" r="4"/></svg>
<span>Agent 管理</span>
</router-link>
<router-link to="/alerts" class="nav-item" :class="{ active: currentRoute.startsWith('/alerts') }">
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M18 8A6 6 0 0 0 6 8c0 7-3 9-3 9h18s-3-2-3-9"/><path d="M13.73 21a2 2 0 0 1-3.46 0"/></svg>
<span>告警历史</span>
</router-link>
<router-link to="/settings" class="nav-item" :class="{ active: currentRoute.startsWith('/settings') }">
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><circle cx="12" cy="12" r="3"/><path d="M19.4 15a1.65 1.65 0 0 0 .33 1.82l.06.06a2 2 0 0 1 0 2.83 2 2 0 0 1-2.83 0l-.06-.06a1.65 1.65 0 0 0-1.82-.33 1.65 1.65 0 0 0-1 1.51V21a2 2 0 0 1-2 2 2 2 0 0 1-2-2v-.09A1.65 1.65 0 0 0 9 19.4a1.65 1.65 0 0 0-1.82.33l-.06.06a2 2 0 0 1-2.83 0 2 2 0 0 1 0-2.83l.06-.06A1.65 1.65 0 0 0 4.68 15a1.65 1.65 0 0 0-1.51-1H3a2 2 0 0 1-2-2 2 2 0 0 1 2-2h.09A1.65 1.65 0 0 0 4.6 9a1.65 1.65 0 0 0-.33-1.82l-.06-.06a2 2 0 0 1 0-2.83 2 2 0 0 1 2.83 0l.06.06A1.65 1.65 0 0 0 9 4.68a1.65 1.65 0 0 0 1-1.51V3a2 2 0 0 1 2-2 2 2 0 0 1 2 2v.09a1.65 1.65 0 0 0 1 1.51 1.65 1.65 0 0 0 1.82-.33l.06-.06a2 2 0 0 1 2.83 0 2 2 0 0 1 0 2.83l-.06.06a1.65 1.65 0 0 0-.33 1.82V9a1.65 1.65 0 0 0 1.51 1H21a2 2 0 0 1 2 2 2 2 0 0 1-2 2h-.09a1.65 1.65 0 0 0-1.51 1z"/></svg>
<span>系统配置</span>
</router-link>
</nav>
<div class="sidebar-footer">
<div class="status-dot"></div>
<span>系统运行中</span>
</div>
</aside>
<!-- Main Content -->
<div class="main-area">
<header class="topbar">
<h2 class="page-title">{{ pageTitle }}</h2>
<div class="topbar-right">
<span class="version-badge">v1.0</span>
</div>
</header>
<main class="content">
<router-view />
</main>
</div>
</div>
</template>
<script setup>
import { computed } from 'vue'
import { useRoute } from 'vue-router'
const route = useRoute()
const currentRoute = computed(() => route.path)
const routeTitles = {
'/': '仪表盘',
'/tasks': '所有任务',
'/agents': 'Agent 管理',
'/alerts': '告警历史',
'/settings': '系统配置',
}
const pageTitle = computed(() => routeTitles[route.path] || 'TaskPulse')
</script>
<style scoped>
#app-container {
display: flex;
height: 100vh;
background: #0a0a0f;
color: #e0e0e0;
}
/* ── Sidebar ───────────────────────────────────── */
.sidebar {
width: 220px;
min-width: 220px;
background: linear-gradient(180deg, #0f0f1a 0%, #1a1a2e 100%);
border-right: 1px solid rgba(255,255,255,0.05);
display: flex;
flex-direction: column;
padding: 0;
}
.logo {
display: flex;
align-items: center;
gap: 10px;
padding: 24px 20px 20px;
border-bottom: 1px solid rgba(255,255,255,0.06);
}
.logo-icon {
width: 36px;
height: 36px;
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
border-radius: 10px;
display: flex;
align-items: center;
justify-content: center;
color: #fff;
}
.logo-text {
font-size: 18px;
font-weight: 700;
background: linear-gradient(135deg, #a8b4ff 0%, #c084fc 100%);
-webkit-background-clip: text;
-webkit-text-fill-color: transparent;
background-clip: text;
}
.nav-menu {
flex: 1;
padding: 12px 10px;
display: flex;
flex-direction: column;
gap: 2px;
}
.nav-item {
display: flex;
align-items: center;
gap: 10px;
padding: 10px 14px;
border-radius: 10px;
color: rgba(255,255,255,0.55);
text-decoration: none;
font-size: 14px;
transition: all 0.2s;
}
.nav-item:hover {
color: #fff;
background: rgba(255,255,255,0.06);
}
.nav-item.active {
color: #fff;
background: linear-gradient(135deg, rgba(102,126,234,0.2) 0%, rgba(118,75,162,0.2) 100%);
border: 1px solid rgba(102,126,234,0.15);
}
.sidebar-footer {
padding: 16px 20px;
display: flex;
align-items: center;
gap: 8px;
font-size: 12px;
color: rgba(255,255,255,0.35);
border-top: 1px solid rgba(255,255,255,0.06);
}
.status-dot {
width: 6px;
height: 6px;
border-radius: 50%;
background: #22c55e;
box-shadow: 0 0 6px rgba(34,197,94,0.5);
}
/* ── Main ──────────────────────────────────────── */
.main-area {
flex: 1;
display: flex;
flex-direction: column;
min-width: 0;
background: #0a0a0f;
}
.topbar {
display: flex;
align-items: center;
justify-content: space-between;
padding: 16px 28px;
background: rgba(15,15,26,0.8);
backdrop-filter: blur(12px);
border-bottom: 1px solid rgba(255,255,255,0.05);
}
.page-title {
font-size: 18px;
font-weight: 600;
color: #e0e0e0;
margin: 0;
}
.version-badge {
font-size: 11px;
padding: 3px 10px;
border-radius: 20px;
background: rgba(255,255,255,0.06);
color: rgba(255,255,255,0.4);
border: 1px solid rgba(255,255,255,0.08);
}
.content {
flex: 1;
overflow-y: auto;
padding: 24px 28px;
}
</style>

49
frontend/src/api/index.js Normal file
View File

@ -0,0 +1,49 @@
import axios from 'axios'
const api = axios.create({
baseURL: '/api',
timeout: 10000,
})
// Dashboard
export const getDashboardSummary = () => api.get('/dashboard/summary')
export const getDashboardTasks = () => api.get('/dashboard/tasks')
// Agents
export const listAgents = () => api.get('/agents')
export const getAgent = (id) => api.get(`/agents/${id}`)
export const createAgent = (data) => api.post('/agents', data)
export const updateAgent = (id, data) => api.put(`/agents/${id}`, data)
// Tasks
export const listTasks = (agentId) => api.get('/tasks', { params: { agent_id: agentId } })
export const getTask = (id) => api.get(`/tasks/${id}`)
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 deleteTask = (id) => api.delete(`/tasks/${id}`)
// Executions
export const listExecutions = (taskId, params) => api.get(`/tasks/${taskId}/executions`, { params })
export const getExecution = (id) => api.get(`/executions/${id}`)
export const getRecentExecutions = (params) => api.get('/executions/recent', { params })
// Alerts
export const listAlerts = (params) => api.get('/alerts', { params })
export const acknowledgeAlert = (id) => api.post(`/alerts/${id}/acknowledge`)
// Notification Channels
export const listChannels = () => api.get('/notification-channels')
export const createChannel = (data) => api.post('/notification-channels', data)
export const updateChannel = (id, data) => api.put(`/notification-channels/${id}`, data)
export const deleteChannel = (id) => api.delete(`/notification-channels/${id}`)
// Alert Rules
export const listAlertRules = (params) => api.get('/alert-rules', { params })
export const createAlertRule = (data) => api.post('/alert-rules', data)
export const deleteAlertRule = (id) => api.delete(`/alert-rules/${id}`)
// System Config
export const getSystemConfig = () => api.get('/system/config')
export const saveSystemConfig = (data) => api.put('/system/config', data)
export default api

View File

@ -0,0 +1,232 @@
/* ── TaskPulse Global Theme ─────────────────────── */
:root {
--bg-primary: #0a0a0f;
--bg-card: #13131f;
--bg-card-hover: #1a1a2e;
--bg-elevated: #1e1e32;
--border-color: rgba(255,255,255,0.06);
--border-hover: rgba(255,255,255,0.1);
--text-primary: #e8e8f0;
--text-secondary: rgba(255,255,255,0.5);
--text-muted: rgba(255,255,255,0.3);
--accent-1: #667eea;
--accent-2: #764ba2;
--accent-gradient: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
--success: #22c55e;
--warning: #f59e0b;
--danger: #ef4444;
--info: #6366f1;
--radius: 12px;
--radius-sm: 8px;
}
* {
margin: 0;
padding: 0;
box-sizing: border-box;
}
html, body, #app {
height: 100%;
font-family: -apple-system, BlinkMacSystemFont, 'SF Pro Display', 'Segoe UI', 'Inter', Roboto, sans-serif;
background: var(--bg-primary);
color: var(--text-primary);
-webkit-font-smoothing: antialiased;
}
/* ── Scrollbar ─────────────────────────────────── */
::-webkit-scrollbar {
width: 6px;
}
::-webkit-scrollbar-track {
background: transparent;
}
::-webkit-scrollbar-thumb {
background: rgba(255,255,255,0.08);
border-radius: 3px;
}
::-webkit-scrollbar-thumb:hover {
background: rgba(255,255,255,0.15);
}
/* ── Element Plus Overrides ────────────────────── */
/* Cards */
.el-card {
background: var(--bg-card) !important;
border: 1px solid var(--border-color) !important;
border-radius: var(--radius) !important;
color: var(--text-primary) !important;
}
.el-card__header {
padding: 16px 20px !important;
border-bottom: 1px solid var(--border-color) !important;
}
.el-card__body {
padding: 20px !important;
}
/* Tables */
.el-table {
--el-table-bg-color: transparent !important;
--el-table-tr-bg-color: transparent !important;
--el-table-header-bg-color: rgba(255,255,255,0.02) !important;
--el-table-row-hover-bg-color: rgba(255,255,255,0.04) !important;
--el-table-border-color: var(--border-color) !important;
--el-table-text-color: var(--text-primary) !important;
--el-table-header-text-color: var(--text-secondary) !important;
background: transparent !important;
color: var(--text-primary) !important;
}
.el-table th.el-table__cell {
background: rgba(255,255,255,0.02) !important;
border-bottom: 1px solid var(--border-color) !important;
color: var(--text-secondary) !important;
font-weight: 500 !important;
font-size: 12px !important;
text-transform: uppercase;
letter-spacing: 0.5px;
}
.el-table td.el-table__cell {
border-bottom: 1px solid var(--border-color) !important;
color: var(--text-primary) !important;
}
/* Tags */
.el-tag {
border: none !important;
font-weight: 500 !important;
}
.el-tag--success {
background: rgba(34,197,94,0.15) !important;
color: #4ade80 !important;
}
.el-tag--danger {
background: rgba(239,68,68,0.15) !important;
color: #f87171 !important;
}
.el-tag--warning {
background: rgba(245,158,11,0.15) !important;
color: #fbbf24 !important;
}
.el-tag--info {
background: rgba(255,255,255,0.06) !important;
color: var(--text-secondary) !important;
}
/* Buttons */
.el-button--primary {
--el-button-bg-color: var(--accent-1) !important;
--el-button-border-color: var(--accent-1) !important;
--el-button-hover-bg-color: #7c8ff0 !important;
--el-button-hover-border-color: #7c8ff0 !important;
border-radius: var(--radius-sm) !important;
}
.el-button--small {
border-radius: 6px !important;
}
.el-button.is-text {
color: var(--accent-1) !important;
}
.el-button.is-text.is-danger {
color: var(--danger) !important;
}
/* Dialog */
.el-dialog {
--el-dialog-bg-color: var(--bg-card) !important;
--el-dialog-border-color: var(--border-color) !important;
border-radius: var(--radius) !important;
border: 1px solid var(--border-color) !important;
}
.el-dialog__title {
color: var(--text-primary) !important;
}
.el-dialog__body {
color: var(--text-secondary) !important;
}
/* Form */
.el-form-item__label {
color: var(--text-secondary) !important;
}
.el-input {
--el-input-bg-color: rgba(255,255,255,0.04) !important;
--el-input-border-color: var(--border-color) !important;
--el-input-text-color: var(--text-primary) !important;
--el-input-hover-border-color: var(--accent-1) !important;
--el-input-focus-border-color: var(--accent-1) !important;
}
.el-input__inner {
background: rgba(255,255,255,0.04) !important;
color: var(--text-primary) !important;
border-color: var(--border-color) !important;
}
.el-select {
--el-select-input-focus-border-color: var(--accent-1) !important;
}
.el-select-dropdown {
background: var(--bg-elevated) !important;
border: 1px solid var(--border-color) !important;
}
.el-select-dropdown__item {
color: var(--text-primary) !important;
}
.el-select-dropdown__item.hover {
background: rgba(255,255,255,0.06) !important;
}
/* Switch */
.el-switch {
--el-switch-on-color: var(--accent-1) !important;
}
/* Radio */
.el-radio-group {
--el-radio-button-checked-bg-color: var(--accent-1) !important;
--el-radio-button-checked-border-color: var(--accent-1) !important;
}
.el-radio-button__inner {
background: rgba(255,255,255,0.04) !important;
color: var(--text-secondary) !important;
border-color: var(--border-color) !important;
}
.el-radio-button__original-radio:checked + .el-radio-button__inner {
background: var(--accent-1) !important;
color: #fff !important;
}
/* Descriptions */
.el-descriptions {
--el-descriptions-table-bg: transparent !important;
--el-descriptions-item-bg: transparent !important;
}
.el-descriptions__title {
color: var(--text-primary) !important;
}
.el-descriptions__label {
color: var(--text-secondary) !important;
background: rgba(255,255,255,0.02) !important;
}
.el-descriptions__content {
color: var(--text-primary) !important;
background: transparent !important;
}
.el-descriptions__cell {
border-color: var(--border-color) !important;
}
/* Input number */
.el-input-number {
--el-input-number-controls-height: 100% !important;
}
/* Message */
.el-message {
--el-message-bg-color: var(--bg-elevated) !important;
--el-message-border-color: var(--border-color) !important;
--el-message-text-color: var(--text-primary) !important;
border: 1px solid var(--border-color) !important;
backdrop-filter: blur(12px) !important;
}

19
frontend/src/main.js Normal file
View File

@ -0,0 +1,19 @@
import { createApp } from 'vue'
import ElementPlus from 'element-plus'
import 'element-plus/dist/index.css'
import * as ElementPlusIconsVue from '@element-plus/icons-vue'
import zhCn from 'element-plus/es/locale/lang/zh-cn'
import App from './App.vue'
import router from './router'
import './assets/style.css'
const app = createApp(App)
// Register all Element Plus icons
for (const [key, component] of Object.entries(ElementPlusIconsVue)) {
app.component(key, component)
}
app.use(ElementPlus, { locale: zhCn })
app.use(router)
app.mount('#app')

View File

@ -0,0 +1,23 @@
import { createRouter, createWebHistory } from 'vue-router'
import Dashboard from '../views/Dashboard.vue'
import Tasks from '../views/Tasks.vue'
import TaskDetail from '../views/TaskDetail.vue'
import Agents from '../views/Agents.vue'
import Alerts from '../views/Alerts.vue'
import Settings from '../views/Settings.vue'
const routes = [
{ path: '/', component: Dashboard },
{ path: '/tasks', component: Tasks },
{ path: '/tasks/:id', component: TaskDetail, name: 'TaskDetail' },
{ path: '/agents', component: Agents },
{ path: '/alerts', component: Alerts },
{ path: '/settings', component: Settings },
]
const router = createRouter({
history: createWebHistory(),
routes,
})
export default router

View File

@ -0,0 +1,237 @@
<template>
<div class="agents-page">
<!-- Quick onboard -->
<div class="section mb-24">
<div class="section-header">
<h3>Agent 接入指南</h3>
</div>
<div class="onboard-body">
<p> AI Agent 通过以下方式自动注册无需手动填写</p>
<div class="code-block">
<div class="code-tabs">
<button :class="{ active: tab === 'curl' }" @click="tab = 'curl'">curl</button>
<button :class="{ active: tab === 'python' }" @click="tab = 'python'">Python</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>
</div>
<!-- Agent list -->
<div class="section">
<div class="section-header">
<h3>已注册 Agent</h3>
</div>
<div class="table-wrap">
<table class="data-table">
<thead>
<tr>
<th>ID</th>
<th>名称</th>
<th>描述</th>
<th>状态</th>
<th>任务数</th>
<th>最后心跳</th>
<th>API Key</th>
<th></th>
</tr>
</thead>
<tbody>
<tr v-for="a in agents" :key="a.id">
<td class="cell-mono">#{{ a.id }}</td>
<td class="cell-name">{{ a.name }}</td>
<td class="cell-muted">{{ a.description || '—' }}</td>
<td>
<span class="tag" :class="a.status === 'active' ? 'tag-green' : 'tag-gray'">
{{ a.status === 'active' ? '活跃' : '停用' }}
</span>
</td>
<td class="cell-mono">{{ a.task_count }}</td>
<td class="cell-mono">{{ a.last_heartbeat_at ? dayjs(a.last_heartbeat_at).format('MM-DD HH:mm') : '—' }}</td>
<td>
<code class="cell-key">{{ a.api_key.substring(0, 16) }}...</code>
<button class="btn-icon" @click="copyKey(a.api_key)" title="复制 API Key">
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><rect x="9" y="9" width="13" height="13" rx="2"/><path d="M5 15H4a2 2 0 0 1-2-2V4a2 2 0 0 1 2-2h9a2 2 0 0 1 2 2v1"/></svg>
</button>
</td>
<td>
<router-link to="/tasks" class="cell-link">任务</router-link>
</td>
</tr>
<tr v-if="agents.length === 0">
<td colspan="8" class="cell-empty">暂无 Agent AI Agent 调用上方 API 自动注册</td>
</tr>
</tbody>
</table>
</div>
</div>
</div>
</template>
<script setup>
import { ref, onMounted, computed } from 'vue'
import dayjs from 'dayjs'
import { listAgents, getSystemConfig } from '../api/index.js'
const agents = ref([])
const tab = ref('curl')
const baseUrl = ref(window.location.origin)
const loadData = async () => {
const [agentRes, cfgRes] = await Promise.all([
listAgents(),
getSystemConfig(),
])
agents.value = agentRes.data
baseUrl.value = cfgRes.data.base_url
}
const copyKey = async (key) => {
try {
await navigator.clipboard.writeText(key)
const { ElMessage } = await import('element-plus')
ElMessage.success('已复制 API Key')
} catch {}
}
const codes = computed(() => {
const b = baseUrl.value
return {
curl: `# 注册单个 Agent
curl -X POST ${b}/api/agents \\
-H "Content-Type: application/json" \\
-d '{"name": "my-agent", "description": "你的 AI Agent"}'`,
python: `import requests\n\nresp = requests.post("${b}/api/agents", json={\n "name": "my-agent",\n "description": "你的 AI Agent"\n})\nprint(resp.json())`,
batch: `curl -X POST ${b}/api/agents/register-with-tasks \\\n -H "Content-Type: application/json" \\\n -d '{\n "name": "my-agent",\n "description": "全能数据Agent",\n "tasks": [\n {"name": "sync-data", "cron_expression": "*/5 * * * *"},\n {"name": "daily-report", "cron_expression": "0 9 * * *"}\n ]\n }'`,
}
})
const copyCode = async () => {
try {
await navigator.clipboard.writeText(codes.value[tab.value])
const { ElMessage } = await import('element-plus')
ElMessage.success('已复制')
} catch {}
}
onMounted(loadData)
</script>
<style scoped>
.agents-page { max-width: 1200px; }
.mb-24 { margin-bottom: 24px; }
.section {
background: var(--bg-card);
border: 1px solid var(--border-color);
border-radius: 14px;
overflow: hidden;
}
.section-header {
padding: 16px 20px;
border-bottom: 1px solid var(--border-color);
}
.section-header h3 { font-size: 15px; font-weight: 600; color: #e0e0f0; margin: 0; }
.onboard-body {
padding: 20px;
}
.onboard-body > p {
font-size: 13px;
color: var(--text-secondary);
margin-bottom: 16px;
}
.code-block {
position: relative;
background: rgba(0,0,0,0.3);
border-radius: 10px;
overflow: hidden;
}
.code-tabs {
display: flex;
border-bottom: 1px solid rgba(255,255,255,0.06);
}
.code-tabs button {
padding: 8px 16px;
background: none;
border: none;
color: rgba(255,255,255,0.4);
font-size: 12px;
cursor: pointer;
border-bottom: 2px solid transparent;
}
.code-tabs button.active {
color: #a8b4ff;
border-bottom-color: #667eea;
}
.code-body { padding: 16px; overflow-x: auto; }
.code-body pre {
font-family: 'JetBrains Mono', 'Fira Code', monospace;
font-size: 12px;
line-height: 1.7;
color: rgba(255,255,255,0.75);
white-space: pre;
margin: 0;
}
.btn-copy {
position: absolute;
top: 44px;
right: 12px;
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; }
.data-table { width: 100%; border-collapse: collapse; }
.data-table th {
text-align: left;
padding: 12px 16px;
font-size: 11px;
font-weight: 500;
text-transform: uppercase;
letter-spacing: 0.5px;
color: var(--text-secondary);
background: rgba(255,255,255,0.02);
border-bottom: 1px solid var(--border-color);
}
.data-table td {
padding: 12px 16px;
font-size: 13px;
color: var(--text-primary);
border-bottom: 1px solid var(--border-color);
}
.data-table tbody tr:hover { background: rgba(255,255,255,0.02); }
.cell-name { font-weight: 500; }
.cell-mono { font-family: 'JetBrains Mono', monospace; font-size: 12px; color: var(--text-secondary); }
.cell-muted { color: var(--text-muted); }
.cell-key { font-family: 'JetBrains Mono', monospace; font-size: 11px; color: var(--text-muted); }
.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; }
.cell-link:hover { text-decoration: underline; }
.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-gray { background: rgba(255,255,255,0.05); color: var(--text-secondary); }
.btn-icon {
background: none;
border: none;
color: var(--text-muted);
cursor: pointer;
padding: 4px;
vertical-align: middle;
}
.btn-icon:hover { color: var(--accent-1); }
</style>

View File

@ -0,0 +1,156 @@
<template>
<div class="alerts-page">
<div class="section">
<div class="section-header">
<h3>告警历史</h3>
<div class="filter-group">
<button :class="{ active: filterStatus === '' }" @click="filterStatus = ''; loadData()">全部</button>
<button :class="{ active: filterStatus === 'pending' }" @click="filterStatus = 'pending'; loadData()">待处理</button>
<button :class="{ active: filterStatus === 'acknowledged' }" @click="filterStatus = 'acknowledged'; loadData()">已确认</button>
</div>
</div>
<div class="alert-list">
<div v-for="a in alerts" :key="a.id" class="alert-item" :class="{ 'alert-pending': a.status === 'pending' }">
<div class="alert-left">
<div class="alert-dot" :class="a.status === 'pending' ? 'dot-red' : 'dot-gray'"></div>
</div>
<div class="alert-body">
<div class="alert-top">
<span class="tag" :class="a.alert_type === 'missed_run' ? 'tag-warn' : 'tag-red'">
{{ a.alert_type === 'missed_run' ? '未按时执行' : '执行失败' }}
</span>
<span class="alert-task">{{ a.task_name || '未知任务' }}</span>
<span class="alert-time">{{ dayjs(a.created_at).format('MM-DD HH:mm') }}</span>
</div>
<div class="alert-msg">{{ a.message }}</div>
<div class="alert-bottom">
<span class="alert-status" :class="a.status === 'pending' ? 'text-red' : 'text-muted'">
{{ a.status === 'pending' ? '待处理' : '已确认' }}
</span>
<button v-if="a.status === 'pending'" class="btn-ack" @click="handleAcknowledge(a)">确认</button>
</div>
</div>
</div>
<div v-if="alerts.length === 0" class="empty-state">
<svg width="40" height="40" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" style="color:var(--text-muted);margin-bottom:12px"><path d="M18 8A6 6 0 0 0 6 8c0 7-3 9-3 9h18s-3-2-3-9"/><path d="M13.73 21a2 2 0 0 1-3.46 0"/></svg>
<p>暂无告警</p>
</div>
</div>
</div>
</div>
</template>
<script setup>
import { ref, onMounted } from 'vue'
import dayjs from 'dayjs'
import { listAlerts, acknowledgeAlert } from '../api/index.js'
const alerts = ref([])
const filterStatus = ref('')
const loadData = async () => {
const params = { limit: 200 }
if (filterStatus.value) params.status = filterStatus.value
const res = await listAlerts(params)
alerts.value = res.data
}
const handleAcknowledge = async (row) => {
try {
await acknowledgeAlert(row.id)
await loadData()
} catch (e) {
console.error(e)
}
}
onMounted(loadData)
</script>
<style scoped>
.alerts-page { max-width: 1200px; }
.section {
background: var(--bg-card);
border: 1px solid var(--border-color);
border-radius: 14px;
overflow: hidden;
}
.section-header {
display: flex;
justify-content: space-between;
align-items: center;
padding: 16px 20px;
border-bottom: 1px solid var(--border-color);
}
.section-header h3 { font-size: 15px; font-weight: 600; color: #e0e0f0; margin: 0; }
.filter-group { display: flex; gap: 4px; background: rgba(255,255,255,0.04); border-radius: 8px; padding: 3px; }
.filter-group button {
padding: 5px 14px; border-radius: 6px;
background: none; border: none;
color: var(--text-muted); font-size: 12px; cursor: pointer;
transition: all 0.15s;
}
.filter-group button.active {
background: var(--accent-1);
color: #fff;
}
.alert-list { padding: 0; }
.alert-item {
display: flex;
gap: 14px;
padding: 16px 20px;
border-bottom: 1px solid var(--border-color);
transition: background 0.15s;
}
.alert-item:last-child { border-bottom: none; }
.alert-item:hover { background: rgba(255,255,255,0.02); }
.alert-pending { background: rgba(239,68,68,0.03); }
.alert-left { padding-top: 2px; }
.alert-dot { width: 8px; height: 8px; border-radius: 50%; margin-top: 4px; }
.dot-red { background: #ef4444; box-shadow: 0 0 8px rgba(239,68,68,0.4); }
.dot-gray { background: rgba(255,255,255,0.15); }
.alert-body { flex: 1; min-width: 0; }
.alert-top { display: flex; align-items: center; gap: 8px; margin-bottom: 6px; }
.alert-task { font-size: 13px; font-weight: 500; color: var(--text-primary); }
.alert-time { font-size: 11px; color: var(--text-muted); margin-left: auto; }
.alert-msg {
font-size: 12px;
color: var(--text-secondary);
line-height: 1.5;
margin-bottom: 8px;
display: -webkit-box;
-webkit-line-clamp: 2;
-webkit-box-orient: vertical;
overflow: hidden;
}
.alert-bottom { display: flex; align-items: center; gap: 12px; }
.alert-status { font-size: 11px; }
.text-red { color: #f87171; }
.text-muted { color: var(--text-muted); }
.btn-ack {
padding: 3px 12px;
border-radius: 6px;
border: 1px solid rgba(102,126,234,0.3);
background: rgba(102,126,234,0.1);
color: #a8b4ff;
font-size: 11px;
cursor: pointer;
}
.btn-ack:hover { background: rgba(102,126,234,0.2); }
.empty-state {
text-align: center;
padding: 60px 20px;
color: var(--text-muted);
font-size: 14px;
}
.tag { display: inline-block; padding: 2px 10px; border-radius: 20px; font-size: 11px; font-weight: 500; }
.tag-warn { background: rgba(245,158,11,0.12); color: #fbbf24; }
.tag-red { background: rgba(239,68,68,0.12); color: #f87171; }
</style>

View File

@ -0,0 +1,541 @@
<template>
<div class="dashboard">
<!-- AI Quick-Start Panel -->
<div class="onboarding-panel" v-if="showOnboarding">
<div class="onboarding-glow"></div>
<div class="onboarding-content">
<div class="onboarding-icon">
<svg width="32" height="32" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5">
<path d="M12 2a4 4 0 0 1 4 4v2a4 4 0 0 1-8 0V6a4 4 0 0 1 4-4z"/>
<path d="M5 15h14l-1.5 6H6.5L5 15z"/>
<circle cx="12" cy="18" r="1" fill="currentColor"/>
</svg>
</div>
<div class="onboarding-text">
<h3> AI Agent 自动接入</h3>
<p>只需将下方指令发送给您的 AI Agent它将自动注册并开始汇报任务执行情况</p>
</div>
<button class="btn-primary" @click="showScript = !showScript">
{{ showScript ? '收起指令' : '生成接入指令' }}
</button>
</div>
<div v-if="showScript" class="onboarding-script">
<div class="script-tabs">
<button :class="{ active: scriptTab === 'curl' }" @click="scriptTab = 'curl'">curl</button>
<button :class="{ active: scriptTab === 'python' }" @click="scriptTab = 'python'">Python</button>
</div>
<div class="script-body">
<pre v-if="scriptTab === 'curl'">{{ curlScript }}</pre>
<pre v-else>{{ pythonScript }}</pre>
</div>
<button class="btn-copy" @click="copyScript">复制</button>
</div>
<button class="onboarding-close" @click="showOnboarding = false">
<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>
<!-- Summary Cards -->
<div class="stats-grid">
<div class="stat-card" style="--card-accent: #667eea">
<div class="stat-icon">
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5"><path d="M20 21v-2a4 4 0 0 0-4-4H8a4 4 0 0 0-4 4v2"/><circle cx="12" cy="7" r="4"/></svg>
</div>
<div class="stat-body">
<div class="stat-label">Agent</div>
<div class="stat-value">{{ summary.total_agents }}</div>
<div class="stat-sub">
<span class="stat-dot active"></span>
活跃 {{ summary.active_agents }}
</div>
</div>
</div>
<div class="stat-card" style="--card-accent: #22c55e">
<div class="stat-icon">
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5"><rect x="3" y="3" width="18" height="18" rx="2" ry="2"/><line x1="3" y1="9" x2="21" y2="9"/><line x1="9" y1="21" x2="9" y2="9"/></svg>
</div>
<div class="stat-body">
<div class="stat-label">定时任务</div>
<div class="stat-value">{{ summary.total_tasks }}</div>
<div class="stat-sub">
<span class="stat-dot active"></span>
运行中 {{ summary.active_tasks }}
</div>
</div>
</div>
<div class="stat-card" style="--card-accent: #f59e0b">
<div class="stat-icon">
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5"><circle cx="12" cy="12" r="10"/><polyline points="12 6 12 12 16 14"/></svg>
</div>
<div class="stat-body">
<div class="stat-label">最近24h执行</div>
<div class="stat-value">{{ summary.total_executions }}</div>
<div class="stat-sub">
<span style="color:#4ade80">成功 {{ summary.recent_executions_ok }}</span>
<span style="color:#f87171;margin-left:8px">失败 {{ summary.recent_executions_failed }}</span>
</div>
</div>
</div>
<div class="stat-card" :style="{ '--card-accent': summary.pending_alerts > 0 ? '#ef4444' : '#6366f1' }">
<div class="stat-icon">
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5"><path d="M18 8A6 6 0 0 0 6 8c0 7-3 9-3 9h18s-3-2-3-9"/><path d="M13.73 21a2 2 0 0 1-3.46 0"/></svg>
</div>
<div class="stat-body">
<div class="stat-label">待处理告警</div>
<div class="stat-value" :style="{ color: summary.pending_alerts > 0 ? '#f87171' : 'var(--text-secondary)' }">
{{ summary.pending_alerts }}
</div>
<div class="stat-sub">{{ summary.pending_alerts > 0 ? '需要关注' : '一切正常' }}</div>
</div>
</div>
</div>
<!-- Task Overview -->
<div class="section">
<div class="section-header">
<h3>所有定时任务</h3>
<router-link to="/tasks" class="section-link">查看全部</router-link>
</div>
<div class="table-wrap">
<table class="data-table">
<thead>
<tr>
<th>Agent</th>
<th>任务名称</th>
<th>Cron</th>
<th>状态</th>
<th>最近运行</th>
<th>结果</th>
<th>执行次数</th>
<th></th>
</tr>
</thead>
<tbody>
<tr v-for="t in tasks" :key="t.id">
<td><span class="cell-badge">{{ t.agent_name }}</span></td>
<td class="cell-name">{{ t.name }}</td>
<td><code class="cell-code">{{ t.cron_expression }}</code></td>
<td>
<span class="tag" :class="t.status === 'active' ? 'tag-green' : 'tag-gray'">
{{ t.status === 'active' ? '运行中' : t.status === 'paused' ? '已暂停' : '已停止' }}
</span>
</td>
<td class="cell-mono">{{ t.last_run_at ? dayjs(t.last_run_at).format('MM-DD HH:mm') : '—' }}</td>
<td>
<span v-if="t.last_run_result" class="tag" :class="t.last_run_result === 'success' ? 'tag-green' : 'tag-red'">
{{ t.last_run_result === 'success' ? '成功' : '失败' }}
</span>
<span v-else class="cell-muted"></span>
</td>
<td class="cell-mono">{{ t.total_run_count }}</td>
<td>
<router-link :to="`/tasks/${t.id}`" class="cell-link">详情</router-link>
</td>
</tr>
<tr v-if="tasks.length === 0">
<td colspan="8" class="cell-empty">暂无数据 AI Agent 接入后自动显示</td>
</tr>
</tbody>
</table>
</div>
</div>
</div>
</template>
<script setup>
import { ref, onMounted, computed } from 'vue'
import dayjs from 'dayjs'
import { getDashboardSummary, getDashboardTasks, getSystemConfig } from '../api/index.js'
const summary = ref({
total_agents: 0, active_agents: 0,
total_tasks: 0, active_tasks: 0,
total_executions: 0,
recent_executions_ok: 0, recent_executions_failed: 0,
pending_alerts: 0,
})
const tasks = ref([])
const showOnboarding = ref(true)
const showScript = ref(false)
const scriptTab = ref('curl')
const baseUrl = ref(window.location.origin)
const curlScript = computed(() => {
const b = baseUrl.value
return `# 让 AI Agent 执行以下命令注册自己:
curl -X POST ${b}/api/agents \\
-H "Content-Type: application/json" \\
-d '{"name": "my-agent", "description": "我的 AI Agent"}'
# 保存返回的 api_key后续汇报用
# 注册定时任务替换 YOUR_API_KEY
curl -X POST "${b}/api/tasks?agent_id=<ID>" \\
-H "Content-Type: application/json" \\
-H "Authorization: Bearer <YOUR_API_KEY>" \\
-d '{
"name": "data-sync",
"cron_expression": "*/5 * * * *",
"grace_period": 300
}'
# 执行后汇报
curl -X POST "${b}/api/tasks/<TASK_ID>/executions" \\
-H "Content-Type: application/json" \\
-H "Authorization: Bearer <YOUR_API_KEY>" \\
-d '{
"status": "success",
"duration_ms": 1500,
"log": "任务执行完毕"
}'`
})
const pythonScript = computed(() => {
const b = baseUrl.value
return `import requests
BASE = "${b}"
# 1. AI Agent 注册自己
resp = requests.post(f"{BASE}/api/agents", json={
"name": "my-agent",
"description": "我的 AI Agent"
})
agent = resp.json()
API_KEY = agent["api_key"] # 保存此 Key
AGENT_ID = agent["id"]
# 2. 注册定时任务
resp = requests.post(f"{BASE}/api/tasks",
params={"agent_id": AGENT_ID},
headers={"Authorization": f"Bearer {API_KEY}"},
json={"name": "data-sync", "cron_expression": "*/5 * * * *"}
)
task = resp.json()
TASK_ID = task["id"]
# 3. 每次执行后汇报
requests.post(f"{BASE}/api/tasks/{TASK_ID}/executions",
headers={"Authorization": f"Bearer {API_KEY}"},
json={"status": "success", "duration_ms": 1500, "log": "done"})`
})
onMounted(async () => {
try {
const [sumRes, taskRes, cfgRes] = await Promise.all([
getDashboardSummary(),
getDashboardTasks(),
getSystemConfig(),
])
summary.value = sumRes.data
tasks.value = taskRes.data
baseUrl.value = cfgRes.data.base_url
} catch (e) {
console.error('Failed to load dashboard', e)
}
})
const copyScript = async () => {
const text = scriptTab.value === 'curl' ? curlScript.value : pythonScript.value
try {
await navigator.clipboard.writeText(text)
const { ElMessage } = await import('element-plus')
ElMessage.success('已复制')
} catch {
const { ElMessage } = await import('element-plus')
ElMessage.warning('复制失败,请手动复制')
}
}
</script>
<style scoped>
.dashboard {
max-width: 1200px;
}
/* ── Onboarding Panel ─────────────────────────── */
.onboarding-panel {
position: relative;
background: linear-gradient(135deg, rgba(102,126,234,0.08) 0%, rgba(118,75,162,0.08) 100%);
border: 1px solid rgba(102,126,234,0.15);
border-radius: 16px;
padding: 24px;
margin-bottom: 24px;
overflow: hidden;
}
.onboarding-glow {
position: absolute;
top: -50%;
right: -20%;
width: 300px;
height: 300px;
background: radial-gradient(circle, rgba(102,126,234,0.08) 0%, transparent 70%);
pointer-events: none;
}
.onboarding-content {
display: flex;
align-items: center;
gap: 16px;
position: relative;
}
.onboarding-icon {
width: 48px;
height: 48px;
background: linear-gradient(135deg, rgba(102,126,234,0.2), rgba(118,75,162,0.2));
border-radius: 12px;
display: flex;
align-items: center;
justify-content: center;
color: #a8b4ff;
flex-shrink: 0;
}
.onboarding-text h3 {
font-size: 16px;
font-weight: 600;
color: #e0e0f0;
margin-bottom: 4px;
}
.onboarding-text p {
font-size: 13px;
color: rgba(255,255,255,0.45);
margin: 0;
}
.btn-primary {
margin-left: auto;
padding: 8px 18px;
border-radius: 8px;
border: none;
background: linear-gradient(135deg, #667eea, #764ba2);
color: #fff;
font-size: 13px;
font-weight: 500;
cursor: pointer;
white-space: nowrap;
transition: opacity 0.2s;
}
.btn-primary:hover { opacity: 0.9; }
.onboarding-close {
position: absolute;
top: 16px;
right: 16px;
background: none;
border: none;
color: rgba(255,255,255,0.3);
cursor: pointer;
padding: 4px;
}
.onboarding-close:hover { color: #fff; }
/* Script block */
.onboarding-script {
margin-top: 16px;
background: rgba(0,0,0,0.3);
border-radius: 10px;
overflow: hidden;
position: relative;
}
.script-tabs {
display: flex;
border-bottom: 1px solid rgba(255,255,255,0.06);
}
.script-tabs button {
padding: 8px 16px;
background: none;
border: none;
color: rgba(255,255,255,0.4);
font-size: 12px;
cursor: pointer;
border-bottom: 2px solid transparent;
}
.script-tabs button.active {
color: #a8b4ff;
border-bottom-color: #667eea;
}
.script-body {
padding: 16px;
overflow-x: auto;
}
.script-body pre {
font-family: 'JetBrains Mono', 'Fira Code', 'Consolas', monospace;
font-size: 12px;
line-height: 1.7;
color: rgba(255,255,255,0.75);
white-space: pre;
margin: 0;
}
.btn-copy {
position: absolute;
top: 48px;
right: 12px;
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;
}
/* ── Stats Grid ───────────────────────────────── */
.stats-grid {
display: grid;
grid-template-columns: repeat(4, 1fr);
gap: 16px;
margin-bottom: 24px;
}
.stat-card {
background: var(--bg-card);
border: 1px solid var(--border-color);
border-radius: 14px;
padding: 20px;
display: flex;
gap: 16px;
transition: all 0.25s;
}
.stat-card:hover {
border-color: var(--card-accent, rgba(255,255,255,0.1));
box-shadow: 0 0 20px rgba(var(--card-accent, 255), 0.05);
transform: translateY(-2px);
}
.stat-icon {
width: 44px;
height: 44px;
border-radius: 12px;
background: linear-gradient(135deg, rgba(255,255,255,0.04), rgba(255,255,255,0.02));
border: 1px solid rgba(255,255,255,0.06);
display: flex;
align-items: center;
justify-content: center;
color: var(--card-accent, #667eea);
flex-shrink: 0;
}
.stat-body { flex: 1; }
.stat-label { font-size: 12px; color: var(--text-secondary); margin-bottom: 4px; }
.stat-value { font-size: 28px; font-weight: 700; color: #e8e8f0; line-height: 1.1; margin-bottom: 6px; }
.stat-sub { font-size: 12px; color: var(--text-muted); display: flex; align-items: center; gap: 6px; }
.stat-dot {
width: 5px; height: 5px; border-radius: 50%;
}
.stat-dot.active { background: #22c55e; box-shadow: 0 0 6px rgba(34,197,94,0.5); }
/* ── Task Table Section ───────────────────────── */
.section {
background: var(--bg-card);
border: 1px solid var(--border-color);
border-radius: 14px;
overflow: hidden;
}
.section-header {
display: flex;
justify-content: space-between;
align-items: center;
padding: 16px 20px;
border-bottom: 1px solid var(--border-color);
}
.section-header h3 {
font-size: 15px;
font-weight: 600;
color: #e0e0f0;
margin: 0;
}
.section-link {
font-size: 13px;
color: var(--accent-1);
text-decoration: none;
}
.section-link:hover { text-decoration: underline; }
.table-wrap {
overflow-x: auto;
}
.data-table {
width: 100%;
border-collapse: collapse;
}
.data-table th {
text-align: left;
padding: 12px 16px;
font-size: 11px;
font-weight: 500;
text-transform: uppercase;
letter-spacing: 0.5px;
color: var(--text-secondary);
background: rgba(255,255,255,0.02);
border-bottom: 1px solid var(--border-color);
}
.data-table td {
padding: 12px 16px;
font-size: 13px;
color: var(--text-primary);
border-bottom: 1px solid var(--border-color);
}
.data-table tbody tr:hover {
background: rgba(255,255,255,0.02);
}
.cell-badge {
display: inline-block;
padding: 2px 10px;
border-radius: 20px;
background: rgba(255,255,255,0.04);
color: var(--text-secondary);
font-size: 12px;
}
.cell-name {
font-weight: 500;
}
.cell-code {
font-family: 'JetBrains Mono', 'Fira Code', monospace;
font-size: 12px;
color: #a8b4ff;
background: rgba(102,126,234,0.1);
padding: 2px 8px;
border-radius: 4px;
}
.cell-mono {
font-family: 'JetBrains Mono', monospace;
font-size: 12px;
color: var(--text-secondary);
}
.cell-muted { color: var(--text-muted); }
.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;
}
.cell-link:hover { text-decoration: underline; }
/* Tags */
.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-red {
background: rgba(239,68,68,0.12);
color: #f87171;
}
.tag-gray {
background: rgba(255,255,255,0.05);
color: var(--text-secondary);
}
</style>

View File

@ -0,0 +1,431 @@
<template>
<div class="settings-page">
<!-- System Configuration -->
<div class="section mb-24">
<div class="section-header">
<h3>系统配置</h3>
</div>
<div class="config-body">
<div class="config-row">
<div class="config-info">
<div class="config-label">公网访问地址 (Base URL)</div>
<div class="config-desc">AI Agent 可通过此地址访问本系统 API修改后页面上的接入指令会自动更新</div>
</div>
<div class="config-input-group">
<input v-model="configForm.base_url" class="input config-input" placeholder="https://your-domain.com" />
<button class="btn-primary" @click="handleSaveConfig" :disabled="savingConfig">
{{ savingConfig ? '保存中...' : '保存' }}
</button>
</div>
</div>
<div v-if="configSaved" class="config-success">配置已保存</div>
</div>
</div>
<!-- Notification Channels -->
<div class="section mb-24">
<div class="section-header">
<h3>通知渠道</h3>
<button class="btn-primary" @click="showChannelDialog = true">
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><line x1="12" y1="5" x2="12" y2="19"/><line x1="5" y1="12" x2="19" y2="12"/></svg>
添加渠道
</button>
</div>
<div class="card-grid">
<div v-for="c in channels" :key="c.id" class="channel-card">
<div class="channel-top">
<div class="channel-icon" :class="c.channel_type">
<svg v-if="c.channel_type === 'feishu_cli'" width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5"><path d="M12 2a4 4 0 0 1 4 4v2a4 4 0 0 1-8 0V6a4 4 0 0 1 4-4z"/><path d="M5 15h14l-1.5 6H6.5L5 15z"/></svg>
<svg v-else-if="c.channel_type === 'email'" width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5"><path d="M4 4h16c1.1 0 2 .9 2 2v12c0 1.1-.9 2-2 2H4c-1.1 0-2-.9-2-2V6c0-1.1.9-2 2-2z"/><polyline points="22,6 12,13 2,6"/></svg>
<svg v-else width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5"><path d="M10 13a5 5 0 0 0 7.54.54l3-3a5 5 0 0 0-7.07-7.07l-1.72 1.71"/><path d="M14 11a5 5 0 0 0-7.54-.54l-3 3a5 5 0 0 0 7.07 7.07l1.71-1.71"/></svg>
</div>
<div>
<div class="channel-name">{{ c.name }}</div>
<div class="channel-type">{{ c.channel_type === 'feishu_cli' ? '飞书' : c.channel_type === 'email' ? '邮件' : 'Webhook' }}</div>
</div>
<div class="channel-spacer"></div>
<label class="toggle">
<input type="checkbox" :checked="c.enabled" @change="(e) => toggleChannel(c, e.target.checked)">
<span class="toggle-slider"></span>
</label>
</div>
<div class="channel-config">{{ c.config }}</div>
<button class="channel-delete" @click="handleDeleteChannel(c)">
<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>
</div>
<div v-if="channels.length === 0" class="empty-state">
<p>暂无通知渠道 添加飞书或邮件通知</p>
</div>
</div>
</div>
<!-- Alert Rules -->
<div class="section mb-24">
<div class="section-header">
<h3>告警规则</h3>
<button class="btn-primary" @click="showRuleDialog = true">
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><line x1="12" y1="5" x2="12" y2="19"/><line x1="5" y1="12" x2="19" y2="12"/></svg>
添加规则
</button>
</div>
<div class="table-wrap">
<table class="data-table">
<thead>
<tr>
<th>ID</th>
<th>任务</th>
<th>通知渠道</th>
<th>告警类型</th>
<th></th>
</tr>
</thead>
<tbody>
<tr v-for="r in rules" :key="r.id">
<td class="cell-mono">#{{ r.id }}</td>
<td class="cell-name">任务 #{{ r.task_id }}</td>
<td>{{ r.channel_name }}</td>
<td><span class="tag" :class="r.alert_type === 'missed_run' ? 'tag-warn' : 'tag-red'">{{ r.alert_type === 'missed_run' ? '未按时执行' : '执行失败' }}</span></td>
<td><button class="cell-link-btn" @click="handleDeleteRule(r)">删除</button></td>
</tr>
<tr v-if="rules.length === 0">
<td colspan="5" class="cell-empty">暂无规则</td>
</tr>
</tbody>
</table>
</div>
</div>
<!-- Add Channel Modal -->
<div v-if="showChannelDialog" class="modal-overlay" @click.self="showChannelDialog = false">
<div class="modal">
<div class="modal-header">
<h4>添加通知渠道</h4>
<button class="modal-close" @click="showChannelDialog = false"><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>渠道名称</label>
<input v-model="channelForm.name" placeholder="例如:我的飞书" class="input" />
</div>
<div class="field">
<label>渠道类型</label>
<select v-model="channelForm.channel_type" class="input">
<option value="feishu_cli">飞书 Webhook</option>
<option value="email">邮件 (SMTP)</option>
<option value="webhook">通用 Webhook</option>
</select>
</div>
<div class="field">
<label>配置 (JSON)</label>
<textarea v-model="channelForm.config" rows="4" class="input" placeholder='飞书: {"webhook_url":"..."}'></textarea>
</div>
</div>
<div class="modal-footer">
<button class="btn-secondary" @click="showChannelDialog = false">取消</button>
<button class="btn-primary" @click="handleAddChannel">添加</button>
</div>
</div>
</div>
<!-- Add Rule Modal -->
<div v-if="showRuleDialog" class="modal-overlay" @click.self="showRuleDialog = false">
<div class="modal">
<div class="modal-header">
<h4>添加告警规则</h4>
<button class="modal-close" @click="showRuleDialog = false"><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>任务</label>
<select v-model="ruleForm.task_id" class="input">
<option v-for="t in allTasks" :key="t.id" :value="t.id">[{{ t.id }}] {{ t.name }}</option>
</select>
</div>
<div class="field">
<label>通知渠道</label>
<select v-model="ruleForm.channel_id" class="input">
<option v-for="c in channels" :key="c.id" :value="c.id">{{ c.name }}</option>
</select>
</div>
<div class="field">
<label>告警类型</label>
<select v-model="ruleForm.alert_type" class="input">
<option value="missed_run">未按时执行</option>
<option value="failure">执行失败</option>
</select>
</div>
</div>
<div class="modal-footer">
<button class="btn-secondary" @click="showRuleDialog = false">取消</button>
<button class="btn-primary" @click="handleAddRule">添加</button>
</div>
</div>
</div>
</div>
</template>
<script setup>
import { ref, onMounted } from 'vue'
import {
listChannels, createChannel, updateChannel, deleteChannel,
listAlertRules, createAlertRule, deleteAlertRule,
listTasks, getSystemConfig, saveSystemConfig,
} from '../api/index.js'
const channels = ref([])
const rules = ref([])
const allTasks = ref([])
const showChannelDialog = ref(false)
const showRuleDialog = ref(false)
const channelForm = ref({ name: '', channel_type: 'feishu_cli', config: '' })
const ruleForm = ref({ task_id: null, channel_id: null, alert_type: 'missed_run' })
// System config
const configForm = ref({ base_url: '' })
const savingConfig = ref(false)
const configSaved = ref(false)
const loadData = async () => {
const [chRes, ruleRes, taskRes, cfgRes] = await Promise.all([
listChannels(), listAlertRules(), listTasks(), getSystemConfig(),
])
channels.value = chRes.data
rules.value = ruleRes.data
allTasks.value = taskRes.data
configForm.value.base_url = cfgRes.data.base_url
}
const handleSaveConfig = async () => {
savingConfig.value = true
configSaved.value = false
try {
const res = await saveSystemConfig({ base_url: configForm.value.base_url })
configForm.value.base_url = res.data.base_url
configSaved.value = true
setTimeout(() => { configSaved.value = false }, 3000)
} catch (e) {
console.error(e)
} finally {
savingConfig.value = false
}
}
const handleAddChannel = async () => {
try {
await createChannel(channelForm.value)
showChannelDialog.value = false
channelForm.value = { name: '', channel_type: 'feishu_cli', config: '' }
await loadData()
} catch (e) { console.error(e) }
}
const toggleChannel = async (row, enabled) => {
try { await updateChannel(row.id, { enabled }) } catch (e) { console.error(e) }
}
const handleDeleteChannel = async (row) => {
try { await deleteChannel(row.id); await loadData() } catch (e) { console.error(e) }
}
const handleAddRule = async () => {
try {
await createAlertRule(ruleForm.value)
showRuleDialog.value = false
ruleForm.value = { task_id: null, channel_id: null, alert_type: 'missed_run' }
await loadData()
} catch (e) { console.error(e) }
}
const handleDeleteRule = async (row) => {
try { await deleteAlertRule(row.id); await loadData() } catch (e) { console.error(e) }
}
onMounted(loadData)
</script>
<style scoped>
.settings-page { max-width: 1200px; }
.mb-24 { margin-bottom: 24px; }
.section {
background: var(--bg-card);
border: 1px solid var(--border-color);
border-radius: 14px;
overflow: hidden;
}
.section-header {
display: flex;
justify-content: space-between;
align-items: center;
padding: 16px 20px;
border-bottom: 1px solid var(--border-color);
}
.section-header h3 { font-size: 15px; font-weight: 600; color: #e0e0f0; margin: 0; }
/* System Config */
.config-body { padding: 20px; }
.config-row { display: flex; align-items: flex-start; gap: 20px; }
.config-info { flex: 1; }
.config-label { font-size: 14px; font-weight: 500; color: var(--text-primary); margin-bottom: 4px; }
.config-desc { font-size: 12px; color: var(--text-muted); line-height: 1.5; }
.config-input-group { display: flex; gap: 8px; align-items: center; flex-shrink: 0; }
.config-input { width: 320px; }
.config-success {
margin-top: 12px;
font-size: 13px;
color: #4ade80;
padding: 6px 12px;
background: rgba(34,197,94,0.1);
border-radius: 6px;
display: inline-block;
}
/* Cards */
.card-grid { padding: 16px; display: flex; flex-direction: column; gap: 12px; }
.channel-card {
position: relative;
background: rgba(255,255,255,0.02);
border: 1px solid var(--border-color);
border-radius: 10px;
padding: 16px;
}
.channel-top { display: flex; align-items: center; gap: 12px; }
.channel-icon {
width: 40px; height: 40px; border-radius: 10px;
display: flex; align-items: center; justify-content: center;
flex-shrink: 0;
}
.channel-icon.feishu_cli { background: rgba(102,126,234,0.15); color: #a8b4ff; }
.channel-icon.email { background: rgba(34,197,94,0.15); color: #4ade80; }
.channel-icon.webhook { background: rgba(245,158,11,0.15); color: #fbbf24; }
.channel-spacer { flex: 1; }
.channel-name { font-size: 14px; font-weight: 500; color: var(--text-primary); }
.channel-type { font-size: 11px; color: var(--text-muted); }
.channel-config {
margin-top: 8px;
font-family: 'JetBrains Mono', monospace;
font-size: 11px;
color: var(--text-muted);
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.channel-delete {
position: absolute; top: 12px; right: 12px;
background: none; border: none;
color: var(--text-muted); cursor: pointer;
}
.channel-delete:hover { color: var(--danger); }
/* Toggle */
.toggle { position: relative; display: inline-block; width: 36px; height: 20px; }
.toggle input { opacity: 0; width: 0; height: 0; }
.toggle-slider {
position: absolute; inset: 0;
background: rgba(255,255,255,0.1);
border-radius: 10px;
cursor: pointer;
transition: 0.2s;
}
.toggle-slider::before {
content: '';
position: absolute;
width: 16px; height: 16px;
left: 2px; top: 2px;
background: #fff;
border-radius: 50%;
transition: 0.2s;
}
.toggle input:checked + .toggle-slider { background: var(--accent-1); }
.toggle input:checked + .toggle-slider::before { transform: translateX(16px); }
/* Table */
.table-wrap { overflow-x: auto; }
.data-table { width: 100%; border-collapse: collapse; }
.data-table th {
text-align: left; padding: 12px 16px; font-size: 11px; font-weight: 500;
text-transform: uppercase; letter-spacing: 0.5px;
color: var(--text-secondary); background: rgba(255,255,255,0.02);
border-bottom: 1px solid var(--border-color);
}
.data-table td {
padding: 12px 16px; font-size: 13px;
color: var(--text-primary); border-bottom: 1px solid var(--border-color);
}
.data-table tbody tr:hover { background: rgba(255,255,255,0.02); }
.cell-mono { font-family: 'JetBrains Mono', monospace; font-size: 12px; color: var(--text-secondary); }
.cell-name { font-weight: 500; }
.cell-empty { text-align: center; color: var(--text-muted); padding: 40px 16px !important; }
.cell-link-btn { background: none; border: none; color: var(--danger); font-size: 13px; cursor: pointer; }
.cell-link-btn:hover { text-decoration: underline; }
.tag { display: inline-block; padding: 2px 10px; border-radius: 20px; font-size: 11px; font-weight: 500; }
.tag-warn { background: rgba(245,158,11,0.12); color: #fbbf24; }
.tag-red { background: rgba(239,68,68,0.12); color: #f87171; }
.empty-state { text-align: center; padding: 40px 20px; color: var(--text-muted); }
/* Buttons */
.btn-primary {
display: inline-flex; align-items: center; gap: 6px;
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); }
/* 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: 500px; 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); }
.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;
transition: border-color 0.15s;
}
.input:focus { border-color: var(--accent-1); }
textarea.input { resize: vertical; font-family: 'JetBrains Mono', monospace; font-size: 12px; }
select.input { cursor: pointer; }
</style>

View File

@ -0,0 +1,242 @@
<template>
<div class="task-detail">
<button class="back-btn" @click="$router.back()">
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><line x1="19" y1="12" x2="5" y2="12"/><polyline points="12 19 5 12 12 5"/></svg>
返回
</button>
<div v-if="task" class="section mb-24">
<div class="section-header">
<h3>{{ task.name }}</h3>
<span class="tag" :class="task.status === 'active' ? 'tag-green' : 'tag-gray'">
{{ task.status === 'active' ? '运行中' : task.status === 'paused' ? '已暂停' : '已停止' }}
</span>
</div>
<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"><span class="detail-label">Cron</span><code class="detail-code">{{ 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 mono">{{ task.last_run_at ? dayjs(task.last_run_at).format('YYYY-MM-DD HH:mm:ss') : '—' }}</span></div>
<div class="detail-item">
<span class="detail-label">最近结果</span>
<span v-if="task.last_run_result" class="tag" :class="task.last_run_result === 'success' ? 'tag-green' : 'tag-red'">
{{ task.last_run_result === 'success' ? '成功' : '失败' }}
</span>
<span v-else class="detail-value"></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 ? dayjs(task.next_run_at).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>
</div>
<!-- Execution History -->
<div class="section">
<div class="section-header">
<h3>执行历史</h3>
</div>
<div class="table-wrap">
<table class="data-table">
<thead>
<tr>
<th>ID</th>
<th>状态</th>
<th>开始时间</th>
<th>耗时(ms)</th>
<th>结果摘要</th>
<th></th>
</tr>
</thead>
<tbody>
<tr v-for="e in executions" :key="e.id">
<td class="cell-mono">#{{ e.id }}</td>
<td>
<span class="tag" :class="e.status === 'success' ? 'tag-green' : e.status === 'failed' ? 'tag-red' : 'tag-warn'">
{{ e.status === 'success' ? '成功' : e.status === 'failed' ? '失败' : '运行中' }}
</span>
</td>
<td class="cell-mono">{{ dayjs(e.started_at).format('MM-DD HH:mm:ss') }}</td>
<td class="cell-mono">{{ e.duration_ms ?? '—' }}</td>
<td class="cell-muted cell-ellipsis">{{ e.result || '—' }}</td>
<td><button class="cell-link-btn" @click="showLog(e)">日志</button></td>
</tr>
<tr v-if="executions.length === 0">
<td colspan="6" class="cell-empty">暂无执行记录</td>
</tr>
</tbody>
</table>
</div>
</div>
<!-- Log Dialog -->
<div v-if="logVisible" class="modal-overlay" @click.self="logVisible = false">
<div class="modal">
<div class="modal-header">
<h4>执行日志 #{{ logExecution?.id }}</h4>
<button class="modal-close" @click="logVisible = false">
<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>
<pre class="log-viewer">{{ currentLog || '暂无日志' }}</pre>
</div>
</div>
</div>
</template>
<script setup>
import { ref, onMounted } from 'vue'
import { useRoute } from 'vue-router'
import dayjs from 'dayjs'
import { getTask, listExecutions } from '../api/index.js'
const route = useRoute()
const taskId = Number(route.params.id)
const task = ref(null)
const executions = ref([])
const logVisible = ref(false)
const currentLog = ref('')
const logExecution = ref(null)
onMounted(async () => {
try {
const [taskRes, execRes] = await Promise.all([
getTask(taskId),
listExecutions(taskId, { limit: 100 }),
])
task.value = taskRes.data
executions.value = execRes.data
} catch (e) {
console.error(e)
}
})
const showLog = (row) => {
logExecution.value = row
currentLog.value = row.log || '暂无日志'
logVisible.value = true
}
</script>
<style scoped>
.task-detail { max-width: 1200px; }
.back-btn {
display: inline-flex;
align-items: center;
gap: 6px;
background: none;
border: 1px solid var(--border-color);
border-radius: 8px;
padding: 6px 14px;
color: var(--text-secondary);
font-size: 13px;
cursor: pointer;
margin-bottom: 16px;
}
.back-btn:hover { color: #fff; border-color: rgba(255,255,255,0.15); }
.mb-24 { margin-bottom: 24px; }
.section {
background: var(--bg-card);
border: 1px solid var(--border-color);
border-radius: 14px;
overflow: hidden;
}
.section-header {
display: flex;
justify-content: space-between;
align-items: center;
padding: 16px 20px;
border-bottom: 1px solid var(--border-color);
}
.section-header h3 { font-size: 15px; font-weight: 600; color: #e0e0f0; margin: 0; }
.detail-grid {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 0;
}
.detail-item {
display: flex;
flex-direction: column;
gap: 4px;
padding: 14px 20px;
border-bottom: 1px solid var(--border-color);
}
.detail-item:nth-child(odd) { border-right: 1px solid var(--border-color); }
.detail-label { font-size: 11px; color: var(--text-muted); text-transform: uppercase; letter-spacing: 0.5px; }
.detail-value { font-size: 13px; color: var(--text-primary); }
.detail-value.mono, .cell-mono { font-family: 'JetBrains Mono', monospace; font-size: 12px; color: var(--text-secondary); }
.detail-code {
font-family: 'JetBrains Mono', monospace;
font-size: 12px; color: #a8b4ff;
background: rgba(102,126,234,0.1);
padding: 2px 8px; border-radius: 4px;
display: inline-block;
}
/* Table */
.table-wrap { overflow-x: auto; }
.data-table { width: 100%; border-collapse: collapse; }
.data-table th {
text-align: left; padding: 12px 16px; font-size: 11px; font-weight: 500;
text-transform: uppercase; letter-spacing: 0.5px;
color: var(--text-secondary); background: rgba(255,255,255,0.02);
border-bottom: 1px solid var(--border-color);
}
.data-table td {
padding: 12px 16px; font-size: 13px;
color: var(--text-primary); border-bottom: 1px solid var(--border-color);
}
.data-table tbody tr:hover { background: rgba(255,255,255,0.02); }
.cell-mono { font-family: 'JetBrains Mono', monospace; font-size: 12px; color: var(--text-secondary); }
.cell-muted { color: var(--text-muted); }
.cell-empty { text-align: center; color: var(--text-muted); padding: 40px 16px !important; }
.cell-ellipsis { max-width: 200px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.cell-link-btn {
background: none; border: none; color: var(--accent-1);
font-size: 13px; 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-green { background: rgba(34,197,94,0.12); color: #4ade80; }
.tag-red { background: rgba(239,68,68,0.12); color: #f87171; }
.tag-warn { background: rgba(245,158,11,0.12); color: #fbbf24; }
.tag-gray { background: rgba(255,255,255,0.05); color: var(--text-secondary); }
/* Log 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: 700px; 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; }
.log-viewer {
background: #0d0d14;
color: #c0c4d0;
padding: 20px;
font-family: 'JetBrains Mono', 'Fira Code', monospace;
font-size: 13px;
line-height: 1.6;
max-height: 500px;
overflow: auto;
white-space: pre-wrap;
word-break: break-all;
margin: 0;
}
</style>

View File

@ -0,0 +1,220 @@
<template>
<div class="tasks-page">
<div class="section mb-24">
<div class="section-header">
<h3>定时任务 AI Agent 自动管理</h3>
</div>
<div class="onboard-body">
<p>Agent 注册后通过下方 API 汇报任务执行情况无需手动创建</p>
<div class="code-block">
<div class="code-tabs">
<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>
</div>
<!-- Task list -->
<div class="section">
<div class="section-header">
<h3>所有任务</h3>
</div>
<div class="table-wrap">
<table class="data-table">
<thead>
<tr>
<th>ID</th>
<th>Agent</th>
<th>任务名称</th>
<th>Cron</th>
<th>状态</th>
<th>最近运行</th>
<th>结果</th>
<th>执行次数</th>
<th></th>
</tr>
</thead>
<tbody>
<tr v-for="t in tasks" :key="t.id">
<td class="cell-mono">#{{ t.id }}</td>
<td><span class="cell-badge">{{ t.agent_name }}</span></td>
<td class="cell-name">{{ t.name }}</td>
<td><code class="cell-code">{{ t.cron_expression }}</code></td>
<td>
<span class="tag" :class="t.status === 'active' ? 'tag-green' : 'tag-gray'">
{{ t.status === 'active' ? '运行中' : t.status === 'paused' ? '已暂停' : '已停止' }}
</span>
</td>
<td class="cell-mono">{{ t.last_run_at ? dayjs(t.last_run_at).format('MM-DD HH:mm') : '—' }}</td>
<td>
<span v-if="t.last_run_result" class="tag" :class="t.last_run_result === 'success' ? 'tag-green' : 'tag-red'">
{{ t.last_run_result === 'success' ? '成功' : '失败' }}
</span>
<span v-else class="cell-muted"></span>
</td>
<td class="cell-mono">{{ t.total_run_count }}</td>
<td>
<router-link :to="`/tasks/${t.id}`" class="cell-link">详情</router-link>
<button class="btn-icon" @click="handleDelete(t)" title="删除">
<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>
</td>
</tr>
<tr v-if="tasks.length === 0">
<td colspan="9" class="cell-empty">暂无任务 Agent 注册后会自动出现在这里</td>
</tr>
</tbody>
</table>
</div>
</div>
</div>
</template>
<script setup>
import { ref, onMounted, computed } from 'vue'
import dayjs from 'dayjs'
import { listTasks, deleteTask, getSystemConfig } from '../api/index.js'
const tasks = ref([])
const tab = ref('report')
const baseUrl = ref(window.location.origin)
const loadData = async () => {
const [taskRes, cfgRes] = await Promise.all([
listTasks(),
getSystemConfig(),
])
tasks.value = taskRes.data
baseUrl.value = cfgRes.data.base_url
}
const handleDelete = async (row) => {
try {
await deleteTask(row.id)
await loadData()
} catch (e) {
console.error(e)
}
}
const codes = computed(() => {
const b = baseUrl.value
return {
report: `curl -X POST ${b}/api/tasks/<TASK_ID>/executions \\
-H "Content-Type: application/json" \\
-H "Authorization: Bearer <YOUR_API_KEY>" \\
-d '{"status":"success","duration_ms":2500,"log":"done"}'`,
register: `curl -X POST "${b}/api/tasks?agent_id=<AGENT_ID>" \\
-H "Content-Type: application/json" \\
-H "Authorization: Bearer <YOUR_API_KEY>" \\
-d '{"name":"data-sync","cron_expression":"*/5 * * * *","grace_period":300}'`,
batch: `curl -X POST ${b}/api/agents/register-with-tasks \\
-H "Content-Type: application/json" \\
-d '{"name":"agent","tasks":[{"name":"sync","cron_expression":"*/5 * * * *"}]}'`,
}
})
const copyCode = async () => {
try {
await navigator.clipboard.writeText(codes.value[tab.value])
const { ElMessage } = await import('element-plus')
ElMessage.success('已复制')
} catch {}
}
onMounted(loadData)
</script>
<style scoped>
.tasks-page { max-width: 1200px; }
.mb-24 { margin-bottom: 24px; }
.section {
background: var(--bg-card);
border: 1px solid var(--border-color);
border-radius: 14px;
overflow: hidden;
}
.section-header {
padding: 16px 20px;
border-bottom: 1px solid var(--border-color);
}
.section-header h3 { font-size: 15px; font-weight: 600; color: #e0e0f0; margin: 0; }
.onboard-body { padding: 20px; }
.onboard-body > p { font-size: 13px; color: var(--text-secondary); margin-bottom: 16px; }
.code-block {
position: relative;
background: rgba(0,0,0,0.3);
border-radius: 10px;
overflow: hidden;
}
.code-tabs { display: flex; border-bottom: 1px solid rgba(255,255,255,0.06); }
.code-tabs button {
padding: 8px 16px; background: none; border: none;
color: rgba(255,255,255,0.4); font-size: 12px; cursor: pointer;
border-bottom: 2px solid transparent;
}
.code-tabs button.active { color: #a8b4ff; border-bottom-color: #667eea; }
.code-body { padding: 16px; overflow-x: auto; }
.code-body pre {
font-family: 'JetBrains Mono', 'Fira Code', monospace;
font-size: 12px; line-height: 1.7; color: rgba(255,255,255,0.75);
white-space: pre; margin: 0;
}
.btn-copy {
position: absolute; top: 44px; right: 12px;
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-wrap { overflow-x: auto; }
.data-table { width: 100%; border-collapse: collapse; }
.data-table th {
text-align: left; padding: 12px 16px; font-size: 11px; font-weight: 500;
text-transform: uppercase; letter-spacing: 0.5px;
color: var(--text-secondary); background: rgba(255,255,255,0.02);
border-bottom: 1px solid var(--border-color);
}
.data-table td {
padding: 12px 16px; font-size: 13px;
color: var(--text-primary); border-bottom: 1px solid var(--border-color);
}
.data-table tbody tr:hover { background: rgba(255,255,255,0.02); }
.cell-badge {
display: inline-block; padding: 2px 10px; border-radius: 20px;
background: rgba(255,255,255,0.04); color: var(--text-secondary); font-size: 12px;
}
.cell-name { font-weight: 500; }
.cell-code {
font-family: 'JetBrains Mono', monospace; font-size: 12px;
color: #a8b4ff; background: rgba(102,126,234,0.1);
padding: 2px 8px; border-radius: 4px;
}
.cell-mono { font-family: 'JetBrains Mono', monospace; font-size: 12px; color: var(--text-secondary); }
.cell-muted { color: var(--text-muted); }
.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:hover { text-decoration: underline; }
.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-red { background: rgba(239,68,68,0.12); color: #f87171; }
.tag-gray { background: rgba(255,255,255,0.05); color: var(--text-secondary); }
.btn-icon {
background: none; border: none; color: var(--text-muted);
cursor: pointer; padding: 4px; vertical-align: middle;
}
.btn-icon:hover { color: var(--danger); }
</style>

19
frontend/vite.config.js Normal file
View File

@ -0,0 +1,19 @@
import { defineConfig } from 'vite'
import vue from '@vitejs/plugin-vue'
export default defineConfig({
plugins: [vue()],
server: {
port: 3000,
proxy: {
'/api': {
target: 'http://localhost:8000',
changeOrigin: true,
},
},
},
build: {
outDir: 'dist',
assetsDir: 'assets',
},
})

1
init.sql Normal file
View File

@ -0,0 +1 @@
CREATE DATABASE IF NOT EXISTS taskpulse CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;