commit 521cf0269cc05c0b90b2cd03fabc6762fbba6803 Author: Steven Date: Sun Jun 14 22:39:23 2026 +0800 feat: initial TaskPulse release AI Agent 定时任务跟踪、监控、状态看板系统。 - FastAPI + Vue3 单体应用 - AI Agent 一键接入(批量注册 API) - 暗色主题 Dashboard - 后台调度器 + 超时告警 - 飞书/邮件/Webhook 多渠道通知 - Base URL 可配置 - Gunicorn 生产部署 diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..1d1f72a --- /dev/null +++ b/.gitignore @@ -0,0 +1,29 @@ +# Python +__pycache__/ +*.py[cod] +*.egg-info/ +.venv/ +venv/ + +# Node +node_modules/ +frontend/dist/ + +# IDE +.idea/ +.vscode/ +*.swp + +# OS +.DS_Store +Thumbs.db + +# Env +.env + +# Tooling +.codegraph/ +.reasonix/ +*.db +*.db-shm +*.db-wal diff --git a/AGENT_GUIDE.md b/AGENT_GUIDE.md new file mode 100644 index 0000000..8913d27 --- /dev/null +++ b/AGENT_GUIDE.md @@ -0,0 +1,126 @@ +# Agent 接入指南 + +本文档说明 AI Agent 如何接入 TaskPulse 系统。 + +## 一、注册 Agent + +首先向 TaskPulse 注册你的 Agent,获取 API Key: + +```bash +curl -X POST http://localhost:8000/api/agents \ + -H "Content-Type: application/json" \ + -d '{"name": "my-agent", "description": "我的第一个 Agent"}' +``` + +返回示例: +```json +{ + "id": 1, + "name": "my-agent", + "api_key": "tp_a1b2c3d4e5f6...", + "status": "active", + "task_count": 0, + ... +} +``` + +**请妥善保存 `api_key`**,后续所有操作都需要通过这个 Key 鉴权。 + +## 二、注册定时任务 + +Agent 注册成功后,注册它所管理的定时任务: + +```bash +curl -X POST "http://localhost:8000/api/tasks?agent_id=1" \ + -H "Content-Type: application/json" \ + -d '{ + "name": "data-sync", + "cron_expression": "0 */1 * * *", + "description": "每小时同步数据", + "grace_period": 300 + }' +``` + +| 参数 | 说明 | +|------|------| +| `name` | 任务名称 | +| `cron_expression` | Cron 表达式(分 时 日 月 周) | +| `grace_period` | 容忍窗口(秒),超过预定时间N秒未执行则触发告警 | +| `description` | 任务描述(可选) | + +## 三、执行后汇报结果 + +每次任务执行完成后,向 TaskPulse 汇报执行结果: + +```bash +curl -X POST http://localhost:8000/api/tasks/1/executions \ + -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 + }' +``` + +| 参数 | 必填 | 说明 | +|------|------|------| +| `status` | 是 | `success` 或 `failed` | +| `finished_at` | 否 | 结束时间(ISO 格式),默认当前时间 | +| `duration_ms` | 否 | 执行耗时(毫秒) | +| `result` | 否 | 执行结果摘要(JSON 字符串) | +| `log` | 否 | 执行日志文本 | +| `error_message` | 否 | 错误信息(仅失败时填写) | + +## 四、Python SDK 示例 + +```python +import requests + +BASE_URL = "http://localhost:8000" +API_KEY = "tp_a1b2c3d4e5f6..." + +headers = {"Authorization": f"Bearer {API_KEY}"} + +# 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() + +# 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() + +# 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) +``` + +## 五、查看看板 + +打开浏览器访问 `http://localhost:8000` 即可查看统一的 Dashboard。 diff --git a/README.md b/README.md new file mode 100644 index 0000000..f4ae4b6 --- /dev/null +++ b/README.md @@ -0,0 +1,193 @@ +# TaskPulse — AI Agent Task Monitor + +统一看板,监控 AI Agent 定时任务的运行状态、执行历史,并在任务超时未执行时发送告警通知。 + +**线上地址**: https://task.pags.cn + +--- + +## 核心功能 + +- **AI 原生接入** — AI Agent 通过 API 一次性注册自己 + 所有定时任务,无需手动填写表单 +- **统一状态看板** — 所有定时任务的运行状态、最近运行时间、结果一目了然 +- **执行汇报** — Agent 执行完任务后主动汇报结果,含详细日志 +- **超时告警** — 任务未按时执行时自动告警 +- **多渠道通知** — 支持飞书 Webhook、邮件、通用 Webhook +- **Base URL 可配置** — 系统设置页面可配置公网访问地址,所有接入指令自动更新 + +## 技术栈 + +| 层 | 技术 | +|---|---| +| 后端 | Python FastAPI (异步) + Gunicorn (生产) | +| 前端 | Vue3 + Element Plus (暗色主题) | +| 数据库 | MySQL 8.0 | +| 部署 | 单体应用(FastAPI serve Vue3 构建产物) | + +## 快速开始 + +### 本地开发 + +```bash +# 1. 克隆项目 +git clone https://gitea.tatta.cn/Tatta/TaskPulse.git +cd TaskPulse + +# 2. 后端 - 创建虚拟环境并安装依赖 +cd backend +python -m venv .venv +# Windows: .venv\Scripts\activate +# Linux: source .venv/bin/activate +pip install -r requirements.txt + +# 3. 复制环境变量并修改数据库配置 +cp .env.example .env +# 编辑 .env,配置 MySQL 连接信息 + +# 4. 启动后端开发服务器 +uvicorn app.main:app --reload --port 8000 + +# 5. 前端(新开终端) +cd frontend +npm install +npm run dev # 访问 http://localhost:3000 +``` + +> 前端 dev server 自动代理 `/api` 请求到 `localhost:8000` + +### 生产部署 + +```bash +# 1. 构建前端 +cd frontend +npm install && npm run build + +# 2. 部署到服务器 +# 将整个项目复制到服务器(排除 .venv 和 node_modules) +rsync -avz --exclude='.venv' --exclude='node_modules' --exclude='.git' \ + ./ user@server:/home/openclaw/app/taskpulse/ + +# 3. 在服务器上创建虚拟环境并安装依赖 +cd /home/openclaw/app/taskpulse/backend +python3 -m venv .venv +.venv/bin/pip install -r requirements.txt + +# 4. 使用 Gunicorn 启动 +.venv/bin/gunicorn app.main:app \ + -k uvicorn.workers.UvicornWorker \ + -b 0.0.0.0:8000 \ + -w 2 \ + --access-logfile /var/log/taskpulse/access.log \ + --error-logfile /var/log/taskpulse/error.log \ + --daemon +``` + +## 环境变量 + +复制 `backend/.env.example` 为 `backend/.env` 并按需修改: + +| 变量 | 默认值 | 说明 | +|------|--------|------| +| TASKPULSE_DB_HOST | localhost | MySQL 主机 | +| TASKPULSE_DB_PORT | 3306 | MySQL 端口 | +| TASKPULSE_DB_USER | dbuser | 数据库用户 | +| TASKPULSE_DB_PASSWORD | - | 数据库密码 | +| TASKPULSE_DB_NAME | taskpulse | 数据库名 | +| TASKPULSE_DEBUG | true | 调试模式(生产环境设为 false) | +| TASKPULSE_FEISHU_WEBHOOK_URL | (空) | 飞书 Webhook 地址 | +| TASKPULSE_SMTP_HOST | (空) | SMTP 服务器 | +| TASKPULSE_SMTP_PORT | 587 | SMTP 端口 | +| TASKPULSE_SMTP_USER | (空) | SMTP 用户 | +| TASKPULSE_SMTP_PASSWORD | (空) | SMTP 密码 | + +## Agent 接入 + +详细接入说明见 [AGENT_GUIDE.md](./AGENT_GUIDE.md)。 + +### 最小接入示例 + +```python +import requests + +BASE = "https://task.pags.cn" # 替换为你的实际地址 + +# 1. AI Agent 注册自己 + 所有定时任务(一次性) +resp = requests.post(f"{BASE}/api/agents/register-with-tasks", json={ + "name": "my-agent", + "description": "我的 AI Agent", + "tasks": [ + {"name": "sync-data", "cron_expression": "*/5 * * * *"}, + {"name": "daily-report", "cron_expression": "0 9 * * *"} + ] +}) +agent = resp.json() +API_KEY = agent["api_key"] # 保存此 Key +AGENT_ID = agent["agent"]["id"] + +# 2. 每次执行后汇报结果 +requests.post(f"{BASE}/api/tasks/{TASK_ID}/executions", + headers={"Authorization": f"Bearer {API_KEY}"}, + json={"status": "success", "duration_ms": 1200, "log": "done"}) +``` + +## API 概览 + +| 方法 | 路径 | 说明 | +|------|------|------| +| POST | /api/agents | 注册 Agent | +| POST | /api/agents/register-with-tasks | **AI Agent 一次性注册自己 + 所有任务** | +| GET | /api/agents | 列出所有 Agent | +| POST | /api/tasks?agent_id=X | 注册定时任务 | +| GET | /api/tasks | 列出所有任务 | +| DELETE | /api/tasks/{id} | 删除任务 | +| POST | /api/tasks/{id}/executions | 汇报执行结果 | +| GET | /api/tasks/{id}/executions | 查看执行历史 | +| GET | /api/dashboard/summary | 看板概览 | +| GET | /api/dashboard/tasks | 看板任务视图 | +| GET | /api/system/config | 读取系统配置(Base URL) | +| PUT | /api/system/config | 更新系统配置 | +| GET | /api/alerts | 告警列表 | +| POST | /api/alerts/{id}/acknowledge | 确认告警 | +| POST | /api/notification-channels | 添加通知渠道 | +| GET | /api/notification-channels | 列出通知渠道 | + +完整 API 文档见 `/docs`(Swagger UI)。 + +## 项目结构 + +``` +taskpulse/ +├── backend/ +│ ├── app/ +│ │ ├── main.py # FastAPI 入口 + SPA 静态文件服务 +│ │ ├── config.py # 环境变量配置 +│ │ ├── database.py # 异步数据库连接 +│ │ ├── models/ # SQLAlchemy 数据模型 +│ │ │ ├── agent.py # Agent(含 API Key 自动生成) +│ │ │ ├── task.py # 定时任务(cron、宽容窗口) +│ │ │ ├── execution.py # 执行记录(日志、结果、耗时) +│ │ │ ├── notification.py # 通知渠道、告警规则、告警历史 +│ │ │ └── system_config.py # 系统配置(key-value) +│ │ ├── schemas/ # Pydantic 数据校验 +│ │ ├── api/ # API 路由 +│ │ │ ├── agents.py # Agent CRUD + 批量注册 +│ │ │ ├── tasks.py # 任务 CRUD +│ │ │ ├── executions.py # 执行汇报 + 历史查询 +│ │ │ ├── notifications.py # 通知渠道 + 告警规则 + 告警确认 +│ │ │ ├── dashboard.py # 看板概览统计 +│ │ │ └── system.py # 系统配置 +│ │ └── services/ # 业务逻辑 +│ │ ├── agent.py / task.py / execution.py +│ │ ├── scheduler.py # 后台扫描:检测超时任务→生成告警→通知 +│ │ └── notification.py # 飞书 / 邮件 / Webhook 通知发送 +│ ├── alembic/ # 数据库迁移 +│ └── requirements.txt +├── frontend/ +│ ├── src/ +│ │ ├── views/ # 页面组件(暗色主题) +│ │ ├── router/ # 路由 +│ │ └── api/ # Axios API 客户端 +│ └── package.json +├── AGENT_GUIDE.md # Agent 接入指南 +└── README.md +``` diff --git a/backend/.env.example b/backend/.env.example new file mode 100644 index 0000000..4ec6f90 --- /dev/null +++ b/backend/.env.example @@ -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 diff --git a/backend/Dockerfile b/backend/Dockerfile new file mode 100644 index 0000000..8b19ec9 --- /dev/null +++ b/backend/Dockerfile @@ -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"] diff --git a/backend/alembic.ini b/backend/alembic.ini new file mode 100644 index 0000000..3e1703c --- /dev/null +++ b/backend/alembic.ini @@ -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 diff --git a/backend/alembic/env.py b/backend/alembic/env.py new file mode 100644 index 0000000..ee762aa --- /dev/null +++ b/backend/alembic/env.py @@ -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() diff --git a/backend/alembic/versions/0001_initial.py b/backend/alembic/versions/0001_initial.py new file mode 100644 index 0000000..5ef121f --- /dev/null +++ b/backend/alembic/versions/0001_initial.py @@ -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") diff --git a/backend/app/__init__.py b/backend/app/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/backend/app/api/__init__.py b/backend/app/api/__init__.py new file mode 100644 index 0000000..891d8c8 --- /dev/null +++ b/backend/app/api/__init__.py @@ -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 diff --git a/backend/app/api/agents.py b/backend/app/api/agents.py new file mode 100644 index 0000000..ac69b9d --- /dev/null +++ b/backend/app/api/agents.py @@ -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, + ) diff --git a/backend/app/api/dashboard.py b/backend/app/api/dashboard.py new file mode 100644 index 0000000..d659706 --- /dev/null +++ b/backend/app/api/dashboard.py @@ -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 diff --git a/backend/app/api/executions.py b/backend/app/api/executions.py new file mode 100644 index 0000000..78d808c --- /dev/null +++ b/backend/app/api/executions.py @@ -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__) diff --git a/backend/app/api/notifications.py b/backend/app/api/notifications.py new file mode 100644 index 0000000..2724f02 --- /dev/null +++ b/backend/app/api/notifications.py @@ -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}) diff --git a/backend/app/api/system.py b/backend/app/api/system.py new file mode 100644 index 0000000..4c53c50 --- /dev/null +++ b/backend/app/api/system.py @@ -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, + ) diff --git a/backend/app/api/tasks.py b/backend/app/api/tasks.py new file mode 100644 index 0000000..4e6debc --- /dev/null +++ b/backend/app/api/tasks.py @@ -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") diff --git a/backend/app/config.py b/backend/app/config.py new file mode 100644 index 0000000..e89d9a5 --- /dev/null +++ b/backend/app/config.py @@ -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() diff --git a/backend/app/database.py b/backend/app/database.py new file mode 100644 index 0000000..5c6317c --- /dev/null +++ b/backend/app/database.py @@ -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 diff --git a/backend/app/main.py b/backend/app/main.py new file mode 100644 index 0000000..f5db911 --- /dev/null +++ b/backend/app/main.py @@ -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"} diff --git a/backend/app/models/__init__.py b/backend/app/models/__init__.py new file mode 100644 index 0000000..a2901eb --- /dev/null +++ b/backend/app/models/__init__.py @@ -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"] diff --git a/backend/app/models/agent.py b/backend/app/models/agent.py new file mode 100644 index 0000000..00ab2b3 --- /dev/null +++ b/backend/app/models/agent.py @@ -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"" diff --git a/backend/app/models/execution.py b/backend/app/models/execution.py new file mode 100644 index 0000000..fd7f9f2 --- /dev/null +++ b/backend/app/models/execution.py @@ -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"" diff --git a/backend/app/models/notification.py b/backend/app/models/notification.py new file mode 100644 index 0000000..ac91030 --- /dev/null +++ b/backend/app/models/notification.py @@ -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"" + + +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"" + + +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"" diff --git a/backend/app/models/system_config.py b/backend/app/models/system_config.py new file mode 100644 index 0000000..96b74c0 --- /dev/null +++ b/backend/app/models/system_config.py @@ -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"" diff --git a/backend/app/models/task.py b/backend/app/models/task.py new file mode 100644 index 0000000..a07e868 --- /dev/null +++ b/backend/app/models/task.py @@ -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"" diff --git a/backend/app/schemas/__init__.py b/backend/app/schemas/__init__.py new file mode 100644 index 0000000..e31102e --- /dev/null +++ b/backend/app/schemas/__init__.py @@ -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", +] diff --git a/backend/app/schemas/agent.py b/backend/app/schemas/agent.py new file mode 100644 index 0000000..175e472 --- /dev/null +++ b/backend/app/schemas/agent.py @@ -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 diff --git a/backend/app/schemas/execution.py b/backend/app/schemas/execution.py new file mode 100644 index 0000000..8d6972e --- /dev/null +++ b/backend/app/schemas/execution.py @@ -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} diff --git a/backend/app/schemas/notification.py b/backend/app/schemas/notification.py new file mode 100644 index 0000000..f5d57e6 --- /dev/null +++ b/backend/app/schemas/notification.py @@ -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 diff --git a/backend/app/schemas/task.py b/backend/app/schemas/task.py new file mode 100644 index 0000000..751b147 --- /dev/null +++ b/backend/app/schemas/task.py @@ -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} diff --git a/backend/app/services/__init__.py b/backend/app/services/__init__.py new file mode 100644 index 0000000..5c9de45 --- /dev/null +++ b/backend/app/services/__init__.py @@ -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", +] diff --git a/backend/app/services/agent.py b/backend/app/services/agent.py new file mode 100644 index 0000000..d2344f7 --- /dev/null +++ b/backend/app/services/agent.py @@ -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 diff --git a/backend/app/services/execution.py b/backend/app/services/execution.py new file mode 100644 index 0000000..17ab6da --- /dev/null +++ b/backend/app/services/execution.py @@ -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()) diff --git a/backend/app/services/notification.py b/backend/app/services/notification.py new file mode 100644 index 0000000..38ae7a6 --- /dev/null +++ b/backend/app/services/notification.py @@ -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) diff --git a/backend/app/services/scheduler.py b/backend/app/services/scheduler.py new file mode 100644 index 0000000..62902bb --- /dev/null +++ b/backend/app/services/scheduler.py @@ -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) diff --git a/backend/app/services/task.py b/backend/app/services/task.py new file mode 100644 index 0000000..679cc95 --- /dev/null +++ b/backend/app/services/task.py @@ -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 diff --git a/backend/requirements.txt b/backend/requirements.txt new file mode 100644 index 0000000..c164373 --- /dev/null +++ b/backend/requirements.txt @@ -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 diff --git a/docker-compose.yml b/docker-compose.yml new file mode 100644 index 0000000..20cc21d --- /dev/null +++ b/docker-compose.yml @@ -0,0 +1,18 @@ +version: "3.8" + +services: + backend: + build: + context: ./backend + dockerfile: Dockerfile + container_name: taskpulse-backend + restart: unless-stopped + ports: + - "8000:8000" + environment: + TASKPULSE_DB_HOST: als-01.tatta.cn + TASKPULSE_DB_PORT: 8036 + TASKPULSE_DB_USER: dbuser + TASKPULSE_DB_PASSWORD: Tata@1234 + TASKPULSE_DB_NAME: taskpulse + TASKPULSE_DEBUG: "false" diff --git a/frontend/index.html b/frontend/index.html new file mode 100644 index 0000000..f602e0d --- /dev/null +++ b/frontend/index.html @@ -0,0 +1,12 @@ + + + + + + TaskPulse — AI Agent Task Monitor + + +
+ + + diff --git a/frontend/package-lock.json b/frontend/package-lock.json new file mode 100644 index 0000000..c96b6ed --- /dev/null +++ b/frontend/package-lock.json @@ -0,0 +1,1705 @@ +{ + "name": "taskpulse-frontend", + "version": "1.0.0", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "taskpulse-frontend", + "version": "1.0.0", + "dependencies": { + "@element-plus/icons-vue": "^2.3.1", + "axios": "^1.7.0", + "dayjs": "^1.11.0", + "echarts": "^5.5.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", + "vite": "^5.4.0" + } + }, + "node_modules/@babel/helper-string-parser": { + "version": "7.29.7", + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-validator-identifier": { + "version": "7.29.7", + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/parser": { + "version": "7.29.7", + "license": "MIT", + "dependencies": { + "@babel/types": "^7.29.7" + }, + "bin": { + "parser": "bin/babel-parser.js" + }, + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/@babel/types": { + "version": "7.29.7", + "license": "MIT", + "dependencies": { + "@babel/helper-string-parser": "^7.29.7", + "@babel/helper-validator-identifier": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@ctrl/tinycolor": { + "version": "4.2.0", + "license": "MIT", + "engines": { + "node": ">=14" + } + }, + "node_modules/@element-plus/icons-vue": { + "version": "2.3.2", + "license": "MIT", + "peerDependencies": { + "vue": "^3.2.0" + } + }, + "node_modules/@esbuild/aix-ppc64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.21.5.tgz", + "integrity": "sha512-1SDgH6ZSPTlggy1yI6+Dbkiz8xzpHJEVAlF/AM1tHPLsf5STom9rwtjE4hKAF20FfXXNTFqEYXyJNWh1GiZedQ==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "aix" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/android-arm": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.21.5.tgz", + "integrity": "sha512-vCPvzSjpPHEi1siZdlvAlsPxXl7WbOVUBBAowWug4rJHb68Ox8KualB+1ocNvT5fjv6wpkX6o/iEpbDrf68zcg==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/android-arm64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.21.5.tgz", + "integrity": "sha512-c0uX9VAUBQ7dTDCjq+wdyGLowMdtR/GoC2U5IYk/7D1H1JYC0qseD7+11iMP2mRLN9RcCMRcjC4YMclCzGwS/A==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/android-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.21.5.tgz", + "integrity": "sha512-D7aPRUUNHRBwHxzxRvp856rjUHRFW1SdQATKXH2hqA0kAZb1hKmi02OpYRacl0TxIGz/ZmXWlbZgjwWYaCakTA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/darwin-arm64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.21.5.tgz", + "integrity": "sha512-DwqXqZyuk5AiWWf3UfLiRDJ5EDd49zg6O9wclZ7kUMv2WRFr4HKjXp/5t8JZ11QbQfUS6/cRCKGwYhtNAY88kQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/darwin-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.21.5.tgz", + "integrity": "sha512-se/JjF8NlmKVG4kNIuyWMV/22ZaerB+qaSi5MdrXtd6R08kvs2qCN4C09miupktDitvh8jRFflwGFBQcxZRjbw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/freebsd-arm64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.21.5.tgz", + "integrity": "sha512-5JcRxxRDUJLX8JXp/wcBCy3pENnCgBR9bN6JsY4OmhfUtIHe3ZW0mawA7+RDAcMLrMIZaf03NlQiX9DGyB8h4g==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/freebsd-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.21.5.tgz", + "integrity": "sha512-J95kNBj1zkbMXtHVH29bBriQygMXqoVQOQYA+ISs0/2l3T9/kj42ow2mpqerRBxDJnmkUDCaQT/dfNXWX/ZZCQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-arm": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.21.5.tgz", + "integrity": "sha512-bPb5AHZtbeNGjCKVZ9UGqGwo8EUu4cLq68E95A53KlxAPRmUyYv2D6F0uUI65XisGOL1hBP5mTronbgo+0bFcA==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-arm64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.21.5.tgz", + "integrity": "sha512-ibKvmyYzKsBeX8d8I7MH/TMfWDXBF3db4qM6sy+7re0YXya+K1cem3on9XgdT2EQGMu4hQyZhan7TeQ8XkGp4Q==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-ia32": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.21.5.tgz", + "integrity": "sha512-YvjXDqLRqPDl2dvRODYmmhz4rPeVKYvppfGYKSNGdyZkA01046pLWyRKKI3ax8fbJoK5QbxblURkwK/MWY18Tg==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-loong64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.21.5.tgz", + "integrity": "sha512-uHf1BmMG8qEvzdrzAqg2SIG/02+4/DHB6a9Kbya0XDvwDEKCoC8ZRWI5JJvNdUjtciBGFQ5PuBlpEOXQj+JQSg==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-mips64el": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.21.5.tgz", + "integrity": "sha512-IajOmO+KJK23bj52dFSNCMsz1QP1DqM6cwLUv3W1QwyxkyIWecfafnI555fvSGqEKwjMXVLokcV5ygHW5b3Jbg==", + "cpu": [ + "mips64el" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-ppc64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.21.5.tgz", + "integrity": "sha512-1hHV/Z4OEfMwpLO8rp7CvlhBDnjsC3CttJXIhBi+5Aj5r+MBvy4egg7wCbe//hSsT+RvDAG7s81tAvpL2XAE4w==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-riscv64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.21.5.tgz", + "integrity": "sha512-2HdXDMd9GMgTGrPWnJzP2ALSokE/0O5HhTUvWIbD3YdjME8JwvSCnNGBnTThKGEB91OZhzrJ4qIIxk/SBmyDDA==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-s390x": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.21.5.tgz", + "integrity": "sha512-zus5sxzqBJD3eXxwvjN1yQkRepANgxE9lgOW2qLnmr8ikMTphkjgXu1HR01K4FJg8h1kEEDAqDcZQtbrRnB41A==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.21.5.tgz", + "integrity": "sha512-1rYdTpyv03iycF1+BhzrzQJCdOuAOtaqHTWJZCWvijKD2N5Xu0TtVC8/+1faWqcP9iBCWOmjmhoH94dH82BxPQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/netbsd-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.21.5.tgz", + "integrity": "sha512-Woi2MXzXjMULccIwMnLciyZH4nCIMpWQAs049KEeMvOcNADVxo0UBIQPfSmxB3CWKedngg7sWZdLvLczpe0tLg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/openbsd-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.21.5.tgz", + "integrity": "sha512-HLNNw99xsvx12lFBUwoT8EVCsSvRNDVxNpjZ7bPn947b8gJPzeHWyNVhFsaerc0n3TsbOINvRP2byTZ5LKezow==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/sunos-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.21.5.tgz", + "integrity": "sha512-6+gjmFpfy0BHU5Tpptkuh8+uw3mnrvgs+dSPQXQOv3ekbordwnzTVEb4qnIvQcYXq6gzkyTnoZ9dZG+D4garKg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "sunos" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/win32-arm64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.21.5.tgz", + "integrity": "sha512-Z0gOTd75VvXqyq7nsl93zwahcTROgqvuAcYDUr+vOv8uHhNSKROyU961kgtCD1e95IqPKSQKH7tBTslnS3tA8A==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/win32-ia32": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.21.5.tgz", + "integrity": "sha512-SWXFF1CL2RVNMaVs+BBClwtfZSvDgtL//G/smwAc5oVK/UPu2Gu9tIaRgFmYFFKrmg3SyAjSrElf0TiJ1v8fYA==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/win32-x64": { + "version": "0.21.5", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@floating-ui/core": { + "version": "1.7.5", + "license": "MIT", + "dependencies": { + "@floating-ui/utils": "^0.2.11" + } + }, + "node_modules/@floating-ui/dom": { + "version": "1.7.6", + "license": "MIT", + "dependencies": { + "@floating-ui/core": "^1.7.5", + "@floating-ui/utils": "^0.2.11" + } + }, + "node_modules/@floating-ui/utils": { + "version": "0.2.11", + "license": "MIT" + }, + "node_modules/@jridgewell/sourcemap-codec": { + "version": "1.5.5", + "license": "MIT" + }, + "node_modules/@popperjs/core": { + "name": "@sxzz/popperjs-es", + "version": "2.11.8", + "license": "MIT", + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/popperjs" + } + }, + "node_modules/@rollup/rollup-android-arm-eabi": { + "version": "4.62.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.62.0.tgz", + "integrity": "sha512-IPIQ55ythEHkfEd9jMEi32OQ7SxURsGA43JI22lj01OLZNt2NUbJX8YUHxkVWyQ6daHPNn0truF5nSj3DQp6YQ==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ] + }, + "node_modules/@rollup/rollup-android-arm64": { + "version": "4.62.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm64/-/rollup-android-arm64-4.62.0.tgz", + "integrity": "sha512-M6s9cr10MibETyo8JsOkq+Lo1+lU6hcvb1MApnUql5qte/5hMEgzlN8/ReIKNfRV8rrqX50W1BX9zoUhC192RA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ] + }, + "node_modules/@rollup/rollup-darwin-arm64": { + "version": "4.62.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-arm64/-/rollup-darwin-arm64-4.62.0.tgz", + "integrity": "sha512-BqCoMoIbn0keKys+dEAdBa70EtOwV1bEsQCUgU9FdiZmmMge/Zk7LlkYGqbrdHR+Frnt0E1FOanly+rlwvvQzw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@rollup/rollup-darwin-x64": { + "version": "4.62.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-x64/-/rollup-darwin-x64-4.62.0.tgz", + "integrity": "sha512-SIMzST3VFNXDAbeIWDWiFCNM5qncUBDWaEV7NfE7oZbDt2mgfW4MvbKdbYiGOLoM32gbTv608UMd0XktEYSD7w==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@rollup/rollup-freebsd-arm64": { + "version": "4.62.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-arm64/-/rollup-freebsd-arm64-4.62.0.tgz", + "integrity": "sha512-ezjfSQMP7ArdUsbBwbQIfwAlhE84I2iVnzQNCFSveqV42q+BmKlzVpf7mxv5EchLcoWU4y6/heFzVg1F+hodUQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ] + }, + "node_modules/@rollup/rollup-freebsd-x64": { + "version": "4.62.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-x64/-/rollup-freebsd-x64-4.62.0.tgz", + "integrity": "sha512-9+qTWGW9AZRhnUgwtTwzNwcPlL87ngkeN0LA+q1bADvmY9aNvWaF2TFW8BZgnQPYxpDI7+rMVLivcd4V737TAQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ] + }, + "node_modules/@rollup/rollup-linux-arm-gnueabihf": { + "version": "4.62.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-gnueabihf/-/rollup-linux-arm-gnueabihf-4.62.0.tgz", + "integrity": "sha512-T1dMEQhXA/jkJ/jyMIw9IovK8bSUq7A8kLIlvZTb/6YIVsp2zLavr4F3oyllHWo7eIVJRyE5n3tUjQJEbE1IuQ==", + "cpu": [ + "arm" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm-musleabihf": { + "version": "4.62.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-musleabihf/-/rollup-linux-arm-musleabihf-4.62.0.tgz", + "integrity": "sha512-2as0LgT7qQpyceQq6VUJYnumUMUrgGQCWIiDIN9DE0/tglsk6o66uCB4f3djRawAltvfCNLyZZrsqbPA6inCsA==", + "cpu": [ + "arm" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm64-gnu": { + "version": "4.62.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-gnu/-/rollup-linux-arm64-gnu-4.62.0.tgz", + "integrity": "sha512-bVURMg+6eNN9C/yc0aVjooZcwTTtYF4YW3xta5pP0//r3o1V8gXEHXWCndj47w/HhwsFroZrFhR+6uQP5T0n0g==", + "cpu": [ + "arm64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm64-musl": { + "version": "4.62.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-musl/-/rollup-linux-arm64-musl-4.62.0.tgz", + "integrity": "sha512-Ful8pM/2yYI83PViWdFdpZhdI8HJ5qsXANe5atypbHDf+KIBBDsZsbyy8hbXnULVvW9NsTh5DHwbcBftyLTfiw==", + "cpu": [ + "arm64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-loong64-gnu": { + "version": "4.62.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-gnu/-/rollup-linux-loong64-gnu-4.62.0.tgz", + "integrity": "sha512-9Gp/DgrkzfUBmNPVTyPTvay+4xEP7M/clXpj3efXBcm6uTIVIgDg4rqUpqKXvLEuFRVuEpSAOkhgNeecvaZ4Cg==", + "cpu": [ + "loong64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-loong64-musl": { + "version": "4.62.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-musl/-/rollup-linux-loong64-musl-4.62.0.tgz", + "integrity": "sha512-m9tsJz54LUXkSYM8+8PG81B9IKK5r+2T0clMq4QrS16xFosufU7firBDAZEsDheDs7wTlP7h3++S7lMsU955HA==", + "cpu": [ + "loong64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-ppc64-gnu": { + "version": "4.62.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-gnu/-/rollup-linux-ppc64-gnu-4.62.0.tgz", + "integrity": "sha512-3UvJ5PNVU16aJf6M3tFI24pWzAl2/ynfbyRN3ICyQajK1lSkrnVYNnLz3v04J32qKa0FczJc22zeToc0lr2A3w==", + "cpu": [ + "ppc64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-ppc64-musl": { + "version": "4.62.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-musl/-/rollup-linux-ppc64-musl-4.62.0.tgz", + "integrity": "sha512-vRWUAbYLGHBZS6Q8Msb2sfnf1fvJf+47t8l/TwOerM2qArzy+IeNMTHrYLHXh95h8MoatPHI5hhSZNs+mGXKPg==", + "cpu": [ + "ppc64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-riscv64-gnu": { + "version": "4.62.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-gnu/-/rollup-linux-riscv64-gnu-4.62.0.tgz", + "integrity": "sha512-c00T5SYENHAt86cfW47URaP3Us5vLC/4QO7GYud1G5VNRffCwwCuBspwqYrriuJB+5m0WFzClCn9wed0FBjKvg==", + "cpu": [ + "riscv64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-riscv64-musl": { + "version": "4.62.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-musl/-/rollup-linux-riscv64-musl-4.62.0.tgz", + "integrity": "sha512-krrCDilhXOwFkSkO3Wm9I/f9H0L92XHHwy2fwxjukxIbh0dem8gZqOW5Y8BsHrpJv5qwlRBV+Wl4ZFyRWhUpwg==", + "cpu": [ + "riscv64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-s390x-gnu": { + "version": "4.62.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-s390x-gnu/-/rollup-linux-s390x-gnu-4.62.0.tgz", + "integrity": "sha512-7pfYFSTc4/rUC/FtAI0Qp6QthDBCIi6/AuP1xYqFk5vanI6KnL5dWKP60OM/05LOsbwTmIcvr6eXC4CJuJ75IA==", + "cpu": [ + "s390x" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-x64-gnu": { + "version": "4.62.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-gnu/-/rollup-linux-x64-gnu-4.62.0.tgz", + "integrity": "sha512-7SDIalKeIpG0Ifogbbdn58HmSotYMlf23K3dCJEmiVd9Fg36Vmni82iPQec27N3wY4Bvbxftkxz6vSx9OcouTg==", + "cpu": [ + "x64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-x64-musl": { + "version": "4.62.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-musl/-/rollup-linux-x64-musl-4.62.0.tgz", + "integrity": "sha512-eRZevouTH2i1HeAVLqJuLnt256krQkGY0TN6WsTmsIhuzbh457HuWDMakKwmi0Cjadux983CoSr8Lim2QhUIFw==", + "cpu": [ + "x64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-openbsd-x64": { + "version": "4.62.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-openbsd-x64/-/rollup-openbsd-x64-4.62.0.tgz", + "integrity": "sha512-3oVS7FLGa4U1qcvao9ylGxrjXZyUQqR8UwxEcnUEyPX53O/C/mKDZegNXTdHCP+h3e6ta/f1EN38Yif1mmZHYg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ] + }, + "node_modules/@rollup/rollup-openharmony-arm64": { + "version": "4.62.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-openharmony-arm64/-/rollup-openharmony-arm64-4.62.0.tgz", + "integrity": "sha512-yTB9TgfWj5wHe5QgktAgXTLLot1gvEjl1NiPPAUiCs4oPrIWFl5V4nC3GrkNdj9LaAU4s94nVrGbGOCqUpyWsg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ] + }, + "node_modules/@rollup/rollup-win32-arm64-msvc": { + "version": "4.62.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-arm64-msvc/-/rollup-win32-arm64-msvc-4.62.0.tgz", + "integrity": "sha512-5LOhoaesY3doG1c+ac/2JtgREpKoJr5bUHH8tKY0V8di7+uSV6BwLs2PlR0/yzefGOkR+wE7ZolZphHCsyG5Rw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-ia32-msvc": { + "version": "4.62.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-ia32-msvc/-/rollup-win32-ia32-msvc-4.62.0.tgz", + "integrity": "sha512-yYkWHhmbhRTWTnWos5HC4GcPQfjlzzCNbM9e/+GXrLuaBXYA3qSDR9f0Vgufd5S8yX81U8jPKp7ZnAjZFMtRnw==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-x64-gnu": { + "version": "4.62.0", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-x64-msvc": { + "version": "4.62.0", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@types/estree": { + "version": "1.0.9", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/lodash": { + "version": "4.17.24", + "license": "MIT" + }, + "node_modules/@types/lodash-es": { + "version": "4.17.12", + "license": "MIT", + "dependencies": { + "@types/lodash": "*" + } + }, + "node_modules/@types/web-bluetooth": { + "version": "0.0.21", + "license": "MIT" + }, + "node_modules/@vitejs/plugin-vue": { + "version": "5.2.4", + "dev": true, + "license": "MIT", + "engines": { + "node": "^18.0.0 || >=20.0.0" + }, + "peerDependencies": { + "vite": "^5.0.0 || ^6.0.0", + "vue": "^3.2.25" + } + }, + "node_modules/@vue/compiler-core": { + "version": "3.5.38", + "license": "MIT", + "dependencies": { + "@babel/parser": "^7.29.7", + "@vue/shared": "3.5.38", + "entities": "^7.0.1", + "estree-walker": "^2.0.2", + "source-map-js": "^1.2.1" + } + }, + "node_modules/@vue/compiler-dom": { + "version": "3.5.38", + "license": "MIT", + "dependencies": { + "@vue/compiler-core": "3.5.38", + "@vue/shared": "3.5.38" + } + }, + "node_modules/@vue/compiler-sfc": { + "version": "3.5.38", + "license": "MIT", + "dependencies": { + "@babel/parser": "^7.29.7", + "@vue/compiler-core": "3.5.38", + "@vue/compiler-dom": "3.5.38", + "@vue/compiler-ssr": "3.5.38", + "@vue/shared": "3.5.38", + "estree-walker": "^2.0.2", + "magic-string": "^0.30.21", + "postcss": "^8.5.15", + "source-map-js": "^1.2.1" + } + }, + "node_modules/@vue/compiler-ssr": { + "version": "3.5.38", + "license": "MIT", + "dependencies": { + "@vue/compiler-dom": "3.5.38", + "@vue/shared": "3.5.38" + } + }, + "node_modules/@vue/devtools-api": { + "version": "6.6.4", + "license": "MIT" + }, + "node_modules/@vue/reactivity": { + "version": "3.5.38", + "license": "MIT", + "dependencies": { + "@vue/shared": "3.5.38" + } + }, + "node_modules/@vue/runtime-core": { + "version": "3.5.38", + "license": "MIT", + "dependencies": { + "@vue/reactivity": "3.5.38", + "@vue/shared": "3.5.38" + } + }, + "node_modules/@vue/runtime-dom": { + "version": "3.5.38", + "license": "MIT", + "dependencies": { + "@vue/reactivity": "3.5.38", + "@vue/runtime-core": "3.5.38", + "@vue/shared": "3.5.38", + "csstype": "^3.2.3" + } + }, + "node_modules/@vue/server-renderer": { + "version": "3.5.38", + "license": "MIT", + "dependencies": { + "@vue/compiler-ssr": "3.5.38", + "@vue/shared": "3.5.38" + }, + "peerDependencies": { + "vue": "3.5.38" + } + }, + "node_modules/@vue/shared": { + "version": "3.5.38", + "license": "MIT" + }, + "node_modules/@vueuse/core": { + "version": "14.3.0", + "license": "MIT", + "dependencies": { + "@types/web-bluetooth": "^0.0.21", + "@vueuse/metadata": "14.3.0", + "@vueuse/shared": "14.3.0" + }, + "funding": { + "url": "https://github.com/sponsors/antfu" + }, + "peerDependencies": { + "vue": "^3.5.0" + } + }, + "node_modules/@vueuse/metadata": { + "version": "14.3.0", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/antfu" + } + }, + "node_modules/@vueuse/shared": { + "version": "14.3.0", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/antfu" + }, + "peerDependencies": { + "vue": "^3.5.0" + } + }, + "node_modules/agent-base": { + "version": "6.0.2", + "license": "MIT", + "dependencies": { + "debug": "4" + }, + "engines": { + "node": ">= 6.0.0" + } + }, + "node_modules/async-validator": { + "version": "4.2.5", + "license": "MIT" + }, + "node_modules/asynckit": { + "version": "0.4.0", + "license": "MIT" + }, + "node_modules/axios": { + "version": "1.17.0", + "license": "MIT", + "dependencies": { + "follow-redirects": "^1.16.0", + "form-data": "^4.0.5", + "https-proxy-agent": "^5.0.1", + "proxy-from-env": "^2.1.0" + } + }, + "node_modules/call-bind-apply-helpers": { + "version": "1.0.2", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "function-bind": "^1.1.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/combined-stream": { + "version": "1.0.8", + "license": "MIT", + "dependencies": { + "delayed-stream": "~1.0.0" + }, + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/csstype": { + "version": "3.2.3", + "license": "MIT" + }, + "node_modules/dayjs": { + "version": "1.11.21", + "license": "MIT" + }, + "node_modules/debug": { + "version": "4.4.3", + "license": "MIT", + "dependencies": { + "ms": "^2.1.3" + }, + "engines": { + "node": ">=6.0" + }, + "peerDependenciesMeta": { + "supports-color": { + "optional": true + } + } + }, + "node_modules/delayed-stream": { + "version": "1.0.0", + "license": "MIT", + "engines": { + "node": ">=0.4.0" + } + }, + "node_modules/dunder-proto": { + "version": "1.0.1", + "license": "MIT", + "dependencies": { + "call-bind-apply-helpers": "^1.0.1", + "es-errors": "^1.3.0", + "gopd": "^1.2.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/echarts": { + "version": "5.6.0", + "license": "Apache-2.0", + "dependencies": { + "tslib": "2.3.0", + "zrender": "5.6.1" + } + }, + "node_modules/element-plus": { + "version": "2.14.2", + "license": "MIT", + "dependencies": { + "@ctrl/tinycolor": "^4.2.0", + "@element-plus/icons-vue": "^2.3.2", + "@floating-ui/dom": "^1.7.6", + "@popperjs/core": "npm:@sxzz/popperjs-es@^2.11.8", + "@types/lodash": "^4.17.24", + "@types/lodash-es": "^4.17.12", + "@vueuse/core": "14.3.0", + "async-validator": "^4.2.5", + "dayjs": "^1.11.20", + "lodash": "^4.18.1", + "lodash-es": "^4.18.1", + "lodash-unified": "^1.0.3", + "memoize-one": "^6.0.0", + "normalize-wheel-es": "^1.2.0", + "vue-component-type-helpers": "^3.3.3" + }, + "peerDependencies": { + "vue": "^3.3.7" + } + }, + "node_modules/entities": { + "version": "7.0.1", + "license": "BSD-2-Clause", + "engines": { + "node": ">=0.12" + }, + "funding": { + "url": "https://github.com/fb55/entities?sponsor=1" + } + }, + "node_modules/es-define-property": { + "version": "1.0.1", + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-errors": { + "version": "1.3.0", + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-object-atoms": { + "version": "1.1.2", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-set-tostringtag": { + "version": "2.1.0", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "get-intrinsic": "^1.2.6", + "has-tostringtag": "^1.0.2", + "hasown": "^2.0.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/esbuild": { + "version": "0.21.5", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "bin": { + "esbuild": "bin/esbuild" + }, + "engines": { + "node": ">=12" + }, + "optionalDependencies": { + "@esbuild/aix-ppc64": "0.21.5", + "@esbuild/android-arm": "0.21.5", + "@esbuild/android-arm64": "0.21.5", + "@esbuild/android-x64": "0.21.5", + "@esbuild/darwin-arm64": "0.21.5", + "@esbuild/darwin-x64": "0.21.5", + "@esbuild/freebsd-arm64": "0.21.5", + "@esbuild/freebsd-x64": "0.21.5", + "@esbuild/linux-arm": "0.21.5", + "@esbuild/linux-arm64": "0.21.5", + "@esbuild/linux-ia32": "0.21.5", + "@esbuild/linux-loong64": "0.21.5", + "@esbuild/linux-mips64el": "0.21.5", + "@esbuild/linux-ppc64": "0.21.5", + "@esbuild/linux-riscv64": "0.21.5", + "@esbuild/linux-s390x": "0.21.5", + "@esbuild/linux-x64": "0.21.5", + "@esbuild/netbsd-x64": "0.21.5", + "@esbuild/openbsd-x64": "0.21.5", + "@esbuild/sunos-x64": "0.21.5", + "@esbuild/win32-arm64": "0.21.5", + "@esbuild/win32-ia32": "0.21.5", + "@esbuild/win32-x64": "0.21.5" + } + }, + "node_modules/estree-walker": { + "version": "2.0.2", + "license": "MIT" + }, + "node_modules/follow-redirects": { + "version": "1.16.0", + "funding": [ + { + "type": "individual", + "url": "https://github.com/sponsors/RubenVerborgh" + } + ], + "license": "MIT", + "engines": { + "node": ">=4.0" + }, + "peerDependenciesMeta": { + "debug": { + "optional": true + } + } + }, + "node_modules/form-data": { + "version": "4.0.6", + "license": "MIT", + "dependencies": { + "asynckit": "^0.4.0", + "combined-stream": "^1.0.8", + "es-set-tostringtag": "^2.1.0", + "hasown": "^2.0.4", + "mime-types": "^2.1.35" + }, + "engines": { + "node": ">= 6" + } + }, + "node_modules/fsevents": { + "version": "2.3.3", + "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", + "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^8.16.0 || ^10.6.0 || >=11.0.0" + } + }, + "node_modules/function-bind": { + "version": "1.1.2", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/get-intrinsic": { + "version": "1.3.0", + "license": "MIT", + "dependencies": { + "call-bind-apply-helpers": "^1.0.2", + "es-define-property": "^1.0.1", + "es-errors": "^1.3.0", + "es-object-atoms": "^1.1.1", + "function-bind": "^1.1.2", + "get-proto": "^1.0.1", + "gopd": "^1.2.0", + "has-symbols": "^1.1.0", + "hasown": "^2.0.2", + "math-intrinsics": "^1.1.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/get-proto": { + "version": "1.0.1", + "license": "MIT", + "dependencies": { + "dunder-proto": "^1.0.1", + "es-object-atoms": "^1.0.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/gopd": { + "version": "1.2.0", + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/has-symbols": { + "version": "1.1.0", + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/has-tostringtag": { + "version": "1.0.2", + "license": "MIT", + "dependencies": { + "has-symbols": "^1.0.3" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/hasown": { + "version": "2.0.4", + "license": "MIT", + "dependencies": { + "function-bind": "^1.1.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/https-proxy-agent": { + "version": "5.0.1", + "license": "MIT", + "dependencies": { + "agent-base": "6", + "debug": "4" + }, + "engines": { + "node": ">= 6" + } + }, + "node_modules/lodash": { + "version": "4.18.1", + "license": "MIT" + }, + "node_modules/lodash-es": { + "version": "4.18.1", + "license": "MIT" + }, + "node_modules/lodash-unified": { + "version": "1.0.3", + "license": "MIT", + "peerDependencies": { + "@types/lodash-es": "*", + "lodash": "*", + "lodash-es": "*" + } + }, + "node_modules/magic-string": { + "version": "0.30.21", + "license": "MIT", + "dependencies": { + "@jridgewell/sourcemap-codec": "^1.5.5" + } + }, + "node_modules/math-intrinsics": { + "version": "1.1.0", + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/memoize-one": { + "version": "6.0.0", + "license": "MIT" + }, + "node_modules/mime-db": { + "version": "1.52.0", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/mime-types": { + "version": "2.1.35", + "license": "MIT", + "dependencies": { + "mime-db": "1.52.0" + }, + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/ms": { + "version": "2.1.3", + "license": "MIT" + }, + "node_modules/nanoid": { + "version": "3.3.12", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "bin": { + "nanoid": "bin/nanoid.cjs" + }, + "engines": { + "node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1" + } + }, + "node_modules/normalize-wheel-es": { + "version": "1.2.0", + "license": "BSD-3-Clause" + }, + "node_modules/picocolors": { + "version": "1.1.1", + "license": "ISC" + }, + "node_modules/postcss": { + "version": "8.5.15", + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/postcss/" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/postcss" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "nanoid": "^3.3.12", + "picocolors": "^1.1.1", + "source-map-js": "^1.2.1" + }, + "engines": { + "node": "^10 || ^12 || >=14" + } + }, + "node_modules/proxy-from-env": { + "version": "2.1.0", + "license": "MIT", + "engines": { + "node": ">=10" + } + }, + "node_modules/resize-detector": { + "version": "0.3.0", + "license": "MIT" + }, + "node_modules/rollup": { + "version": "4.62.0", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/estree": "1.0.9" + }, + "bin": { + "rollup": "dist/bin/rollup" + }, + "engines": { + "node": ">=18.0.0", + "npm": ">=8.0.0" + }, + "optionalDependencies": { + "@rollup/rollup-android-arm-eabi": "4.62.0", + "@rollup/rollup-android-arm64": "4.62.0", + "@rollup/rollup-darwin-arm64": "4.62.0", + "@rollup/rollup-darwin-x64": "4.62.0", + "@rollup/rollup-freebsd-arm64": "4.62.0", + "@rollup/rollup-freebsd-x64": "4.62.0", + "@rollup/rollup-linux-arm-gnueabihf": "4.62.0", + "@rollup/rollup-linux-arm-musleabihf": "4.62.0", + "@rollup/rollup-linux-arm64-gnu": "4.62.0", + "@rollup/rollup-linux-arm64-musl": "4.62.0", + "@rollup/rollup-linux-loong64-gnu": "4.62.0", + "@rollup/rollup-linux-loong64-musl": "4.62.0", + "@rollup/rollup-linux-ppc64-gnu": "4.62.0", + "@rollup/rollup-linux-ppc64-musl": "4.62.0", + "@rollup/rollup-linux-riscv64-gnu": "4.62.0", + "@rollup/rollup-linux-riscv64-musl": "4.62.0", + "@rollup/rollup-linux-s390x-gnu": "4.62.0", + "@rollup/rollup-linux-x64-gnu": "4.62.0", + "@rollup/rollup-linux-x64-musl": "4.62.0", + "@rollup/rollup-openbsd-x64": "4.62.0", + "@rollup/rollup-openharmony-arm64": "4.62.0", + "@rollup/rollup-win32-arm64-msvc": "4.62.0", + "@rollup/rollup-win32-ia32-msvc": "4.62.0", + "@rollup/rollup-win32-x64-gnu": "4.62.0", + "@rollup/rollup-win32-x64-msvc": "4.62.0", + "fsevents": "~2.3.2" + } + }, + "node_modules/source-map-js": { + "version": "1.2.1", + "license": "BSD-3-Clause", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/tslib": { + "version": "2.3.0", + "license": "0BSD" + }, + "node_modules/vite": { + "version": "5.4.21", + "dev": true, + "license": "MIT", + "dependencies": { + "esbuild": "^0.21.3", + "postcss": "^8.4.43", + "rollup": "^4.20.0" + }, + "bin": { + "vite": "bin/vite.js" + }, + "engines": { + "node": "^18.0.0 || >=20.0.0" + }, + "funding": { + "url": "https://github.com/vitejs/vite?sponsor=1" + }, + "optionalDependencies": { + "fsevents": "~2.3.3" + }, + "peerDependencies": { + "@types/node": "^18.0.0 || >=20.0.0", + "less": "*", + "lightningcss": "^1.21.0", + "sass": "*", + "sass-embedded": "*", + "stylus": "*", + "sugarss": "*", + "terser": "^5.4.0" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + }, + "less": { + "optional": true + }, + "lightningcss": { + "optional": true + }, + "sass": { + "optional": true + }, + "sass-embedded": { + "optional": true + }, + "stylus": { + "optional": true + }, + "sugarss": { + "optional": true + }, + "terser": { + "optional": true + } + } + }, + "node_modules/vue": { + "version": "3.5.38", + "license": "MIT", + "dependencies": { + "@vue/compiler-dom": "3.5.38", + "@vue/compiler-sfc": "3.5.38", + "@vue/runtime-dom": "3.5.38", + "@vue/server-renderer": "3.5.38", + "@vue/shared": "3.5.38" + }, + "peerDependencies": { + "typescript": "*" + }, + "peerDependenciesMeta": { + "typescript": { + "optional": true + } + } + }, + "node_modules/vue-component-type-helpers": { + "version": "3.3.5", + "license": "MIT" + }, + "node_modules/vue-echarts": { + "version": "6.7.3", + "hasInstallScript": true, + "license": "MIT", + "dependencies": { + "resize-detector": "^0.3.0", + "vue-demi": "^0.13.11" + }, + "peerDependencies": { + "@vue/composition-api": "^1.0.5", + "@vue/runtime-core": "^3.0.0", + "echarts": "^5.4.1", + "vue": "^2.6.12 || ^3.1.1" + }, + "peerDependenciesMeta": { + "@vue/composition-api": { + "optional": true + }, + "@vue/runtime-core": { + "optional": true + } + } + }, + "node_modules/vue-echarts/node_modules/vue-demi": { + "version": "0.13.11", + "hasInstallScript": true, + "license": "MIT", + "bin": { + "vue-demi-fix": "bin/vue-demi-fix.js", + "vue-demi-switch": "bin/vue-demi-switch.js" + }, + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/antfu" + }, + "peerDependencies": { + "@vue/composition-api": "^1.0.0-rc.1", + "vue": "^3.0.0-0 || ^2.6.0" + }, + "peerDependenciesMeta": { + "@vue/composition-api": { + "optional": true + } + } + }, + "node_modules/vue-router": { + "version": "4.6.4", + "license": "MIT", + "dependencies": { + "@vue/devtools-api": "^6.6.4" + }, + "funding": { + "url": "https://github.com/sponsors/posva" + }, + "peerDependencies": { + "vue": "^3.5.0" + } + }, + "node_modules/zrender": { + "version": "5.6.1", + "license": "BSD-3-Clause", + "dependencies": { + "tslib": "2.3.0" + } + } + } +} diff --git a/frontend/package.json b/frontend/package.json new file mode 100644 index 0000000..a2f761d --- /dev/null +++ b/frontend/package.json @@ -0,0 +1,25 @@ +{ + "name": "taskpulse-frontend", + "private": true, + "version": "1.0.0", + "type": "module", + "scripts": { + "dev": "vite", + "build": "vite build", + "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", + "dayjs": "^1.11.0", + "echarts": "^5.5.0", + "vue-echarts": "^6.7.0" + }, + "devDependencies": { + "@vitejs/plugin-vue": "^5.0.0", + "vite": "^5.4.0" + } +} diff --git a/frontend/src/App.vue b/frontend/src/App.vue new file mode 100644 index 0000000..12a8eeb --- /dev/null +++ b/frontend/src/App.vue @@ -0,0 +1,204 @@ + + + + + diff --git a/frontend/src/api/index.js b/frontend/src/api/index.js new file mode 100644 index 0000000..e5a256b --- /dev/null +++ b/frontend/src/api/index.js @@ -0,0 +1,49 @@ +import axios from 'axios' + +const api = axios.create({ + baseURL: '/api', + timeout: 10000, +}) + +// Dashboard +export const getDashboardSummary = () => api.get('/dashboard/summary') +export const getDashboardTasks = () => api.get('/dashboard/tasks') + +// Agents +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) + +// Tasks +export const listTasks = (agentId) => api.get('/tasks', { params: { agent_id: agentId } }) +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) +export const deleteTask = (id) => api.delete(`/tasks/${id}`) + +// Executions +export const listExecutions = (taskId, params) => api.get(`/tasks/${taskId}/executions`, { params }) +export const getExecution = (id) => api.get(`/executions/${id}`) +export const getRecentExecutions = (params) => api.get('/executions/recent', { params }) + +// Alerts +export const listAlerts = (params) => api.get('/alerts', { params }) +export const acknowledgeAlert = (id) => api.post(`/alerts/${id}/acknowledge`) + +// Notification Channels +export const listChannels = () => api.get('/notification-channels') +export const createChannel = (data) => api.post('/notification-channels', data) +export const updateChannel = (id, data) => api.put(`/notification-channels/${id}`, data) +export const deleteChannel = (id) => api.delete(`/notification-channels/${id}`) + +// Alert Rules +export const listAlertRules = (params) => api.get('/alert-rules', { params }) +export const createAlertRule = (data) => api.post('/alert-rules', data) +export const deleteAlertRule = (id) => api.delete(`/alert-rules/${id}`) + +// System Config +export const getSystemConfig = () => api.get('/system/config') +export const saveSystemConfig = (data) => api.put('/system/config', data) + +export default api diff --git a/frontend/src/assets/style.css b/frontend/src/assets/style.css new file mode 100644 index 0000000..a190f33 --- /dev/null +++ b/frontend/src/assets/style.css @@ -0,0 +1,232 @@ +/* ── TaskPulse Global Theme ─────────────────────── */ + +:root { + --bg-primary: #0a0a0f; + --bg-card: #13131f; + --bg-card-hover: #1a1a2e; + --bg-elevated: #1e1e32; + --border-color: rgba(255,255,255,0.06); + --border-hover: rgba(255,255,255,0.1); + --text-primary: #e8e8f0; + --text-secondary: rgba(255,255,255,0.5); + --text-muted: rgba(255,255,255,0.3); + --accent-1: #667eea; + --accent-2: #764ba2; + --accent-gradient: linear-gradient(135deg, #667eea 0%, #764ba2 100%); + --success: #22c55e; + --warning: #f59e0b; + --danger: #ef4444; + --info: #6366f1; + --radius: 12px; + --radius-sm: 8px; +} + +* { + margin: 0; + padding: 0; + box-sizing: border-box; +} + +html, body, #app { + height: 100%; + font-family: -apple-system, BlinkMacSystemFont, 'SF Pro Display', 'Segoe UI', 'Inter', Roboto, sans-serif; + background: var(--bg-primary); + color: var(--text-primary); + -webkit-font-smoothing: antialiased; +} + +/* ── Scrollbar ─────────────────────────────────── */ +::-webkit-scrollbar { + width: 6px; +} +::-webkit-scrollbar-track { + background: transparent; +} +::-webkit-scrollbar-thumb { + background: rgba(255,255,255,0.08); + border-radius: 3px; +} +::-webkit-scrollbar-thumb:hover { + background: rgba(255,255,255,0.15); +} + +/* ── Element Plus Overrides ────────────────────── */ + +/* Cards */ +.el-card { + background: var(--bg-card) !important; + border: 1px solid var(--border-color) !important; + border-radius: var(--radius) !important; + color: var(--text-primary) !important; +} +.el-card__header { + padding: 16px 20px !important; + border-bottom: 1px solid var(--border-color) !important; +} +.el-card__body { + padding: 20px !important; +} + +/* Tables */ +.el-table { + --el-table-bg-color: transparent !important; + --el-table-tr-bg-color: transparent !important; + --el-table-header-bg-color: rgba(255,255,255,0.02) !important; + --el-table-row-hover-bg-color: rgba(255,255,255,0.04) !important; + --el-table-border-color: var(--border-color) !important; + --el-table-text-color: var(--text-primary) !important; + --el-table-header-text-color: var(--text-secondary) !important; + background: transparent !important; + color: var(--text-primary) !important; +} +.el-table th.el-table__cell { + background: rgba(255,255,255,0.02) !important; + border-bottom: 1px solid var(--border-color) !important; + color: var(--text-secondary) !important; + font-weight: 500 !important; + font-size: 12px !important; + text-transform: uppercase; + letter-spacing: 0.5px; +} +.el-table td.el-table__cell { + border-bottom: 1px solid var(--border-color) !important; + color: var(--text-primary) !important; +} + +/* Tags */ +.el-tag { + border: none !important; + font-weight: 500 !important; +} +.el-tag--success { + background: rgba(34,197,94,0.15) !important; + color: #4ade80 !important; +} +.el-tag--danger { + background: rgba(239,68,68,0.15) !important; + color: #f87171 !important; +} +.el-tag--warning { + background: rgba(245,158,11,0.15) !important; + color: #fbbf24 !important; +} +.el-tag--info { + background: rgba(255,255,255,0.06) !important; + color: var(--text-secondary) !important; +} + +/* Buttons */ +.el-button--primary { + --el-button-bg-color: var(--accent-1) !important; + --el-button-border-color: var(--accent-1) !important; + --el-button-hover-bg-color: #7c8ff0 !important; + --el-button-hover-border-color: #7c8ff0 !important; + border-radius: var(--radius-sm) !important; +} +.el-button--small { + border-radius: 6px !important; +} +.el-button.is-text { + color: var(--accent-1) !important; +} +.el-button.is-text.is-danger { + color: var(--danger) !important; +} + +/* Dialog */ +.el-dialog { + --el-dialog-bg-color: var(--bg-card) !important; + --el-dialog-border-color: var(--border-color) !important; + border-radius: var(--radius) !important; + border: 1px solid var(--border-color) !important; +} +.el-dialog__title { + color: var(--text-primary) !important; +} +.el-dialog__body { + color: var(--text-secondary) !important; +} + +/* Form */ +.el-form-item__label { + color: var(--text-secondary) !important; +} +.el-input { + --el-input-bg-color: rgba(255,255,255,0.04) !important; + --el-input-border-color: var(--border-color) !important; + --el-input-text-color: var(--text-primary) !important; + --el-input-hover-border-color: var(--accent-1) !important; + --el-input-focus-border-color: var(--accent-1) !important; +} +.el-input__inner { + background: rgba(255,255,255,0.04) !important; + color: var(--text-primary) !important; + border-color: var(--border-color) !important; +} +.el-select { + --el-select-input-focus-border-color: var(--accent-1) !important; +} +.el-select-dropdown { + background: var(--bg-elevated) !important; + border: 1px solid var(--border-color) !important; +} +.el-select-dropdown__item { + color: var(--text-primary) !important; +} +.el-select-dropdown__item.hover { + background: rgba(255,255,255,0.06) !important; +} + +/* Switch */ +.el-switch { + --el-switch-on-color: var(--accent-1) !important; +} + +/* Radio */ +.el-radio-group { + --el-radio-button-checked-bg-color: var(--accent-1) !important; + --el-radio-button-checked-border-color: var(--accent-1) !important; +} +.el-radio-button__inner { + background: rgba(255,255,255,0.04) !important; + color: var(--text-secondary) !important; + border-color: var(--border-color) !important; +} +.el-radio-button__original-radio:checked + .el-radio-button__inner { + background: var(--accent-1) !important; + color: #fff !important; +} + +/* Descriptions */ +.el-descriptions { + --el-descriptions-table-bg: transparent !important; + --el-descriptions-item-bg: transparent !important; +} +.el-descriptions__title { + color: var(--text-primary) !important; +} +.el-descriptions__label { + color: var(--text-secondary) !important; + background: rgba(255,255,255,0.02) !important; +} +.el-descriptions__content { + color: var(--text-primary) !important; + background: transparent !important; +} +.el-descriptions__cell { + border-color: var(--border-color) !important; +} + +/* Input number */ +.el-input-number { + --el-input-number-controls-height: 100% !important; +} + +/* Message */ +.el-message { + --el-message-bg-color: var(--bg-elevated) !important; + --el-message-border-color: var(--border-color) !important; + --el-message-text-color: var(--text-primary) !important; + border: 1px solid var(--border-color) !important; + backdrop-filter: blur(12px) !important; +} diff --git a/frontend/src/main.js b/frontend/src/main.js new file mode 100644 index 0000000..738f1fc --- /dev/null +++ b/frontend/src/main.js @@ -0,0 +1,19 @@ +import { createApp } from 'vue' +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 App from './App.vue' +import router from './router' +import './assets/style.css' + +const app = createApp(App) + +// Register all Element Plus icons +for (const [key, component] of Object.entries(ElementPlusIconsVue)) { + app.component(key, component) +} + +app.use(ElementPlus, { locale: zhCn }) +app.use(router) +app.mount('#app') diff --git a/frontend/src/router/index.js b/frontend/src/router/index.js new file mode 100644 index 0000000..ae49306 --- /dev/null +++ b/frontend/src/router/index.js @@ -0,0 +1,23 @@ +import { createRouter, createWebHistory } from 'vue-router' +import Dashboard from '../views/Dashboard.vue' +import Tasks from '../views/Tasks.vue' +import TaskDetail from '../views/TaskDetail.vue' +import Agents from '../views/Agents.vue' +import Alerts from '../views/Alerts.vue' +import Settings from '../views/Settings.vue' + +const routes = [ + { path: '/', component: Dashboard }, + { path: '/tasks', component: Tasks }, + { path: '/tasks/:id', component: TaskDetail, name: 'TaskDetail' }, + { path: '/agents', component: Agents }, + { path: '/alerts', component: Alerts }, + { path: '/settings', component: Settings }, +] + +const router = createRouter({ + history: createWebHistory(), + routes, +}) + +export default router diff --git a/frontend/src/views/Agents.vue b/frontend/src/views/Agents.vue new file mode 100644 index 0000000..48715fa --- /dev/null +++ b/frontend/src/views/Agents.vue @@ -0,0 +1,237 @@ + + + + + diff --git a/frontend/src/views/Alerts.vue b/frontend/src/views/Alerts.vue new file mode 100644 index 0000000..6f233ab --- /dev/null +++ b/frontend/src/views/Alerts.vue @@ -0,0 +1,156 @@ + + + + + diff --git a/frontend/src/views/Dashboard.vue b/frontend/src/views/Dashboard.vue new file mode 100644 index 0000000..71e6443 --- /dev/null +++ b/frontend/src/views/Dashboard.vue @@ -0,0 +1,541 @@ + + + + + diff --git a/frontend/src/views/Settings.vue b/frontend/src/views/Settings.vue new file mode 100644 index 0000000..f3adbaf --- /dev/null +++ b/frontend/src/views/Settings.vue @@ -0,0 +1,431 @@ + + + + + diff --git a/frontend/src/views/TaskDetail.vue b/frontend/src/views/TaskDetail.vue new file mode 100644 index 0000000..13673c6 --- /dev/null +++ b/frontend/src/views/TaskDetail.vue @@ -0,0 +1,242 @@ + + + + + diff --git a/frontend/src/views/Tasks.vue b/frontend/src/views/Tasks.vue new file mode 100644 index 0000000..7cd1ea3 --- /dev/null +++ b/frontend/src/views/Tasks.vue @@ -0,0 +1,220 @@ + + + + + diff --git a/frontend/vite.config.js b/frontend/vite.config.js new file mode 100644 index 0000000..0b68482 --- /dev/null +++ b/frontend/vite.config.js @@ -0,0 +1,19 @@ +import { defineConfig } from 'vite' +import vue from '@vitejs/plugin-vue' + +export default defineConfig({ + plugins: [vue()], + server: { + port: 3000, + proxy: { + '/api': { + target: 'http://localhost:8000', + changeOrigin: true, + }, + }, + }, + build: { + outDir: 'dist', + assetsDir: 'assets', + }, +}) diff --git a/init.sql b/init.sql new file mode 100644 index 0000000..4f074a6 --- /dev/null +++ b/init.sql @@ -0,0 +1 @@ +CREATE DATABASE IF NOT EXISTS taskpulse CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;