feat: initial TaskPulse release

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

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