From 6f64eecb43f2f1861a396726eec481da86215ef7 Mon Sep 17 00:00:00 2001 From: Steven Date: Mon, 15 Jun 2026 03:29:07 +0800 Subject: [PATCH] fix: heartbeat 500 error, timezone to Beijing, tasks guide collapse - fix(api): heartbeat endpoint no longer requires request body, fix spa_fallback 404 handler causing 500 on API routes, add db.refresh() in agent update/heartbeat - feat(time): unify all frontend time display to Beijing time (Asia/Shanghai) via new beijing() utility; backend alert messages also use BJT - feat(ui): collapse AI agent instruction panel on Tasks page by default - chore: add backend/app/utils.py and frontend/src/utils/time.js --- AGENT_GUIDE.md | 203 +++++++++++++++++++----------- backend/app/api/agents.py | 2 +- backend/app/main.py | 3 +- backend/app/services/agent.py | 2 + backend/app/services/scheduler.py | 5 +- backend/app/utils.py | 29 +++++ frontend/src/utils/time.js | 25 ++++ frontend/src/views/Agents.vue | 4 +- frontend/src/views/Alerts.vue | 4 +- frontend/src/views/Dashboard.vue | 146 ++++++++++++++++----- frontend/src/views/TaskDetail.vue | 8 +- frontend/src/views/Tasks.vue | 144 +++++++++++++++++++-- 12 files changed, 445 insertions(+), 130 deletions(-) create mode 100644 backend/app/utils.py create mode 100644 frontend/src/utils/time.js diff --git a/AGENT_GUIDE.md b/AGENT_GUIDE.md index 8913d27..3f87976 100644 --- a/AGENT_GUIDE.md +++ b/AGENT_GUIDE.md @@ -1,126 +1,177 @@ -# Agent 接入指南 +# TaskPulse 接入指引 — 面向 AI Agent -本文档说明 AI Agent 如何接入 TaskPulse 系统。 +以下步骤面向 AI Agent。用户将此文档发送给你,请你按步骤完成 TaskPulse 系统的接入。 -## 一、注册 Agent +## 环境要求 -首先向 TaskPulse 注册你的 Agent,获取 API Key: +开始接入前,请确认你可以发起 HTTP 请求(curl 或 Python requests)。 + +--- + +## 第 1 步:注册你自己 + +向 TaskPulse 注册你的 Agent 身份。用你的实际名称替换 `<你的名字>`。 ```bash -curl -X POST http://localhost:8000/api/agents \ +# 注册 Agent +TASK_PULSE_URL="<用户提供的系统地址>" + +curl -X POST ${TASK_PULSE_URL}/api/agents \ -H "Content-Type: application/json" \ - -d '{"name": "my-agent", "description": "我的第一个 Agent"}' + -d '{"name": "<你的名字>", "description": "<你的职责描述,例如:数据同步 Agent>"}' ``` -返回示例: +**响应示例:** ```json { "id": 1, "name": "my-agent", "api_key": "tp_a1b2c3d4e5f6...", - "status": "active", - "task_count": 0, - ... + "status": "active" } ``` -**请妥善保存 `api_key`**,后续所有操作都需要通过这个 Key 鉴权。 +**请妥善保存 `api_key`**,后续所有请求都需要通过 `Authorization: Bearer ` 鉴权。 -## 二、注册定时任务 +## 第 2 步:注册定时任务 -Agent 注册成功后,注册它所管理的定时任务: +你需要在 TaskPulse 中注册你管理的每一个定时任务。任务注册后,系统会跟踪它们的执行情况。 + +### 方式一:逐一注册 ```bash -curl -X POST "http://localhost:8000/api/tasks?agent_id=1" \ +curl -X POST "${TASK_PULSE_URL}/api/tasks?agent_id=<第1步返回的agent_id>" \ -H "Content-Type: application/json" \ + -H "Authorization: Bearer <第1步返回的api_key>" \ -d '{ - "name": "data-sync", - "cron_expression": "0 */1 * * *", - "description": "每小时同步数据", + "name": "数据同步", + "cron_expression": "*/5 * * * *", + "description": "每5分钟同步一次数据", "grace_period": 300 }' ``` | 参数 | 说明 | |------|------| -| `name` | 任务名称 | -| `cron_expression` | Cron 表达式(分 时 日 月 周) | -| `grace_period` | 容忍窗口(秒),超过预定时间N秒未执行则触发告警 | +| `name` | 任务名称(必填) | +| `cron_expression` | Cron 表达式,格式:分 时 日 月 周(必填) | | `description` | 任务描述(可选) | +| `grace_period` | 容忍窗口(秒),超过预定时间 N 秒未执行则触发告警,默认 300 | -## 三、执行后汇报结果 +### 方式二:批量注册(推荐) -每次任务执行完成后,向 TaskPulse 汇报执行结果: +如果你有多个任务,可以一次性完成 Agent 注册 + 全部任务登记: ```bash -curl -X POST http://localhost:8000/api/tasks/1/executions \ +curl -X POST ${TASK_PULSE_URL}/api/agents/register-with-tasks \ -H "Content-Type: application/json" \ - -H "Authorization: Bearer tp_a1b2c3d4e5f6..." \ -d '{ - "status": "success", - "duration_ms": 1523, - "result": "{\"records_synced\": 100}", - "log": "[2025-01-01 10:00:00] Starting sync...\n[2025-01-01 10:00:01] Synced 100 records", - "error_message": null + "name": "<你的名字>", + "description": "<你的职责>", + "tasks": [ + {"name": "sync-data", "cron_expression": "*/5 * * * *"}, + {"name": "daily-report", "cron_expression": "0 9 * * *"}, + {"name": "weekly-summary", "cron_expression": "0 10 * * 1"} + ] }' ``` -| 参数 | 必填 | 说明 | -|------|------|------| -| `status` | 是 | `success` 或 `failed` | -| `finished_at` | 否 | 结束时间(ISO 格式),默认当前时间 | -| `duration_ms` | 否 | 执行耗时(毫秒) | -| `result` | 否 | 执行结果摘要(JSON 字符串) | -| `log` | 否 | 执行日志文本 | -| `error_message` | 否 | 错误信息(仅失败时填写) | +## 第 3 步:修改你的代码 — 每次执行后主动汇报 -## 四、Python SDK 示例 +**这是最关键的一步。** 如果只注册不汇报,看板上永远看不到运行状态。你需要修改自身的任务执行逻辑,在每次执行完毕后主动向 TaskPulse 汇报结果。 + +### 改造思路 + +在你的每一个定时任务执行函数的末尾,加上汇报逻辑。无论成功还是失败都要汇报。 ```python +import time import requests -BASE_URL = "http://localhost:8000" -API_KEY = "tp_a1b2c3d4e5f6..." +TASK_PULSE_URL = "<用户提供的系统地址>" +API_KEY = "<第1步获取的 api_key>" -headers = {"Authorization": f"Bearer {API_KEY}"} +def run_and_report(task_func, task_id): + """包裹你的任务函数,自动汇报执行结果""" + start = time.time() + try: + # 执行你的任务逻辑 + result = task_func() + elapsed_ms = int((time.time() - start) * 1000) -# 1. 注册 Agent -def register_agent(name: str, description: str = "") -> dict: - resp = requests.post(f"{BASE_URL}/api/agents", json={ - "name": name, "description": description - }) - resp.raise_for_status() - return resp.json() + # 汇报成功 + requests.post(f"{TASK_PULSE_URL}/api/tasks/{task_id}/executions", + headers={"Authorization": f"Bearer {API_KEY}"}, + json={ + "status": "success", + "duration_ms": elapsed_ms, + "log": str(result)[:2000] # 截断过长日志 + }) + except Exception as e: + elapsed_ms = int((time.time() - start) * 1000) -# 2. 注册定时任务 -def register_task(agent_id: int, name: str, cron: str) -> dict: - resp = requests.post(f"{BASE_URL}/api/tasks", params={"agent_id": agent_id}, json={ - "name": name, "cron_expression": cron - }) - resp.raise_for_status() - return resp.json() + # 汇报失败 + requests.post(f"{TASK_PULSE_URL}/api/tasks/{task_id}/executions", + headers={"Authorization": f"Bearer {API_KEY}"}, + json={ + "status": "failed", + "duration_ms": elapsed_ms, + "error_message": str(e)[:500] + }) -# 3. 汇报执行结果 -def report_execution(task_id: int, status: str, log: str = "", duration_ms: int = 0): - resp = requests.post( - f"{BASE_URL}/api/tasks/{task_id}/executions", - headers=headers, - json={ - "status": status, - "duration_ms": duration_ms, - "log": log, - "result": "{}", - } - ) - resp.raise_for_status() - return resp.json() - -# 使用示例 -agent = register_agent("data-agent", "数据同步 Agent") -task = register_task(agent["id"], "hourly-sync", "0 * * * *") -report_execution(task["id"], "success", log="Sync completed", duration_ms=2500) +# ── 在你现有的任务循环中使用 ── +# run_and_report(my_sync_function, task_id=1) +# run_and_report(my_report_function, task_id=2) ``` -## 五、查看看板 +### curl 方式(无代码修改能力时使用) -打开浏览器访问 `http://localhost:8000` 即可查看统一的 Dashboard。 +如果无法修改自身代码,可以在每次任务执行后手动调用: + +```bash +# 成功后调用 +curl -X POST ${TASK_PULSE_URL}/api/tasks//executions \ + -H "Content-Type: application/json" \ + -H "Authorization: Bearer " \ + -d '{"status":"success","duration_ms":2500,"log":"任务执行完毕"}' + +# 失败后调用 +curl -X POST ${TASK_PULSE_URL}/api/tasks//executions \ + -H "Content-Type: application/json" \ + -H "Authorization: Bearer " \ + -d '{"status":"failed","duration_ms":1200,"error_message":"连接超时"}' +``` + +## 第 4 步:保持活跃 + +定期发送心跳,让系统知道你仍在运行。建议在每次任务执行循环中都附带一次心跳。 + +```bash +curl -X POST ${TASK_PULSE_URL}/api/agents//heartbeat \ + -H "Authorization: Bearer " +``` + +## 第 5 步:查看看板 + +完成以上步骤后,用户可以在浏览器中打开 TaskPulse 看板页面,看到你的运行状态、任务列表和执行历史。 + +--- + +## 常见问题 + +**Q: 我如何知道自己注册了哪些任务?** +```bash +curl -X GET "${TASK_PULSE_URL}/api/tasks" \ + -H "Authorization: Bearer " +``` + +**Q: 如何修改任务的 cron 表达式?** +```bash +curl -X PUT "${TASK_PULSE_URL}/api/tasks/" \ + -H "Content-Type: application/json" \ + -H "Authorization: Bearer " \ + -d '{"cron_expression":"0 * * * *"}' +``` + +**Q: 汇报结果时 `log` 字段有限制吗?** +建议控制在 2000 字符以内,过长日志请自行截断。 diff --git a/backend/app/api/agents.py b/backend/app/api/agents.py index ac69b9d..05d0b88 100644 --- a/backend/app/api/agents.py +++ b/backend/app/api/agents.py @@ -50,7 +50,7 @@ async def update_agent(agent_id: int, body: AgentUpdate, db: AsyncSession = Depe @router.post("/{agent_id}/heartbeat", response_model=AgentOut) -async def heartbeat(agent_id: int, body: AgentHeartbeat, db: AsyncSession = Depends(get_db)): +async def heartbeat(agent_id: int, db: AsyncSession = Depends(get_db)): svc = AgentService(db) agent = await svc.heartbeat(agent_id) if not agent: diff --git a/backend/app/main.py b/backend/app/main.py index 1efdefd..b433261 100644 --- a/backend/app/main.py +++ b/backend/app/main.py @@ -74,7 +74,8 @@ if FRONTEND_DIST.is_dir(): 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 + from fastapi.responses import JSONResponse + return JSONResponse(status_code=404, content={"detail": "Not Found"}) logger.info("Frontend SPA mounted from %s", FRONTEND_DIST) else: diff --git a/backend/app/services/agent.py b/backend/app/services/agent.py index d2344f7..b99e4c4 100644 --- a/backend/app/services/agent.py +++ b/backend/app/services/agent.py @@ -38,6 +38,7 @@ class AgentService: if v is not None and hasattr(agent, k): setattr(agent, k, v) await self.db.flush() + await self.db.refresh(agent) return agent async def heartbeat(self, agent_id: int) -> Agent | None: @@ -46,6 +47,7 @@ class AgentService: if agent: agent.last_heartbeat_at = datetime.utcnow() await self.db.flush() + await self.db.refresh(agent) return agent async def get_task_count(self, agent_id: int) -> int: diff --git a/backend/app/services/scheduler.py b/backend/app/services/scheduler.py index 62902bb..31f535c 100644 --- a/backend/app/services/scheduler.py +++ b/backend/app/services/scheduler.py @@ -10,6 +10,7 @@ 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 +from app.utils import fmt_bj logger = logging.getLogger("taskpulse.scheduler") @@ -66,9 +67,9 @@ class SchedulerService: alert_type="missed_run", message=( f"任务 [{task.name}](ID={task.id}) 超过预定时间未执行。\n" - f"预期执行时间: {task.next_run_at}\n" + f"预期执行时间: {fmt_bj(task.next_run_at)}\n" f"容忍窗口: {task.grace_period}s\n" - f"当前时间: {now}" + f"当前时间: {fmt_bj(now)}" ), status="pending", ) diff --git a/backend/app/utils.py b/backend/app/utils.py new file mode 100644 index 0000000..e2bb4db --- /dev/null +++ b/backend/app/utils.py @@ -0,0 +1,29 @@ +"""Time utility — Beijing time (Asia/Shanghai, UTC+8).""" + +from datetime import datetime, timezone, timedelta, tzinfo + +# Beijing timezone (UTC+8, no DST) +_BJ_TZ = timezone(timedelta(hours=8)) + + +def now() -> datetime: + """Return current time in UTC (naive, for DB storage).""" + return datetime.utcnow() + + +def now_bj() -> datetime: + """Return current time as Beijing time (aware).""" + return datetime.now(_BJ_TZ) + + +def fmt_bj(dt: datetime | None) -> str: + """Format a naive datetime (assumed UTC) as Beijing time string. + + Returns e.g. '2026-06-15 10:30:00' + """ + if dt is None: + return "—" + # Assume naive dt is UTC + utc_dt = dt.replace(tzinfo=timezone.utc) + bj_dt = utc_dt.astimezone(_BJ_TZ) + return bj_dt.strftime("%Y-%m-%d %H:%M:%S") diff --git a/frontend/src/utils/time.js b/frontend/src/utils/time.js new file mode 100644 index 0000000..7228185 --- /dev/null +++ b/frontend/src/utils/time.js @@ -0,0 +1,25 @@ +/** + * 时间工具 — 北京时间 (Asia/Shanghai, UTC+8) + * + * 后端存储的日期时间均为 UTC(naive datetime), + * 前端统一转换为北京时间展示。 + */ +import dayjs from 'dayjs' +import utc from 'dayjs/plugin/utc' +import timezone from 'dayjs/plugin/timezone' + +dayjs.extend(utc) +dayjs.extend(timezone) + +const TZ = 'Asia/Shanghai' + +/** + * 将 UTC 时间戳格式化为北京时间字符串 + * @param {string|Date|null} ts ISO 时间字符串或 Date 对象 + * @param {string} fmt dayjs 格式模板,默认 'MM-DD HH:mm' + * @returns {string} 格式化后的北京时间 + */ +export function beijing(ts, fmt = 'MM-DD HH:mm') { + if (!ts) return '—' + return dayjs.utc(ts).tz(TZ).format(fmt) +} diff --git a/frontend/src/views/Agents.vue b/frontend/src/views/Agents.vue index 48715fa..8e0e859 100644 --- a/frontend/src/views/Agents.vue +++ b/frontend/src/views/Agents.vue @@ -51,7 +51,7 @@ {{ a.task_count }} - {{ a.last_heartbeat_at ? dayjs(a.last_heartbeat_at).format('MM-DD HH:mm') : '—' }} + {{ a.last_heartbeat_at ? beijing(a.last_heartbeat_at) : '—' }} {{ a.api_key.substring(0, 16) }}...