diff --git a/backend/alembic/versions/0001_initial.py b/backend/alembic/versions/0001_initial.py index 8de184a..d3d792c 100644 --- a/backend/alembic/versions/0001_initial.py +++ b/backend/alembic/versions/0001_initial.py @@ -48,6 +48,7 @@ def upgrade() -> None: 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("tags", sa.Text, nullable=True), 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), diff --git a/backend/app/api/agents.py b/backend/app/api/agents.py index ac69b9d..e36d83d 100644 --- a/backend/app/api/agents.py +++ b/backend/app/api/agents.py @@ -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) diff --git a/backend/app/api/executions.py b/backend/app/api/executions.py index 78d808c..ebd6928 100644 --- a/backend/app/api/executions.py +++ b/backend/app/api/executions.py @@ -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, diff --git a/backend/app/api/tasks.py b/backend/app/api/tasks.py index 4e6debc..dd76bb9 100644 --- a/backend/app/api/tasks.py +++ b/backend/app/api/tasks.py @@ -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) diff --git a/backend/app/models/task.py b/backend/app/models/task.py index a07e868..8baa0fc 100644 --- a/backend/app/models/task.py +++ b/backend/app/models/task.py @@ -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="最近一次执行时间") diff --git a/backend/app/schemas/task.py b/backend/app/schemas/task.py index 751b147..366f156 100644 --- a/backend/app/schemas/task.py +++ b/backend/app/schemas/task.py @@ -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 diff --git a/backend/app/services/agent.py b/backend/app/services/agent.py index d2344f7..40c9605 100644 --- a/backend/app/services/agent.py +++ b/backend/app/services/agent.py @@ -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 diff --git a/frontend/package-lock.json b/frontend/package-lock.json index c96b6ed..465fd4f 100644 --- a/frontend/package-lock.json +++ b/frontend/package-lock.json @@ -10,6 +10,7 @@ "dependencies": { "@element-plus/icons-vue": "^2.3.1", "axios": "^1.7.0", + "cronstrue": "^3.14.0", "dayjs": "^1.11.0", "echarts": "^5.5.0", "element-plus": "^2.7.0", @@ -1079,6 +1080,15 @@ "node": ">= 0.8" } }, + "node_modules/cronstrue": { + "version": "3.14.0", + "resolved": "https://registry.npmjs.org/cronstrue/-/cronstrue-3.14.0.tgz", + "integrity": "sha512-XnW4vuK/jPJjmTyDWiej1Zq36Od7ITwxaV2O1pzHZuyMVvdy7NAvyvIBzybt+idqSpfqYuoDG7uf/ocGtJVWxA==", + "license": "MIT", + "bin": { + "cronstrue": "bin/cli.js" + } + }, "node_modules/csstype": { "version": "3.2.3", "license": "MIT" diff --git a/frontend/package.json b/frontend/package.json index a2f761d..bdabd7e 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -9,14 +9,15 @@ "preview": "vite preview" }, "dependencies": { - "vue": "^3.4.0", - "vue-router": "^4.3.0", - "element-plus": "^2.7.0", "@element-plus/icons-vue": "^2.3.1", "axios": "^1.7.0", + "cronstrue": "^3.14.0", "dayjs": "^1.11.0", "echarts": "^5.5.0", - "vue-echarts": "^6.7.0" + "element-plus": "^2.7.0", + "vue": "^3.4.0", + "vue-echarts": "^6.7.0", + "vue-router": "^4.3.0" }, "devDependencies": { "@vitejs/plugin-vue": "^5.0.0", diff --git a/frontend/src/api/index.js b/frontend/src/api/index.js index 2fec992..048860d 100644 --- a/frontend/src/api/index.js +++ b/frontend/src/api/index.js @@ -42,9 +42,10 @@ export const listAgents = () => api.get('/agents') export const getAgent = (id) => api.get(`/agents/${id}`) export const createAgent = (data) => api.post('/agents', data) export const updateAgent = (id, data) => api.put(`/agents/${id}`, data) +export const deleteAgent = (id) => api.delete(`/agents/${id}`) // Tasks -export const listTasks = (agentId) => api.get('/tasks', { params: { agent_id: agentId } }) +export const listTasks = (params) => api.get('/tasks', { params }) export const getTask = (id) => api.get(`/tasks/${id}`) export const createTask = (agentId, data) => api.post('/tasks', data, { params: { agent_id: agentId } }) export const updateTask = (id, data) => api.put(`/tasks/${id}`, data) diff --git a/frontend/src/assets/style.css b/frontend/src/assets/style.css index a190f33..779366a 100644 --- a/frontend/src/assets/style.css +++ b/frontend/src/assets/style.css @@ -230,3 +230,33 @@ html, body, #app { border: 1px solid var(--border-color) !important; backdrop-filter: blur(12px) !important; } + +/* Agent Avatar */ +.agent-avatar { + display: inline-flex; + align-items: center; + justify-content: center; + width: 28px; + height: 28px; + border-radius: 50%; + color: #fff; + font-size: 12px; + font-weight: 600; + flex-shrink: 0; + vertical-align: middle; + margin-right: 6px; +} +.agent-avatar-sm { + display: inline-flex; + align-items: center; + justify-content: center; + width: 22px; + height: 22px; + border-radius: 50%; + color: #fff; + font-size: 10px; + font-weight: 600; + flex-shrink: 0; + vertical-align: middle; + margin-right: 4px; +} diff --git a/frontend/src/main.js b/frontend/src/main.js index 738f1fc..5fc764d 100644 --- a/frontend/src/main.js +++ b/frontend/src/main.js @@ -3,10 +3,15 @@ import ElementPlus from 'element-plus' import 'element-plus/dist/index.css' import * as ElementPlusIconsVue from '@element-plus/icons-vue' import zhCn from 'element-plus/es/locale/lang/zh-cn' +import dayjs from 'dayjs' +import utc from 'dayjs/plugin/utc' import App from './App.vue' import router from './router' import './assets/style.css' +// Extend dayjs with UTC plugin for timezone conversion +dayjs.extend(utc) + const app = createApp(App) // Register all Element Plus icons @@ -14,6 +19,118 @@ for (const [key, component] of Object.entries(ElementPlusIconsVue)) { app.component(key, component) } +// Global helper: cron expression to readable Chinese +app.config.globalProperties.$cron = (expr) => { + if (!expr) return '' + try { + const parts = expr.trim().split(/\s+/) + if (parts.length !== 5) return expr + const [min, hour, dom, month, dow] = parts + + // Helper: describe a cron field + const descField = (val, unit) => { + if (val === '*') return `每${unit}` + if (val.startsWith('*/')) return `每${val.slice(2)}${unit}` + if (val.includes(',')) { + const items = val.split(',').map(v => v.padStart(2, '0')).join('分、') + return `第 ${items}分` + } + if (val.includes('-')) { + const [from, to] = val.split('-') + return `${from.padStart(2,'0')}分到${to.padStart(2,'0')}分` + } + return `${val.padStart(2,'0')}${unit}` + } + + const allStar = (...args) => args.every(v => v === '*') + + // Every minute + if (min === '*' && hour === '*' && allStar(dom, month, dow)) return '每分钟' + + // Every N minutes (always) + if (min.startsWith('*/') && hour === '*' && allStar(dom, month, dow)) { + return `每${min.slice(2)}分钟` + } + + // Every N hours (at min 0) + if (min === '0' && hour.startsWith('*/') && allStar(dom, month, dow)) { + return `每${hour.slice(2)}小时` + } + + // Specific minutes every hour + if (min !== '*' && !min.startsWith('*/') && hour === '*' && allStar(dom, month, dow)) { + if (min.includes(',')) { + const items = min.split(',').map(v => v.padStart(2, '0')) + return `每小时 ${items.join('、')}分` + } + return `每小时 第${min.padStart(2,'0')}分钟` + } + + // Daily at specific time(s) + if (allStar(dom, month, dow)) { + if (min.startsWith('*/')) { + const interval = min.slice(2) + if (hour.includes('-')) { + const [hFrom, hTo] = hour.split('-').map(h => h.padStart(2,'0')) + return `每${interval}分钟(${hFrom}:00-${hTo}:00)` + } + if (hour !== '*') return `每${interval}分钟(${hour.padStart(2,'0')}点)` + return `每${interval}分钟` + } + if (hour.includes(',')) { + const times = hour.split(',').map(h => `${h.padStart(2,'0')}:${min.padStart(2,'0')}`) + return `每天 ${times.join('、')}` + } + if (hour.includes('-')) { + return `每天 ${hour.split('-')[0].padStart(2,'0')}:${min.padStart(2,'0')} 到 ${hour.split('-')[1].padStart(2,'0')}:${min.padStart(2,'0')}` + } + return `每天 ${hour.padStart(2,'0')}:${min.padStart(2,'0')}` + } + + // Weekly + const dowNames = ['日', '一', '二', '三', '四', '五', '六', '日'] + if (allStar(dom, month) && dow !== '*') { + if (dow.includes(',')) { + const days = dow.split(',').map(d => `周${dowNames[parseInt(d)]}`) + return `每${days.join('、')} ${hour.padStart(2,'0')}:${min.padStart(2,'0')}` + } + if (dow.includes('-')) { + const [from, to] = dow.split('-') + return `每周${dowNames[parseInt(from)]}到${dowNames[parseInt(to)]} ${hour.padStart(2,'0')}:${min.padStart(2,'0')}` + } + return `每周${dowNames[parseInt(dow)]} ${hour.padStart(2,'0')}:${min.padStart(2,'0')}` + } + + // Every N minutes within specific hours (e.g., */15 9-18 * * *) + if (min.startsWith('*/') && !allStar(dom, month, dow)) { + const interval = min.slice(2) + let hourDesc = '' + if (hour !== '*') hourDesc = descField(hour, '点') + return `每${interval}分钟${hourDesc ? ` (${hourDesc})` : ''}` + } + + return expr + } catch { + return expr + } +} + +// Global helper: agent name → avatar { letter, bg color } +const AVATAR_COLORS = [ + '#667eea', '#22c55e', '#f59e0b', '#ef4444', '#ec4899', + '#8b5cf6', '#06b6d4', '#f97316', '#14b8a6', '#a855f7', + '#6366f1', '#84cc16', '#e11d48', '#0ea5e9', +] +app.config.globalProperties.$avatar = (name) => { + if (!name) return { letter: '?', bg: AVATAR_COLORS[0] } + const letter = name.trim()[0] + // Deterministic color from name + let hash = 0 + for (let i = 0; i < name.length; i++) hash = ((hash << 5) - hash) + name.charCodeAt(i) + const idx = Math.abs(hash) % AVATAR_COLORS.length + return { letter, bg: AVATAR_COLORS[idx] } +} + app.use(ElementPlus, { locale: zhCn }) app.use(router) app.mount('#app') diff --git a/frontend/src/views/Agents.vue b/frontend/src/views/Agents.vue index 48715fa..2f20f8d 100644 --- a/frontend/src/views/Agents.vue +++ b/frontend/src/views/Agents.vue @@ -43,7 +43,10 @@ #{{ a.id }} - {{ a.name }} + + {{ $avatar(a.name).letter }} + {{ a.name }} + {{ a.description || '—' }} @@ -51,7 +54,7 @@ {{ a.task_count }} - {{ a.last_heartbeat_at ? dayjs(a.last_heartbeat_at).format('MM-DD HH:mm') : '—' }} + {{ a.last_heartbeat_at ? dayjs.utc(a.last_heartbeat_at).local().format('MM-DD HH:mm') : '—' }} {{ a.api_key.substring(0, 16) }}... + @@ -70,12 +75,65 @@ + + + + + + diff --git a/frontend/src/views/Alerts.vue b/frontend/src/views/Alerts.vue index 6f233ab..7853db6 100644 --- a/frontend/src/views/Alerts.vue +++ b/frontend/src/views/Alerts.vue @@ -20,7 +20,7 @@ {{ a.alert_type === 'missed_run' ? '未按时执行' : '执行失败' }} {{ a.task_name || '未知任务' }} - {{ dayjs(a.created_at).format('MM-DD HH:mm') }} + {{ dayjs.utc(a.created_at).local().format('MM-DD HH:mm') }}
{{ a.message }}
@@ -68,7 +68,7 @@ onMounted(loadData)