feat: initial TaskPulse release
AI Agent 定时任务跟踪、监控、状态看板系统。 - FastAPI + Vue3 单体应用 - AI Agent 一键接入(批量注册 API) - 暗色主题 Dashboard - 后台调度器 + 超时告警 - 飞书/邮件/Webhook 多渠道通知 - Base URL 可配置 - Gunicorn 生产部署
This commit is contained in:
@@ -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
|
||||
@@ -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"]
|
||||
@@ -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
|
||||
@@ -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()
|
||||
@@ -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,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
|
||||
@@ -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,
|
||||
)
|
||||
@@ -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
|
||||
@@ -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__)
|
||||
@@ -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})
|
||||
@@ -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,
|
||||
)
|
||||
@@ -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")
|
||||
@@ -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()
|
||||
@@ -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
|
||||
@@ -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"}
|
||||
@@ -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"]
|
||||
@@ -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}>"
|
||||
@@ -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}>"
|
||||
@@ -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}>"
|
||||
@@ -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}>"
|
||||
@@ -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}>"
|
||||
@@ -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",
|
||||
]
|
||||
@@ -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
|
||||
@@ -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}
|
||||
@@ -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
|
||||
@@ -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}
|
||||
@@ -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",
|
||||
]
|
||||
@@ -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
|
||||
@@ -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())
|
||||
@@ -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)
|
||||
@@ -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)
|
||||
@@ -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
|
||||
@@ -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
|
||||
Reference in New Issue
Block a user