feat: agent avatars, task tags, search, edit/delete agents, cron readability, timezone fix
- Agent avatars: first-letter + deterministic color avatar in all views - Agent management: edit (name/description) and delete with confirmation - Execution API: validates agent existence, returns 410 if deleted - Task tags: JSON array field, editable with tag pills UI - Task search: by name (?q=) and by tags (?tags=) and by status - Task edit: simplified to name/tags/description only (not agent params) - Cron display: human-readable Chinese (e.g. '每5分钟', '每天 09:00') - Timezone fix: UTC→local via dayjs.utc().local() across all pages - Content area: full width (removed max-width:1200px constraints) - Login page: standalone layout without sidebar
This commit is contained in:
@@ -59,6 +59,17 @@ async def heartbeat(agent_id: int, body: AgentHeartbeat, db: AsyncSession = Depe
|
||||
return AgentOut(**{**agent.__dict__, "task_count": cnt})
|
||||
|
||||
|
||||
@router.delete("/{agent_id}", status_code=204)
|
||||
async def delete_agent(agent_id: int, db: AsyncSession = Depends(get_db)):
|
||||
svc = AgentService(db)
|
||||
agent = await svc.get(agent_id)
|
||||
if not agent:
|
||||
raise HTTPException(404, "Agent not found")
|
||||
# Delete all tasks first, then the agent (cascade)
|
||||
await svc.delete(agent_id)
|
||||
await db.commit()
|
||||
|
||||
|
||||
# ── Batch registration: AI agent self-registers with all its tasks ─────
|
||||
|
||||
@router.post("/register-with-tasks", response_model=AgentBatchRegisterResult, status_code=201)
|
||||
|
||||
@@ -20,6 +20,14 @@ async def report_execution(task_id: int, body: ExecutionReport,
|
||||
if not task:
|
||||
raise HTTPException(404, "Task not found")
|
||||
|
||||
# Verify agent exists and is active
|
||||
agent_svc = AgentService(db)
|
||||
agent = await agent_svc.get(task.agent_id)
|
||||
if not agent:
|
||||
raise HTTPException(410, "Agent已被删除,请重新注册")
|
||||
if agent.status != "active":
|
||||
raise HTTPException(403, "Agent已停用,无法汇报")
|
||||
|
||||
exec_svc = ExecutionService(db)
|
||||
record = await exec_svc.report_result(
|
||||
task_id=task_id,
|
||||
|
||||
+65
-14
@@ -1,13 +1,28 @@
|
||||
"""API router — Task management."""
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException
|
||||
import json
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException, Query
|
||||
from sqlalchemy import select, or_
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.database import get_db
|
||||
from app.schemas import TaskCreate, TaskOut, TaskUpdate
|
||||
from app.services import TaskService, AgentService
|
||||
from app.models import ScheduledTask
|
||||
|
||||
router = APIRouter(prefix="/api/tasks", tags=["tasks"])
|
||||
router = APIRouter(prefix="/api/tasks", tags=["tags"])
|
||||
|
||||
|
||||
def _task_to_out(task, agent_name: str = "") -> dict:
|
||||
"""Convert task model to TaskOut dict, handling tags JSON deserialization."""
|
||||
data = {**task.__dict__}
|
||||
try:
|
||||
data["tags"] = json.loads(data.get("tags", "[]"))
|
||||
except (json.JSONDecodeError, TypeError):
|
||||
data["tags"] = []
|
||||
data["agent_name"] = agent_name
|
||||
return data
|
||||
|
||||
|
||||
@router.post("", response_model=TaskOut, status_code=201)
|
||||
@@ -25,23 +40,48 @@ async def create_task(body: TaskCreate, agent_id: int, db: AsyncSession = Depend
|
||||
description=body.description,
|
||||
grace_period=body.grace_period,
|
||||
)
|
||||
return TaskOut(**{**task.__dict__, "agent_name": agent.name})
|
||||
# Store tags
|
||||
if body.tags:
|
||||
task.tags = json.dumps(body.tags, ensure_ascii=False)
|
||||
await db.flush()
|
||||
await db.refresh(task)
|
||||
return TaskOut(**_task_to_out(task, agent.name))
|
||||
|
||||
|
||||
@router.get("", response_model=list[TaskOut])
|
||||
async def list_tasks(agent_id: int | None = None, db: AsyncSession = Depends(get_db)):
|
||||
"""List all tasks, optionally filtered by agent_id."""
|
||||
async def list_tasks(
|
||||
agent_id: int | None = Query(None),
|
||||
q: str | None = Query(None, description="关键词搜索(任务名称)"),
|
||||
tags: str | None = Query(None, description="标签筛选,逗号分隔"),
|
||||
status: str | None = Query(None),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
"""List all tasks, with optional search and filters."""
|
||||
svc = TaskService(db)
|
||||
agent_svc = AgentService(db)
|
||||
|
||||
# Build query
|
||||
stmt = select(ScheduledTask)
|
||||
if agent_id:
|
||||
tasks = await svc.list_by_agent(agent_id)
|
||||
else:
|
||||
tasks = await svc.list_all()
|
||||
result = []
|
||||
stmt = stmt.where(ScheduledTask.agent_id == agent_id)
|
||||
if status:
|
||||
stmt = stmt.where(ScheduledTask.status == status)
|
||||
if q:
|
||||
stmt = stmt.where(ScheduledTask.name.like(f"%{q}%"))
|
||||
if tags:
|
||||
tag_list = [t.strip() for t in tags.split(",") if t.strip()]
|
||||
for tag in tag_list:
|
||||
stmt = stmt.where(ScheduledTask.tags.like(f'%"{tag}"%'))
|
||||
|
||||
stmt = stmt.order_by(ScheduledTask.id)
|
||||
result = await db.execute(stmt)
|
||||
tasks = list(result.scalars().all())
|
||||
|
||||
output = []
|
||||
for t in tasks:
|
||||
agent = await agent_svc.get(t.agent_id)
|
||||
result.append(TaskOut(**{**t.__dict__, "agent_name": agent.name if agent else ""}))
|
||||
return result
|
||||
output.append(TaskOut(**_task_to_out(t, agent.name if agent else "")))
|
||||
return output
|
||||
|
||||
|
||||
@router.get("/{task_id}", response_model=TaskOut)
|
||||
@@ -52,18 +92,29 @@ async def get_task(task_id: int, db: AsyncSession = Depends(get_db)):
|
||||
if not task:
|
||||
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 ""})
|
||||
return TaskOut(**_task_to_out(task, 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))
|
||||
|
||||
# Handle tags separately (JSON serialization)
|
||||
update_data = body.model_dump(exclude_none=True)
|
||||
tags_list = update_data.pop("tags", None)
|
||||
|
||||
task = await svc.update(task_id, **update_data)
|
||||
if not task:
|
||||
raise HTTPException(404, "Task not found")
|
||||
|
||||
if tags_list is not None:
|
||||
task.tags = json.dumps(tags_list, ensure_ascii=False)
|
||||
await db.flush()
|
||||
await db.refresh(task)
|
||||
|
||||
agent = await agent_svc.get(task.agent_id)
|
||||
return TaskOut(**{**task.__dict__, "agent_name": agent.name if agent else ""})
|
||||
return TaskOut(**_task_to_out(task, agent.name if agent else ""))
|
||||
|
||||
|
||||
@router.delete("/{task_id}", status_code=204)
|
||||
|
||||
@@ -18,6 +18,7 @@ class ScheduledTask(Base):
|
||||
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")
|
||||
tags: Mapped[str | None] = mapped_column(Text, nullable=True, comment="标签 JSON 数组, e.g. [\"数据同步\",\"重要\"]")
|
||||
|
||||
# 冗余字段方便查询
|
||||
last_run_at: Mapped[datetime | None] = mapped_column(DateTime, nullable=True, comment="最近一次执行时间")
|
||||
|
||||
@@ -10,6 +10,7 @@ class TaskCreate(BaseModel):
|
||||
description: str = Field(default="")
|
||||
cron_expression: str = Field(..., max_length=64)
|
||||
grace_period: int = Field(default=300, ge=0)
|
||||
tags: list[str] = Field(default_factory=list)
|
||||
|
||||
|
||||
class TaskUpdate(BaseModel):
|
||||
@@ -18,6 +19,7 @@ class TaskUpdate(BaseModel):
|
||||
cron_expression: str | None = None
|
||||
grace_period: int | None = None
|
||||
status: str | None = None # active / paused / stopped
|
||||
tags: list[str] | None = None
|
||||
|
||||
|
||||
class TaskOut(BaseModel):
|
||||
@@ -29,6 +31,7 @@ class TaskOut(BaseModel):
|
||||
cron_expression: str
|
||||
grace_period: int
|
||||
status: str
|
||||
tags: list[str] = Field(default_factory=list)
|
||||
last_run_at: datetime | None = None
|
||||
last_run_result: str | None = None
|
||||
next_run_at: datetime | None = None
|
||||
|
||||
@@ -52,3 +52,11 @@ class AgentService:
|
||||
stmt = select(func.count(ScheduledTask.id)).where(ScheduledTask.agent_id == agent_id)
|
||||
result = await self.db.execute(stmt)
|
||||
return result.scalar() or 0
|
||||
|
||||
async def delete(self, agent_id: int) -> bool:
|
||||
agent = await self.get(agent_id)
|
||||
if agent is None:
|
||||
return False
|
||||
await self.db.delete(agent)
|
||||
await self.db.flush()
|
||||
return True
|
||||
|
||||
Reference in New Issue
Block a user