feat: initial TaskPulse release
AI Agent 定时任务跟踪、监控、状态看板系统。 - FastAPI + Vue3 单体应用 - AI Agent 一键接入(批量注册 API) - 暗色主题 Dashboard - 后台调度器 + 超时告警 - 飞书/邮件/Webhook 多渠道通知 - Base URL 可配置 - Gunicorn 生产部署
This commit is contained in:
commit
521cf0269c
29
.gitignore
vendored
Normal file
29
.gitignore
vendored
Normal file
@ -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
|
||||||
126
AGENT_GUIDE.md
Normal file
126
AGENT_GUIDE.md
Normal file
@ -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。
|
||||||
193
README.md
Normal file
193
README.md
Normal file
@ -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
|
||||||
|
```
|
||||||
18
backend/.env.example
Normal file
18
backend/.env.example
Normal file
@ -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
|
||||||
17
backend/Dockerfile
Normal file
17
backend/Dockerfile
Normal file
@ -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"]
|
||||||
36
backend/alembic.ini
Normal file
36
backend/alembic.ini
Normal file
@ -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
|
||||||
53
backend/alembic/env.py
Normal file
53
backend/alembic/env.py
Normal file
@ -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()
|
||||||
111
backend/alembic/versions/0001_initial.py
Normal file
111
backend/alembic/versions/0001_initial.py
Normal file
@ -0,0 +1,111 @@
|
|||||||
|
"""Alembic generic migration script."""
|
||||||
|
"""
|
||||||
|
Revision ID: 0001
|
||||||
|
Revises:
|
||||||
|
Create Date: 2025-01-01
|
||||||
|
"""
|
||||||
|
from typing import Sequence, Union
|
||||||
|
|
||||||
|
from alembic import op
|
||||||
|
import sqlalchemy as sa
|
||||||
|
|
||||||
|
revision: str = "0001"
|
||||||
|
down_revision: Union[str, None] = None
|
||||||
|
branch_labels: Union[str, Sequence[str], None] = None
|
||||||
|
depends_on: Union[str, Sequence[str], None] = None
|
||||||
|
|
||||||
|
|
||||||
|
def upgrade() -> None:
|
||||||
|
op.create_table(
|
||||||
|
"agents",
|
||||||
|
sa.Column("id", sa.Integer, primary_key=True, autoincrement=True),
|
||||||
|
sa.Column("name", sa.String(128), unique=True, nullable=False),
|
||||||
|
sa.Column("description", sa.Text, default=""),
|
||||||
|
sa.Column("api_key", sa.String(128), unique=True),
|
||||||
|
sa.Column("status", sa.String(32), default="active"),
|
||||||
|
sa.Column("last_heartbeat_at", sa.DateTime, nullable=True),
|
||||||
|
sa.Column("created_at", sa.DateTime, server_default=sa.func.now()),
|
||||||
|
sa.Column("updated_at", sa.DateTime, server_default=sa.func.now()),
|
||||||
|
)
|
||||||
|
|
||||||
|
op.create_table(
|
||||||
|
"notification_channels",
|
||||||
|
sa.Column("id", sa.Integer, primary_key=True, autoincrement=True),
|
||||||
|
sa.Column("name", sa.String(128), nullable=False),
|
||||||
|
sa.Column("channel_type", sa.String(32), nullable=False),
|
||||||
|
sa.Column("config", sa.Text, nullable=False),
|
||||||
|
sa.Column("enabled", sa.Boolean, default=True),
|
||||||
|
sa.Column("created_at", sa.DateTime, server_default=sa.func.now()),
|
||||||
|
sa.Column("updated_at", sa.DateTime, server_default=sa.func.now()),
|
||||||
|
)
|
||||||
|
|
||||||
|
op.create_table(
|
||||||
|
"scheduled_tasks",
|
||||||
|
sa.Column("id", sa.Integer, primary_key=True, autoincrement=True),
|
||||||
|
sa.Column("agent_id", sa.Integer, sa.ForeignKey("agents.id", ondelete="CASCADE"), nullable=False),
|
||||||
|
sa.Column("name", sa.String(256), nullable=False),
|
||||||
|
sa.Column("description", sa.Text, default=""),
|
||||||
|
sa.Column("cron_expression", sa.String(64), nullable=False),
|
||||||
|
sa.Column("grace_period", sa.Integer, default=300),
|
||||||
|
sa.Column("status", sa.String(32), default="active"),
|
||||||
|
sa.Column("last_run_at", sa.DateTime, nullable=True),
|
||||||
|
sa.Column("last_run_result", sa.String(32), nullable=True),
|
||||||
|
sa.Column("next_run_at", sa.DateTime, nullable=True),
|
||||||
|
sa.Column("total_run_count", sa.Integer, default=0),
|
||||||
|
sa.Column("created_at", sa.DateTime, server_default=sa.func.now()),
|
||||||
|
sa.Column("updated_at", sa.DateTime, server_default=sa.func.now()),
|
||||||
|
)
|
||||||
|
|
||||||
|
op.create_table(
|
||||||
|
"task_executions",
|
||||||
|
sa.Column("id", sa.Integer, primary_key=True, autoincrement=True),
|
||||||
|
sa.Column("task_id", sa.Integer, sa.ForeignKey("scheduled_tasks.id", ondelete="CASCADE"), nullable=False),
|
||||||
|
sa.Column("agent_id", sa.Integer, sa.ForeignKey("agents.id", ondelete="CASCADE"), nullable=False),
|
||||||
|
sa.Column("status", sa.String(32), default="running"),
|
||||||
|
sa.Column("started_at", sa.DateTime, server_default=sa.func.now()),
|
||||||
|
sa.Column("finished_at", sa.DateTime, nullable=True),
|
||||||
|
sa.Column("duration_ms", sa.Integer, nullable=True),
|
||||||
|
sa.Column("result", sa.Text, nullable=True),
|
||||||
|
sa.Column("log", sa.Text, nullable=True),
|
||||||
|
sa.Column("error_message", sa.Text, nullable=True),
|
||||||
|
sa.Column("created_at", sa.DateTime, server_default=sa.func.now()),
|
||||||
|
)
|
||||||
|
|
||||||
|
op.create_table(
|
||||||
|
"alert_rules",
|
||||||
|
sa.Column("id", sa.Integer, primary_key=True, autoincrement=True),
|
||||||
|
sa.Column("task_id", sa.Integer, sa.ForeignKey("scheduled_tasks.id", ondelete="CASCADE"), nullable=False),
|
||||||
|
sa.Column("channel_id", sa.Integer, sa.ForeignKey("notification_channels.id", ondelete="CASCADE"), nullable=False),
|
||||||
|
sa.Column("alert_type", sa.String(32), default="missed_run"),
|
||||||
|
sa.Column("enabled", sa.Boolean, default=True),
|
||||||
|
sa.Column("created_at", sa.DateTime, server_default=sa.func.now()),
|
||||||
|
)
|
||||||
|
|
||||||
|
op.create_table(
|
||||||
|
"alerts",
|
||||||
|
sa.Column("id", sa.Integer, primary_key=True, autoincrement=True),
|
||||||
|
sa.Column("task_id", sa.Integer, sa.ForeignKey("scheduled_tasks.id", ondelete="SET NULL"), nullable=True),
|
||||||
|
sa.Column("alert_type", sa.String(32), nullable=False),
|
||||||
|
sa.Column("message", sa.Text, nullable=False),
|
||||||
|
sa.Column("status", sa.String(32), default="pending"),
|
||||||
|
sa.Column("acknowledged_at", sa.DateTime, nullable=True),
|
||||||
|
sa.Column("created_at", sa.DateTime, server_default=sa.func.now()),
|
||||||
|
)
|
||||||
|
|
||||||
|
op.create_table(
|
||||||
|
"system_config",
|
||||||
|
sa.Column("key", sa.String(128), primary_key=True),
|
||||||
|
sa.Column("value", sa.Text, nullable=False),
|
||||||
|
sa.Column("description", sa.String(256), default=""),
|
||||||
|
sa.Column("updated_at", sa.DateTime, server_default=sa.func.now()),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def downgrade() -> None:
|
||||||
|
op.drop_table("system_config")
|
||||||
|
op.drop_table("alerts")
|
||||||
|
op.drop_table("alert_rules")
|
||||||
|
op.drop_table("task_executions")
|
||||||
|
op.drop_table("scheduled_tasks")
|
||||||
|
op.drop_table("notification_channels")
|
||||||
|
op.drop_table("agents")
|
||||||
0
backend/app/__init__.py
Normal file
0
backend/app/__init__.py
Normal file
25
backend/app/api/__init__.py
Normal file
25
backend/app/api/__init__.py
Normal file
@ -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
|
||||||
87
backend/app/api/agents.py
Normal file
87
backend/app/api/agents.py
Normal file
@ -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,
|
||||||
|
)
|
||||||
115
backend/app/api/dashboard.py
Normal file
115
backend/app/api/dashboard.py
Normal file
@ -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
|
||||||
70
backend/app/api/executions.py
Normal file
70
backend/app/api/executions.py
Normal file
@ -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__)
|
||||||
158
backend/app/api/notifications.py
Normal file
158
backend/app/api/notifications.py
Normal file
@ -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})
|
||||||
77
backend/app/api/system.py
Normal file
77
backend/app/api/system.py
Normal file
@ -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,
|
||||||
|
)
|
||||||
74
backend/app/api/tasks.py
Normal file
74
backend/app/api/tasks.py
Normal file
@ -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")
|
||||||
49
backend/app/config.py
Normal file
49
backend/app/config.py
Normal file
@ -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()
|
||||||
24
backend/app/database.py
Normal file
24
backend/app/database.py
Normal file
@ -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
|
||||||
80
backend/app/main.py
Normal file
80
backend/app/main.py
Normal file
@ -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"}
|
||||||
9
backend/app/models/__init__.py
Normal file
9
backend/app/models/__init__.py
Normal file
@ -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"]
|
||||||
31
backend/app/models/agent.py
Normal file
31
backend/app/models/agent.py
Normal file
@ -0,0 +1,31 @@
|
|||||||
|
"""SQLAlchemy models — Agent."""
|
||||||
|
|
||||||
|
import uuid
|
||||||
|
from datetime import datetime
|
||||||
|
|
||||||
|
from sqlalchemy import Boolean, DateTime, Float, String, Text, func
|
||||||
|
from sqlalchemy.orm import Mapped, mapped_column, relationship
|
||||||
|
|
||||||
|
from app.database import Base
|
||||||
|
|
||||||
|
|
||||||
|
def _gen_api_key() -> str:
|
||||||
|
return f"tp_{uuid.uuid4().hex}"
|
||||||
|
|
||||||
|
|
||||||
|
class Agent(Base):
|
||||||
|
__tablename__ = "agents"
|
||||||
|
|
||||||
|
id: Mapped[int] = mapped_column(primary_key=True, autoincrement=True)
|
||||||
|
name: Mapped[str] = mapped_column(String(128), unique=True, nullable=False, comment="Agent 名称")
|
||||||
|
description: Mapped[str] = mapped_column(Text, default="", comment="描述")
|
||||||
|
api_key: Mapped[str] = mapped_column(String(128), unique=True, default=_gen_api_key, comment="API Key")
|
||||||
|
status: Mapped[str] = mapped_column(String(32), default="active", comment="active / inactive")
|
||||||
|
last_heartbeat_at: Mapped[datetime | None] = mapped_column(DateTime, nullable=True, comment="最后心跳时间")
|
||||||
|
created_at: Mapped[datetime] = mapped_column(DateTime, server_default=func.now())
|
||||||
|
updated_at: Mapped[datetime] = mapped_column(DateTime, server_default=func.now(), onupdate=func.now())
|
||||||
|
|
||||||
|
tasks = relationship("ScheduledTask", back_populates="agent")
|
||||||
|
|
||||||
|
def __repr__(self) -> str:
|
||||||
|
return f"<Agent {self.name}>"
|
||||||
30
backend/app/models/execution.py
Normal file
30
backend/app/models/execution.py
Normal file
@ -0,0 +1,30 @@
|
|||||||
|
"""SQLAlchemy models — TaskExecution."""
|
||||||
|
|
||||||
|
from datetime import datetime
|
||||||
|
|
||||||
|
from sqlalchemy import DateTime, ForeignKey, Integer, String, Text, func
|
||||||
|
from sqlalchemy.orm import Mapped, mapped_column, relationship
|
||||||
|
|
||||||
|
from app.database import Base
|
||||||
|
|
||||||
|
|
||||||
|
class TaskExecution(Base):
|
||||||
|
__tablename__ = "task_executions"
|
||||||
|
|
||||||
|
id: Mapped[int] = mapped_column(primary_key=True, autoincrement=True)
|
||||||
|
task_id: Mapped[int] = mapped_column(ForeignKey("scheduled_tasks.id", ondelete="CASCADE"), nullable=False)
|
||||||
|
agent_id: Mapped[int] = mapped_column(ForeignKey("agents.id", ondelete="CASCADE"), nullable=False)
|
||||||
|
status: Mapped[str] = mapped_column(String(32), default="running", comment="running / success / failed")
|
||||||
|
started_at: Mapped[datetime] = mapped_column(DateTime, server_default=func.now(), comment="开始时间")
|
||||||
|
finished_at: Mapped[datetime | None] = mapped_column(DateTime, nullable=True, comment="结束时间")
|
||||||
|
duration_ms: Mapped[int | None] = mapped_column(Integer, nullable=True, comment="耗时(毫秒)")
|
||||||
|
result: Mapped[str | None] = mapped_column(Text, nullable=True, comment="执行结果摘要 (JSON)")
|
||||||
|
log: Mapped[str | None] = mapped_column(Text, nullable=True, comment="执行日志")
|
||||||
|
error_message: Mapped[str | None] = mapped_column(Text, nullable=True, comment="错误信息")
|
||||||
|
created_at: Mapped[datetime] = mapped_column(DateTime, server_default=func.now())
|
||||||
|
|
||||||
|
task = relationship("ScheduledTask", back_populates="executions")
|
||||||
|
agent = relationship("Agent")
|
||||||
|
|
||||||
|
def __repr__(self) -> str:
|
||||||
|
return f"<TaskExecution #{self.id} task={self.task_id} status={self.status}>"
|
||||||
60
backend/app/models/notification.py
Normal file
60
backend/app/models/notification.py
Normal file
@ -0,0 +1,60 @@
|
|||||||
|
"""SQLAlchemy models — NotificationChannel & AlertRule & AlertHistory."""
|
||||||
|
|
||||||
|
from datetime import datetime
|
||||||
|
|
||||||
|
from sqlalchemy import Boolean, DateTime, ForeignKey, Integer, String, Text, func
|
||||||
|
from sqlalchemy.orm import Mapped, mapped_column, relationship
|
||||||
|
|
||||||
|
from app.database import Base
|
||||||
|
|
||||||
|
|
||||||
|
class NotificationChannel(Base):
|
||||||
|
"""用户绑定的通知渠道"""
|
||||||
|
__tablename__ = "notification_channels"
|
||||||
|
|
||||||
|
id: Mapped[int] = mapped_column(primary_key=True, autoincrement=True)
|
||||||
|
name: Mapped[str] = mapped_column(String(128), nullable=False, comment="渠道名称, e.g. 我的飞书")
|
||||||
|
channel_type: Mapped[str] = mapped_column(String(32), nullable=False, comment="feishu_cli / email / webhook")
|
||||||
|
config: Mapped[str] = mapped_column(Text, nullable=False, comment="JSON 配置")
|
||||||
|
enabled: Mapped[bool] = mapped_column(default=True)
|
||||||
|
created_at: Mapped[datetime] = mapped_column(DateTime, server_default=func.now())
|
||||||
|
updated_at: Mapped[datetime] = mapped_column(DateTime, server_default=func.now(), onupdate=func.now())
|
||||||
|
|
||||||
|
alert_rules = relationship("AlertRule", back_populates="channel", cascade="all, delete-orphan")
|
||||||
|
|
||||||
|
def __repr__(self) -> str:
|
||||||
|
return f"<NotificationChannel {self.name} ({self.channel_type})>"
|
||||||
|
|
||||||
|
|
||||||
|
class AlertRule(Base):
|
||||||
|
"""告警规则:哪些任务触发哪些通知渠道"""
|
||||||
|
__tablename__ = "alert_rules"
|
||||||
|
|
||||||
|
id: Mapped[int] = mapped_column(primary_key=True, autoincrement=True)
|
||||||
|
task_id: Mapped[int] = mapped_column(ForeignKey("scheduled_tasks.id", ondelete="CASCADE"), nullable=False)
|
||||||
|
channel_id: Mapped[int] = mapped_column(ForeignKey("notification_channels.id", ondelete="CASCADE"), nullable=False)
|
||||||
|
alert_type: Mapped[str] = mapped_column(String(32), default="missed_run", comment="missed_run / failure")
|
||||||
|
enabled: Mapped[bool] = mapped_column(default=True)
|
||||||
|
created_at: Mapped[datetime] = mapped_column(DateTime, server_default=func.now())
|
||||||
|
|
||||||
|
task = relationship("ScheduledTask", back_populates="alert_rules")
|
||||||
|
channel = relationship("NotificationChannel", back_populates="alert_rules")
|
||||||
|
|
||||||
|
def __repr__(self) -> str:
|
||||||
|
return f"<AlertRule task={self.task_id} → channel={self.channel_id}>"
|
||||||
|
|
||||||
|
|
||||||
|
class Alert(Base):
|
||||||
|
"""告警历史"""
|
||||||
|
__tablename__ = "alerts"
|
||||||
|
|
||||||
|
id: Mapped[int] = mapped_column(primary_key=True, autoincrement=True)
|
||||||
|
task_id: Mapped[int] = mapped_column(ForeignKey("scheduled_tasks.id", ondelete="SET NULL"), nullable=True)
|
||||||
|
alert_type: Mapped[str] = mapped_column(String(32), nullable=False, comment="missed_run / failure / timeout")
|
||||||
|
message: Mapped[str] = mapped_column(Text, nullable=False, comment="告警内容")
|
||||||
|
status: Mapped[str] = mapped_column(String(32), default="pending", comment="pending / acknowledged / resolved")
|
||||||
|
acknowledged_at: Mapped[datetime | None] = mapped_column(DateTime, nullable=True, comment="确认时间")
|
||||||
|
created_at: Mapped[datetime] = mapped_column(DateTime, server_default=func.now())
|
||||||
|
|
||||||
|
def __repr__(self) -> str:
|
||||||
|
return f"<Alert #{self.id} type={self.alert_type} status={self.status}>"
|
||||||
21
backend/app/models/system_config.py
Normal file
21
backend/app/models/system_config.py
Normal file
@ -0,0 +1,21 @@
|
|||||||
|
"""SQLAlchemy models — SystemConfig (key-value store for system settings)."""
|
||||||
|
|
||||||
|
from datetime import datetime
|
||||||
|
|
||||||
|
from sqlalchemy import DateTime, String, Text, func
|
||||||
|
from sqlalchemy.orm import Mapped, mapped_column
|
||||||
|
|
||||||
|
from app.database import Base
|
||||||
|
|
||||||
|
|
||||||
|
class SystemConfig(Base):
|
||||||
|
"""System-level configuration stored as key-value pairs."""
|
||||||
|
__tablename__ = "system_config"
|
||||||
|
|
||||||
|
key: Mapped[str] = mapped_column(String(128), primary_key=True, comment="配置键名")
|
||||||
|
value: Mapped[str] = mapped_column(Text, nullable=False, comment="配置值 (JSON)")
|
||||||
|
description: Mapped[str] = mapped_column(String(256), default="", comment="描述")
|
||||||
|
updated_at: Mapped[datetime] = mapped_column(DateTime, server_default=func.now(), onupdate=func.now())
|
||||||
|
|
||||||
|
def __repr__(self) -> str:
|
||||||
|
return f"<SystemConfig {self.key}={self.value}>"
|
||||||
36
backend/app/models/task.py
Normal file
36
backend/app/models/task.py
Normal file
@ -0,0 +1,36 @@
|
|||||||
|
"""SQLAlchemy models — ScheduledTask."""
|
||||||
|
|
||||||
|
from datetime import datetime
|
||||||
|
|
||||||
|
from sqlalchemy import Boolean, DateTime, ForeignKey, Integer, String, Text, func
|
||||||
|
from sqlalchemy.orm import Mapped, mapped_column, relationship
|
||||||
|
|
||||||
|
from app.database import Base
|
||||||
|
|
||||||
|
|
||||||
|
class ScheduledTask(Base):
|
||||||
|
__tablename__ = "scheduled_tasks"
|
||||||
|
|
||||||
|
id: Mapped[int] = mapped_column(primary_key=True, autoincrement=True)
|
||||||
|
agent_id: Mapped[int] = mapped_column(ForeignKey("agents.id", ondelete="CASCADE"), nullable=False)
|
||||||
|
name: Mapped[str] = mapped_column(String(256), nullable=False, comment="任务名称")
|
||||||
|
description: Mapped[str] = mapped_column(Text, default="", comment="任务描述")
|
||||||
|
cron_expression: Mapped[str] = mapped_column(String(64), nullable=False, comment="Cron 表达式, e.g. */5 * * * *")
|
||||||
|
grace_period: Mapped[int] = mapped_column(Integer, default=300, comment="容忍秒数, 超时未执行则告警")
|
||||||
|
status: Mapped[str] = mapped_column(String(32), default="active", comment="active / paused / stopped")
|
||||||
|
|
||||||
|
# 冗余字段方便查询
|
||||||
|
last_run_at: Mapped[datetime | None] = mapped_column(DateTime, nullable=True, comment="最近一次执行时间")
|
||||||
|
last_run_result: Mapped[str | None] = mapped_column(String(32), nullable=True, comment="最近一次执行结果 success/failed")
|
||||||
|
next_run_at: Mapped[datetime | None] = mapped_column(DateTime, nullable=True, comment="预计下次执行时间")
|
||||||
|
total_run_count: Mapped[int] = mapped_column(Integer, default=0, comment="累计执行次数")
|
||||||
|
|
||||||
|
created_at: Mapped[datetime] = mapped_column(DateTime, server_default=func.now())
|
||||||
|
updated_at: Mapped[datetime] = mapped_column(DateTime, server_default=func.now(), onupdate=func.now())
|
||||||
|
|
||||||
|
agent = relationship("Agent", back_populates="tasks")
|
||||||
|
executions = relationship("TaskExecution", back_populates="task", cascade="all, delete-orphan")
|
||||||
|
alert_rules = relationship("AlertRule", back_populates="task", cascade="all, delete-orphan")
|
||||||
|
|
||||||
|
def __repr__(self) -> str:
|
||||||
|
return f"<ScheduledTask {self.name}>"
|
||||||
23
backend/app/schemas/__init__.py
Normal file
23
backend/app/schemas/__init__.py
Normal file
@ -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",
|
||||||
|
]
|
||||||
57
backend/app/schemas/agent.py
Normal file
57
backend/app/schemas/agent.py
Normal file
@ -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
|
||||||
31
backend/app/schemas/execution.py
Normal file
31
backend/app/schemas/execution.py
Normal file
@ -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}
|
||||||
70
backend/app/schemas/notification.py
Normal file
70
backend/app/schemas/notification.py
Normal file
@ -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
|
||||||
39
backend/app/schemas/task.py
Normal file
39
backend/app/schemas/task.py
Normal file
@ -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}
|
||||||
15
backend/app/services/__init__.py
Normal file
15
backend/app/services/__init__.py
Normal file
@ -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",
|
||||||
|
]
|
||||||
54
backend/app/services/agent.py
Normal file
54
backend/app/services/agent.py
Normal file
@ -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
|
||||||
69
backend/app/services/execution.py
Normal file
69
backend/app/services/execution.py
Normal file
@ -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())
|
||||||
88
backend/app/services/notification.py
Normal file
88
backend/app/services/notification.py
Normal file
@ -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)
|
||||||
103
backend/app/services/scheduler.py
Normal file
103
backend/app/services/scheduler.py
Normal file
@ -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)
|
||||||
102
backend/app/services/task.py
Normal file
102
backend/app/services/task.py
Normal file
@ -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
|
||||||
14
backend/requirements.txt
Normal file
14
backend/requirements.txt
Normal file
@ -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
|
||||||
18
docker-compose.yml
Normal file
18
docker-compose.yml
Normal file
@ -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"
|
||||||
12
frontend/index.html
Normal file
12
frontend/index.html
Normal file
@ -0,0 +1,12 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="zh-CN">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8" />
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||||
|
<title>TaskPulse — AI Agent Task Monitor</title>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div id="app"></div>
|
||||||
|
<script type="module" src="/src/main.js"></script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
1705
frontend/package-lock.json
generated
Normal file
1705
frontend/package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
25
frontend/package.json
Normal file
25
frontend/package.json
Normal file
@ -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"
|
||||||
|
}
|
||||||
|
}
|
||||||
204
frontend/src/App.vue
Normal file
204
frontend/src/App.vue
Normal file
@ -0,0 +1,204 @@
|
|||||||
|
<template>
|
||||||
|
<div id="app-container">
|
||||||
|
<!-- Sidebar -->
|
||||||
|
<aside class="sidebar">
|
||||||
|
<div class="logo">
|
||||||
|
<div class="logo-icon">
|
||||||
|
<svg width="28" height="28" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
||||||
|
<circle cx="12" cy="12" r="10"/>
|
||||||
|
<polyline points="12 6 12 12 16 14"/>
|
||||||
|
</svg>
|
||||||
|
</div>
|
||||||
|
<span class="logo-text">TaskPulse</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<nav class="nav-menu">
|
||||||
|
<router-link to="/" class="nav-item" :class="{ active: currentRoute === '/' }">
|
||||||
|
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><rect x="3" y="3" width="7" height="7"/><rect x="14" y="3" width="7" height="7"/><rect x="14" y="14" width="7" height="7"/><rect x="3" y="14" width="7" height="7"/></svg>
|
||||||
|
<span>仪表盘</span>
|
||||||
|
</router-link>
|
||||||
|
<router-link to="/tasks" class="nav-item" :class="{ active: currentRoute.startsWith('/tasks') }">
|
||||||
|
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><line x1="8" y1="6" x2="21" y2="6"/><line x1="8" y1="12" x2="21" y2="12"/><line x1="8" y1="18" x2="21" y2="18"/><line x1="3" y1="6" x2="3.01" y2="6"/><line x1="3" y1="12" x2="3.01" y2="12"/><line x1="3" y1="18" x2="3.01" y2="18"/></svg>
|
||||||
|
<span>所有任务</span>
|
||||||
|
</router-link>
|
||||||
|
<router-link to="/agents" class="nav-item" :class="{ active: currentRoute.startsWith('/agents') }">
|
||||||
|
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M20 21v-2a4 4 0 0 0-4-4H8a4 4 0 0 0-4 4v2"/><circle cx="12" cy="7" r="4"/></svg>
|
||||||
|
<span>Agent 管理</span>
|
||||||
|
</router-link>
|
||||||
|
<router-link to="/alerts" class="nav-item" :class="{ active: currentRoute.startsWith('/alerts') }">
|
||||||
|
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M18 8A6 6 0 0 0 6 8c0 7-3 9-3 9h18s-3-2-3-9"/><path d="M13.73 21a2 2 0 0 1-3.46 0"/></svg>
|
||||||
|
<span>告警历史</span>
|
||||||
|
</router-link>
|
||||||
|
<router-link to="/settings" class="nav-item" :class="{ active: currentRoute.startsWith('/settings') }">
|
||||||
|
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><circle cx="12" cy="12" r="3"/><path d="M19.4 15a1.65 1.65 0 0 0 .33 1.82l.06.06a2 2 0 0 1 0 2.83 2 2 0 0 1-2.83 0l-.06-.06a1.65 1.65 0 0 0-1.82-.33 1.65 1.65 0 0 0-1 1.51V21a2 2 0 0 1-2 2 2 2 0 0 1-2-2v-.09A1.65 1.65 0 0 0 9 19.4a1.65 1.65 0 0 0-1.82.33l-.06.06a2 2 0 0 1-2.83 0 2 2 0 0 1 0-2.83l.06-.06A1.65 1.65 0 0 0 4.68 15a1.65 1.65 0 0 0-1.51-1H3a2 2 0 0 1-2-2 2 2 0 0 1 2-2h.09A1.65 1.65 0 0 0 4.6 9a1.65 1.65 0 0 0-.33-1.82l-.06-.06a2 2 0 0 1 0-2.83 2 2 0 0 1 2.83 0l.06.06A1.65 1.65 0 0 0 9 4.68a1.65 1.65 0 0 0 1-1.51V3a2 2 0 0 1 2-2 2 2 0 0 1 2 2v.09a1.65 1.65 0 0 0 1 1.51 1.65 1.65 0 0 0 1.82-.33l.06-.06a2 2 0 0 1 2.83 0 2 2 0 0 1 0 2.83l-.06.06a1.65 1.65 0 0 0-.33 1.82V9a1.65 1.65 0 0 0 1.51 1H21a2 2 0 0 1 2 2 2 2 0 0 1-2 2h-.09a1.65 1.65 0 0 0-1.51 1z"/></svg>
|
||||||
|
<span>系统配置</span>
|
||||||
|
</router-link>
|
||||||
|
</nav>
|
||||||
|
|
||||||
|
<div class="sidebar-footer">
|
||||||
|
<div class="status-dot"></div>
|
||||||
|
<span>系统运行中</span>
|
||||||
|
</div>
|
||||||
|
</aside>
|
||||||
|
|
||||||
|
<!-- Main Content -->
|
||||||
|
<div class="main-area">
|
||||||
|
<header class="topbar">
|
||||||
|
<h2 class="page-title">{{ pageTitle }}</h2>
|
||||||
|
<div class="topbar-right">
|
||||||
|
<span class="version-badge">v1.0</span>
|
||||||
|
</div>
|
||||||
|
</header>
|
||||||
|
<main class="content">
|
||||||
|
<router-view />
|
||||||
|
</main>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup>
|
||||||
|
import { computed } from 'vue'
|
||||||
|
import { useRoute } from 'vue-router'
|
||||||
|
|
||||||
|
const route = useRoute()
|
||||||
|
const currentRoute = computed(() => route.path)
|
||||||
|
|
||||||
|
const routeTitles = {
|
||||||
|
'/': '仪表盘',
|
||||||
|
'/tasks': '所有任务',
|
||||||
|
'/agents': 'Agent 管理',
|
||||||
|
'/alerts': '告警历史',
|
||||||
|
'/settings': '系统配置',
|
||||||
|
}
|
||||||
|
const pageTitle = computed(() => routeTitles[route.path] || 'TaskPulse')
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
#app-container {
|
||||||
|
display: flex;
|
||||||
|
height: 100vh;
|
||||||
|
background: #0a0a0f;
|
||||||
|
color: #e0e0e0;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── Sidebar ───────────────────────────────────── */
|
||||||
|
.sidebar {
|
||||||
|
width: 220px;
|
||||||
|
min-width: 220px;
|
||||||
|
background: linear-gradient(180deg, #0f0f1a 0%, #1a1a2e 100%);
|
||||||
|
border-right: 1px solid rgba(255,255,255,0.05);
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
padding: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.logo {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 10px;
|
||||||
|
padding: 24px 20px 20px;
|
||||||
|
border-bottom: 1px solid rgba(255,255,255,0.06);
|
||||||
|
}
|
||||||
|
.logo-icon {
|
||||||
|
width: 36px;
|
||||||
|
height: 36px;
|
||||||
|
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
|
||||||
|
border-radius: 10px;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
color: #fff;
|
||||||
|
}
|
||||||
|
.logo-text {
|
||||||
|
font-size: 18px;
|
||||||
|
font-weight: 700;
|
||||||
|
background: linear-gradient(135deg, #a8b4ff 0%, #c084fc 100%);
|
||||||
|
-webkit-background-clip: text;
|
||||||
|
-webkit-text-fill-color: transparent;
|
||||||
|
background-clip: text;
|
||||||
|
}
|
||||||
|
|
||||||
|
.nav-menu {
|
||||||
|
flex: 1;
|
||||||
|
padding: 12px 10px;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 2px;
|
||||||
|
}
|
||||||
|
.nav-item {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 10px;
|
||||||
|
padding: 10px 14px;
|
||||||
|
border-radius: 10px;
|
||||||
|
color: rgba(255,255,255,0.55);
|
||||||
|
text-decoration: none;
|
||||||
|
font-size: 14px;
|
||||||
|
transition: all 0.2s;
|
||||||
|
}
|
||||||
|
.nav-item:hover {
|
||||||
|
color: #fff;
|
||||||
|
background: rgba(255,255,255,0.06);
|
||||||
|
}
|
||||||
|
.nav-item.active {
|
||||||
|
color: #fff;
|
||||||
|
background: linear-gradient(135deg, rgba(102,126,234,0.2) 0%, rgba(118,75,162,0.2) 100%);
|
||||||
|
border: 1px solid rgba(102,126,234,0.15);
|
||||||
|
}
|
||||||
|
|
||||||
|
.sidebar-footer {
|
||||||
|
padding: 16px 20px;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
font-size: 12px;
|
||||||
|
color: rgba(255,255,255,0.35);
|
||||||
|
border-top: 1px solid rgba(255,255,255,0.06);
|
||||||
|
}
|
||||||
|
.status-dot {
|
||||||
|
width: 6px;
|
||||||
|
height: 6px;
|
||||||
|
border-radius: 50%;
|
||||||
|
background: #22c55e;
|
||||||
|
box-shadow: 0 0 6px rgba(34,197,94,0.5);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── Main ──────────────────────────────────────── */
|
||||||
|
.main-area {
|
||||||
|
flex: 1;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
min-width: 0;
|
||||||
|
background: #0a0a0f;
|
||||||
|
}
|
||||||
|
|
||||||
|
.topbar {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
padding: 16px 28px;
|
||||||
|
background: rgba(15,15,26,0.8);
|
||||||
|
backdrop-filter: blur(12px);
|
||||||
|
border-bottom: 1px solid rgba(255,255,255,0.05);
|
||||||
|
}
|
||||||
|
.page-title {
|
||||||
|
font-size: 18px;
|
||||||
|
font-weight: 600;
|
||||||
|
color: #e0e0e0;
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
.version-badge {
|
||||||
|
font-size: 11px;
|
||||||
|
padding: 3px 10px;
|
||||||
|
border-radius: 20px;
|
||||||
|
background: rgba(255,255,255,0.06);
|
||||||
|
color: rgba(255,255,255,0.4);
|
||||||
|
border: 1px solid rgba(255,255,255,0.08);
|
||||||
|
}
|
||||||
|
|
||||||
|
.content {
|
||||||
|
flex: 1;
|
||||||
|
overflow-y: auto;
|
||||||
|
padding: 24px 28px;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
49
frontend/src/api/index.js
Normal file
49
frontend/src/api/index.js
Normal file
@ -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
|
||||||
232
frontend/src/assets/style.css
Normal file
232
frontend/src/assets/style.css
Normal file
@ -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;
|
||||||
|
}
|
||||||
19
frontend/src/main.js
Normal file
19
frontend/src/main.js
Normal file
@ -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')
|
||||||
23
frontend/src/router/index.js
Normal file
23
frontend/src/router/index.js
Normal file
@ -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
|
||||||
237
frontend/src/views/Agents.vue
Normal file
237
frontend/src/views/Agents.vue
Normal file
@ -0,0 +1,237 @@
|
|||||||
|
<template>
|
||||||
|
<div class="agents-page">
|
||||||
|
<!-- Quick onboard -->
|
||||||
|
<div class="section mb-24">
|
||||||
|
<div class="section-header">
|
||||||
|
<h3>Agent 接入指南</h3>
|
||||||
|
</div>
|
||||||
|
<div class="onboard-body">
|
||||||
|
<p>让 AI Agent 通过以下方式自动注册,无需手动填写。</p>
|
||||||
|
<div class="code-block">
|
||||||
|
<div class="code-tabs">
|
||||||
|
<button :class="{ active: tab === 'curl' }" @click="tab = 'curl'">curl</button>
|
||||||
|
<button :class="{ active: tab === 'python' }" @click="tab = 'python'">Python</button>
|
||||||
|
<button :class="{ active: tab === 'batch' }" @click="tab = 'batch'">批量注册</button>
|
||||||
|
</div>
|
||||||
|
<div class="code-body">
|
||||||
|
<pre>{{ codes[tab] }}</pre>
|
||||||
|
</div>
|
||||||
|
<button class="btn-copy" @click="copyCode">复制</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Agent list -->
|
||||||
|
<div class="section">
|
||||||
|
<div class="section-header">
|
||||||
|
<h3>已注册 Agent</h3>
|
||||||
|
</div>
|
||||||
|
<div class="table-wrap">
|
||||||
|
<table class="data-table">
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th>ID</th>
|
||||||
|
<th>名称</th>
|
||||||
|
<th>描述</th>
|
||||||
|
<th>状态</th>
|
||||||
|
<th>任务数</th>
|
||||||
|
<th>最后心跳</th>
|
||||||
|
<th>API Key</th>
|
||||||
|
<th></th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
<tr v-for="a in agents" :key="a.id">
|
||||||
|
<td class="cell-mono">#{{ a.id }}</td>
|
||||||
|
<td class="cell-name">{{ a.name }}</td>
|
||||||
|
<td class="cell-muted">{{ a.description || '—' }}</td>
|
||||||
|
<td>
|
||||||
|
<span class="tag" :class="a.status === 'active' ? 'tag-green' : 'tag-gray'">
|
||||||
|
{{ a.status === 'active' ? '活跃' : '停用' }}
|
||||||
|
</span>
|
||||||
|
</td>
|
||||||
|
<td class="cell-mono">{{ a.task_count }}</td>
|
||||||
|
<td class="cell-mono">{{ a.last_heartbeat_at ? dayjs(a.last_heartbeat_at).format('MM-DD HH:mm') : '—' }}</td>
|
||||||
|
<td>
|
||||||
|
<code class="cell-key">{{ a.api_key.substring(0, 16) }}...</code>
|
||||||
|
<button class="btn-icon" @click="copyKey(a.api_key)" title="复制 API Key">
|
||||||
|
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><rect x="9" y="9" width="13" height="13" rx="2"/><path d="M5 15H4a2 2 0 0 1-2-2V4a2 2 0 0 1 2-2h9a2 2 0 0 1 2 2v1"/></svg>
|
||||||
|
</button>
|
||||||
|
</td>
|
||||||
|
<td>
|
||||||
|
<router-link to="/tasks" class="cell-link">任务</router-link>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
<tr v-if="agents.length === 0">
|
||||||
|
<td colspan="8" class="cell-empty">暂无 Agent — 让 AI Agent 调用上方 API 自动注册</td>
|
||||||
|
</tr>
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup>
|
||||||
|
import { ref, onMounted, computed } from 'vue'
|
||||||
|
import dayjs from 'dayjs'
|
||||||
|
import { listAgents, getSystemConfig } from '../api/index.js'
|
||||||
|
|
||||||
|
const agents = ref([])
|
||||||
|
const tab = ref('curl')
|
||||||
|
const baseUrl = ref(window.location.origin)
|
||||||
|
|
||||||
|
const loadData = async () => {
|
||||||
|
const [agentRes, cfgRes] = await Promise.all([
|
||||||
|
listAgents(),
|
||||||
|
getSystemConfig(),
|
||||||
|
])
|
||||||
|
agents.value = agentRes.data
|
||||||
|
baseUrl.value = cfgRes.data.base_url
|
||||||
|
}
|
||||||
|
|
||||||
|
const copyKey = async (key) => {
|
||||||
|
try {
|
||||||
|
await navigator.clipboard.writeText(key)
|
||||||
|
const { ElMessage } = await import('element-plus')
|
||||||
|
ElMessage.success('已复制 API Key')
|
||||||
|
} catch {}
|
||||||
|
}
|
||||||
|
|
||||||
|
const codes = computed(() => {
|
||||||
|
const b = baseUrl.value
|
||||||
|
return {
|
||||||
|
curl: `# 注册单个 Agent
|
||||||
|
curl -X POST ${b}/api/agents \\
|
||||||
|
-H "Content-Type: application/json" \\
|
||||||
|
-d '{"name": "my-agent", "description": "你的 AI Agent"}'`,
|
||||||
|
python: `import requests\n\nresp = requests.post("${b}/api/agents", json={\n "name": "my-agent",\n "description": "你的 AI Agent"\n})\nprint(resp.json())`,
|
||||||
|
batch: `curl -X POST ${b}/api/agents/register-with-tasks \\\n -H "Content-Type: application/json" \\\n -d '{\n "name": "my-agent",\n "description": "全能数据Agent",\n "tasks": [\n {"name": "sync-data", "cron_expression": "*/5 * * * *"},\n {"name": "daily-report", "cron_expression": "0 9 * * *"}\n ]\n }'`,
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
const copyCode = async () => {
|
||||||
|
try {
|
||||||
|
await navigator.clipboard.writeText(codes.value[tab.value])
|
||||||
|
const { ElMessage } = await import('element-plus')
|
||||||
|
ElMessage.success('已复制')
|
||||||
|
} catch {}
|
||||||
|
}
|
||||||
|
|
||||||
|
onMounted(loadData)
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.agents-page { max-width: 1200px; }
|
||||||
|
.mb-24 { margin-bottom: 24px; }
|
||||||
|
|
||||||
|
.section {
|
||||||
|
background: var(--bg-card);
|
||||||
|
border: 1px solid var(--border-color);
|
||||||
|
border-radius: 14px;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
.section-header {
|
||||||
|
padding: 16px 20px;
|
||||||
|
border-bottom: 1px solid var(--border-color);
|
||||||
|
}
|
||||||
|
.section-header h3 { font-size: 15px; font-weight: 600; color: #e0e0f0; margin: 0; }
|
||||||
|
|
||||||
|
.onboard-body {
|
||||||
|
padding: 20px;
|
||||||
|
}
|
||||||
|
.onboard-body > p {
|
||||||
|
font-size: 13px;
|
||||||
|
color: var(--text-secondary);
|
||||||
|
margin-bottom: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.code-block {
|
||||||
|
position: relative;
|
||||||
|
background: rgba(0,0,0,0.3);
|
||||||
|
border-radius: 10px;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
.code-tabs {
|
||||||
|
display: flex;
|
||||||
|
border-bottom: 1px solid rgba(255,255,255,0.06);
|
||||||
|
}
|
||||||
|
.code-tabs button {
|
||||||
|
padding: 8px 16px;
|
||||||
|
background: none;
|
||||||
|
border: none;
|
||||||
|
color: rgba(255,255,255,0.4);
|
||||||
|
font-size: 12px;
|
||||||
|
cursor: pointer;
|
||||||
|
border-bottom: 2px solid transparent;
|
||||||
|
}
|
||||||
|
.code-tabs button.active {
|
||||||
|
color: #a8b4ff;
|
||||||
|
border-bottom-color: #667eea;
|
||||||
|
}
|
||||||
|
.code-body { padding: 16px; overflow-x: auto; }
|
||||||
|
.code-body pre {
|
||||||
|
font-family: 'JetBrains Mono', 'Fira Code', monospace;
|
||||||
|
font-size: 12px;
|
||||||
|
line-height: 1.7;
|
||||||
|
color: rgba(255,255,255,0.75);
|
||||||
|
white-space: pre;
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
.btn-copy {
|
||||||
|
position: absolute;
|
||||||
|
top: 44px;
|
||||||
|
right: 12px;
|
||||||
|
padding: 4px 12px;
|
||||||
|
border-radius: 6px;
|
||||||
|
border: 1px solid rgba(255,255,255,0.1);
|
||||||
|
background: rgba(255,255,255,0.06);
|
||||||
|
color: rgba(255,255,255,0.5);
|
||||||
|
font-size: 11px;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
.btn-copy:hover { background: rgba(255,255,255,0.1); color: #fff; }
|
||||||
|
|
||||||
|
/* Table */
|
||||||
|
.table-wrap { overflow-x: auto; }
|
||||||
|
.data-table { width: 100%; border-collapse: collapse; }
|
||||||
|
.data-table th {
|
||||||
|
text-align: left;
|
||||||
|
padding: 12px 16px;
|
||||||
|
font-size: 11px;
|
||||||
|
font-weight: 500;
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 0.5px;
|
||||||
|
color: var(--text-secondary);
|
||||||
|
background: rgba(255,255,255,0.02);
|
||||||
|
border-bottom: 1px solid var(--border-color);
|
||||||
|
}
|
||||||
|
.data-table td {
|
||||||
|
padding: 12px 16px;
|
||||||
|
font-size: 13px;
|
||||||
|
color: var(--text-primary);
|
||||||
|
border-bottom: 1px solid var(--border-color);
|
||||||
|
}
|
||||||
|
.data-table tbody tr:hover { background: rgba(255,255,255,0.02); }
|
||||||
|
.cell-name { font-weight: 500; }
|
||||||
|
.cell-mono { font-family: 'JetBrains Mono', monospace; font-size: 12px; color: var(--text-secondary); }
|
||||||
|
.cell-muted { color: var(--text-muted); }
|
||||||
|
.cell-key { font-family: 'JetBrains Mono', monospace; font-size: 11px; color: var(--text-muted); }
|
||||||
|
.cell-empty { text-align: center; color: var(--text-muted); padding: 40px 16px !important; }
|
||||||
|
.cell-link { color: var(--accent-1); text-decoration: none; font-size: 13px; }
|
||||||
|
.cell-link:hover { text-decoration: underline; }
|
||||||
|
|
||||||
|
.tag { display: inline-block; padding: 2px 10px; border-radius: 20px; font-size: 12px; font-weight: 500; }
|
||||||
|
.tag-green { background: rgba(34,197,94,0.12); color: #4ade80; }
|
||||||
|
.tag-gray { background: rgba(255,255,255,0.05); color: var(--text-secondary); }
|
||||||
|
|
||||||
|
.btn-icon {
|
||||||
|
background: none;
|
||||||
|
border: none;
|
||||||
|
color: var(--text-muted);
|
||||||
|
cursor: pointer;
|
||||||
|
padding: 4px;
|
||||||
|
vertical-align: middle;
|
||||||
|
}
|
||||||
|
.btn-icon:hover { color: var(--accent-1); }
|
||||||
|
</style>
|
||||||
156
frontend/src/views/Alerts.vue
Normal file
156
frontend/src/views/Alerts.vue
Normal file
@ -0,0 +1,156 @@
|
|||||||
|
<template>
|
||||||
|
<div class="alerts-page">
|
||||||
|
<div class="section">
|
||||||
|
<div class="section-header">
|
||||||
|
<h3>告警历史</h3>
|
||||||
|
<div class="filter-group">
|
||||||
|
<button :class="{ active: filterStatus === '' }" @click="filterStatus = ''; loadData()">全部</button>
|
||||||
|
<button :class="{ active: filterStatus === 'pending' }" @click="filterStatus = 'pending'; loadData()">待处理</button>
|
||||||
|
<button :class="{ active: filterStatus === 'acknowledged' }" @click="filterStatus = 'acknowledged'; loadData()">已确认</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="alert-list">
|
||||||
|
<div v-for="a in alerts" :key="a.id" class="alert-item" :class="{ 'alert-pending': a.status === 'pending' }">
|
||||||
|
<div class="alert-left">
|
||||||
|
<div class="alert-dot" :class="a.status === 'pending' ? 'dot-red' : 'dot-gray'"></div>
|
||||||
|
</div>
|
||||||
|
<div class="alert-body">
|
||||||
|
<div class="alert-top">
|
||||||
|
<span class="tag" :class="a.alert_type === 'missed_run' ? 'tag-warn' : 'tag-red'">
|
||||||
|
{{ a.alert_type === 'missed_run' ? '未按时执行' : '执行失败' }}
|
||||||
|
</span>
|
||||||
|
<span class="alert-task">{{ a.task_name || '未知任务' }}</span>
|
||||||
|
<span class="alert-time">{{ dayjs(a.created_at).format('MM-DD HH:mm') }}</span>
|
||||||
|
</div>
|
||||||
|
<div class="alert-msg">{{ a.message }}</div>
|
||||||
|
<div class="alert-bottom">
|
||||||
|
<span class="alert-status" :class="a.status === 'pending' ? 'text-red' : 'text-muted'">
|
||||||
|
{{ a.status === 'pending' ? '待处理' : '已确认' }}
|
||||||
|
</span>
|
||||||
|
<button v-if="a.status === 'pending'" class="btn-ack" @click="handleAcknowledge(a)">确认</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div v-if="alerts.length === 0" class="empty-state">
|
||||||
|
<svg width="40" height="40" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" style="color:var(--text-muted);margin-bottom:12px"><path d="M18 8A6 6 0 0 0 6 8c0 7-3 9-3 9h18s-3-2-3-9"/><path d="M13.73 21a2 2 0 0 1-3.46 0"/></svg>
|
||||||
|
<p>暂无告警</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup>
|
||||||
|
import { ref, onMounted } from 'vue'
|
||||||
|
import dayjs from 'dayjs'
|
||||||
|
import { listAlerts, acknowledgeAlert } from '../api/index.js'
|
||||||
|
|
||||||
|
const alerts = ref([])
|
||||||
|
const filterStatus = ref('')
|
||||||
|
|
||||||
|
const loadData = async () => {
|
||||||
|
const params = { limit: 200 }
|
||||||
|
if (filterStatus.value) params.status = filterStatus.value
|
||||||
|
const res = await listAlerts(params)
|
||||||
|
alerts.value = res.data
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleAcknowledge = async (row) => {
|
||||||
|
try {
|
||||||
|
await acknowledgeAlert(row.id)
|
||||||
|
await loadData()
|
||||||
|
} catch (e) {
|
||||||
|
console.error(e)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
onMounted(loadData)
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.alerts-page { max-width: 1200px; }
|
||||||
|
|
||||||
|
.section {
|
||||||
|
background: var(--bg-card);
|
||||||
|
border: 1px solid var(--border-color);
|
||||||
|
border-radius: 14px;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
.section-header {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: center;
|
||||||
|
padding: 16px 20px;
|
||||||
|
border-bottom: 1px solid var(--border-color);
|
||||||
|
}
|
||||||
|
.section-header h3 { font-size: 15px; font-weight: 600; color: #e0e0f0; margin: 0; }
|
||||||
|
|
||||||
|
.filter-group { display: flex; gap: 4px; background: rgba(255,255,255,0.04); border-radius: 8px; padding: 3px; }
|
||||||
|
.filter-group button {
|
||||||
|
padding: 5px 14px; border-radius: 6px;
|
||||||
|
background: none; border: none;
|
||||||
|
color: var(--text-muted); font-size: 12px; cursor: pointer;
|
||||||
|
transition: all 0.15s;
|
||||||
|
}
|
||||||
|
.filter-group button.active {
|
||||||
|
background: var(--accent-1);
|
||||||
|
color: #fff;
|
||||||
|
}
|
||||||
|
|
||||||
|
.alert-list { padding: 0; }
|
||||||
|
.alert-item {
|
||||||
|
display: flex;
|
||||||
|
gap: 14px;
|
||||||
|
padding: 16px 20px;
|
||||||
|
border-bottom: 1px solid var(--border-color);
|
||||||
|
transition: background 0.15s;
|
||||||
|
}
|
||||||
|
.alert-item:last-child { border-bottom: none; }
|
||||||
|
.alert-item:hover { background: rgba(255,255,255,0.02); }
|
||||||
|
.alert-pending { background: rgba(239,68,68,0.03); }
|
||||||
|
|
||||||
|
.alert-left { padding-top: 2px; }
|
||||||
|
.alert-dot { width: 8px; height: 8px; border-radius: 50%; margin-top: 4px; }
|
||||||
|
.dot-red { background: #ef4444; box-shadow: 0 0 8px rgba(239,68,68,0.4); }
|
||||||
|
.dot-gray { background: rgba(255,255,255,0.15); }
|
||||||
|
|
||||||
|
.alert-body { flex: 1; min-width: 0; }
|
||||||
|
.alert-top { display: flex; align-items: center; gap: 8px; margin-bottom: 6px; }
|
||||||
|
.alert-task { font-size: 13px; font-weight: 500; color: var(--text-primary); }
|
||||||
|
.alert-time { font-size: 11px; color: var(--text-muted); margin-left: auto; }
|
||||||
|
.alert-msg {
|
||||||
|
font-size: 12px;
|
||||||
|
color: var(--text-secondary);
|
||||||
|
line-height: 1.5;
|
||||||
|
margin-bottom: 8px;
|
||||||
|
display: -webkit-box;
|
||||||
|
-webkit-line-clamp: 2;
|
||||||
|
-webkit-box-orient: vertical;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
.alert-bottom { display: flex; align-items: center; gap: 12px; }
|
||||||
|
.alert-status { font-size: 11px; }
|
||||||
|
.text-red { color: #f87171; }
|
||||||
|
.text-muted { color: var(--text-muted); }
|
||||||
|
.btn-ack {
|
||||||
|
padding: 3px 12px;
|
||||||
|
border-radius: 6px;
|
||||||
|
border: 1px solid rgba(102,126,234,0.3);
|
||||||
|
background: rgba(102,126,234,0.1);
|
||||||
|
color: #a8b4ff;
|
||||||
|
font-size: 11px;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
.btn-ack:hover { background: rgba(102,126,234,0.2); }
|
||||||
|
|
||||||
|
.empty-state {
|
||||||
|
text-align: center;
|
||||||
|
padding: 60px 20px;
|
||||||
|
color: var(--text-muted);
|
||||||
|
font-size: 14px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tag { display: inline-block; padding: 2px 10px; border-radius: 20px; font-size: 11px; font-weight: 500; }
|
||||||
|
.tag-warn { background: rgba(245,158,11,0.12); color: #fbbf24; }
|
||||||
|
.tag-red { background: rgba(239,68,68,0.12); color: #f87171; }
|
||||||
|
</style>
|
||||||
541
frontend/src/views/Dashboard.vue
Normal file
541
frontend/src/views/Dashboard.vue
Normal file
@ -0,0 +1,541 @@
|
|||||||
|
<template>
|
||||||
|
<div class="dashboard">
|
||||||
|
<!-- AI Quick-Start Panel -->
|
||||||
|
<div class="onboarding-panel" v-if="showOnboarding">
|
||||||
|
<div class="onboarding-glow"></div>
|
||||||
|
<div class="onboarding-content">
|
||||||
|
<div class="onboarding-icon">
|
||||||
|
<svg width="32" height="32" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5">
|
||||||
|
<path d="M12 2a4 4 0 0 1 4 4v2a4 4 0 0 1-8 0V6a4 4 0 0 1 4-4z"/>
|
||||||
|
<path d="M5 15h14l-1.5 6H6.5L5 15z"/>
|
||||||
|
<circle cx="12" cy="18" r="1" fill="currentColor"/>
|
||||||
|
</svg>
|
||||||
|
</div>
|
||||||
|
<div class="onboarding-text">
|
||||||
|
<h3>让 AI Agent 自动接入</h3>
|
||||||
|
<p>只需将下方指令发送给您的 AI Agent,它将自动注册并开始汇报任务执行情况。</p>
|
||||||
|
</div>
|
||||||
|
<button class="btn-primary" @click="showScript = !showScript">
|
||||||
|
{{ showScript ? '收起指令' : '生成接入指令' }}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div v-if="showScript" class="onboarding-script">
|
||||||
|
<div class="script-tabs">
|
||||||
|
<button :class="{ active: scriptTab === 'curl' }" @click="scriptTab = 'curl'">curl</button>
|
||||||
|
<button :class="{ active: scriptTab === 'python' }" @click="scriptTab = 'python'">Python</button>
|
||||||
|
</div>
|
||||||
|
<div class="script-body">
|
||||||
|
<pre v-if="scriptTab === 'curl'">{{ curlScript }}</pre>
|
||||||
|
<pre v-else>{{ pythonScript }}</pre>
|
||||||
|
</div>
|
||||||
|
<button class="btn-copy" @click="copyScript">复制</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<button class="onboarding-close" @click="showOnboarding = false">
|
||||||
|
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><line x1="18" y1="6" x2="6" y2="18"/><line x1="6" y1="6" x2="18" y2="18"/></svg>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Summary Cards -->
|
||||||
|
<div class="stats-grid">
|
||||||
|
<div class="stat-card" style="--card-accent: #667eea">
|
||||||
|
<div class="stat-icon">
|
||||||
|
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5"><path d="M20 21v-2a4 4 0 0 0-4-4H8a4 4 0 0 0-4 4v2"/><circle cx="12" cy="7" r="4"/></svg>
|
||||||
|
</div>
|
||||||
|
<div class="stat-body">
|
||||||
|
<div class="stat-label">Agent</div>
|
||||||
|
<div class="stat-value">{{ summary.total_agents }}</div>
|
||||||
|
<div class="stat-sub">
|
||||||
|
<span class="stat-dot active"></span>
|
||||||
|
活跃 {{ summary.active_agents }}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="stat-card" style="--card-accent: #22c55e">
|
||||||
|
<div class="stat-icon">
|
||||||
|
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5"><rect x="3" y="3" width="18" height="18" rx="2" ry="2"/><line x1="3" y1="9" x2="21" y2="9"/><line x1="9" y1="21" x2="9" y2="9"/></svg>
|
||||||
|
</div>
|
||||||
|
<div class="stat-body">
|
||||||
|
<div class="stat-label">定时任务</div>
|
||||||
|
<div class="stat-value">{{ summary.total_tasks }}</div>
|
||||||
|
<div class="stat-sub">
|
||||||
|
<span class="stat-dot active"></span>
|
||||||
|
运行中 {{ summary.active_tasks }}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="stat-card" style="--card-accent: #f59e0b">
|
||||||
|
<div class="stat-icon">
|
||||||
|
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5"><circle cx="12" cy="12" r="10"/><polyline points="12 6 12 12 16 14"/></svg>
|
||||||
|
</div>
|
||||||
|
<div class="stat-body">
|
||||||
|
<div class="stat-label">最近24h执行</div>
|
||||||
|
<div class="stat-value">{{ summary.total_executions }}</div>
|
||||||
|
<div class="stat-sub">
|
||||||
|
<span style="color:#4ade80">成功 {{ summary.recent_executions_ok }}</span>
|
||||||
|
<span style="color:#f87171;margin-left:8px">失败 {{ summary.recent_executions_failed }}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="stat-card" :style="{ '--card-accent': summary.pending_alerts > 0 ? '#ef4444' : '#6366f1' }">
|
||||||
|
<div class="stat-icon">
|
||||||
|
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5"><path d="M18 8A6 6 0 0 0 6 8c0 7-3 9-3 9h18s-3-2-3-9"/><path d="M13.73 21a2 2 0 0 1-3.46 0"/></svg>
|
||||||
|
</div>
|
||||||
|
<div class="stat-body">
|
||||||
|
<div class="stat-label">待处理告警</div>
|
||||||
|
<div class="stat-value" :style="{ color: summary.pending_alerts > 0 ? '#f87171' : 'var(--text-secondary)' }">
|
||||||
|
{{ summary.pending_alerts }}
|
||||||
|
</div>
|
||||||
|
<div class="stat-sub">{{ summary.pending_alerts > 0 ? '需要关注' : '一切正常' }}</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Task Overview -->
|
||||||
|
<div class="section">
|
||||||
|
<div class="section-header">
|
||||||
|
<h3>所有定时任务</h3>
|
||||||
|
<router-link to="/tasks" class="section-link">查看全部</router-link>
|
||||||
|
</div>
|
||||||
|
<div class="table-wrap">
|
||||||
|
<table class="data-table">
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th>Agent</th>
|
||||||
|
<th>任务名称</th>
|
||||||
|
<th>Cron</th>
|
||||||
|
<th>状态</th>
|
||||||
|
<th>最近运行</th>
|
||||||
|
<th>结果</th>
|
||||||
|
<th>执行次数</th>
|
||||||
|
<th></th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
<tr v-for="t in tasks" :key="t.id">
|
||||||
|
<td><span class="cell-badge">{{ t.agent_name }}</span></td>
|
||||||
|
<td class="cell-name">{{ t.name }}</td>
|
||||||
|
<td><code class="cell-code">{{ t.cron_expression }}</code></td>
|
||||||
|
<td>
|
||||||
|
<span class="tag" :class="t.status === 'active' ? 'tag-green' : 'tag-gray'">
|
||||||
|
{{ t.status === 'active' ? '运行中' : t.status === 'paused' ? '已暂停' : '已停止' }}
|
||||||
|
</span>
|
||||||
|
</td>
|
||||||
|
<td class="cell-mono">{{ t.last_run_at ? dayjs(t.last_run_at).format('MM-DD HH:mm') : '—' }}</td>
|
||||||
|
<td>
|
||||||
|
<span v-if="t.last_run_result" class="tag" :class="t.last_run_result === 'success' ? 'tag-green' : 'tag-red'">
|
||||||
|
{{ t.last_run_result === 'success' ? '成功' : '失败' }}
|
||||||
|
</span>
|
||||||
|
<span v-else class="cell-muted">—</span>
|
||||||
|
</td>
|
||||||
|
<td class="cell-mono">{{ t.total_run_count }}</td>
|
||||||
|
<td>
|
||||||
|
<router-link :to="`/tasks/${t.id}`" class="cell-link">详情</router-link>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
<tr v-if="tasks.length === 0">
|
||||||
|
<td colspan="8" class="cell-empty">暂无数据 — 让 AI Agent 接入后自动显示</td>
|
||||||
|
</tr>
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup>
|
||||||
|
import { ref, onMounted, computed } from 'vue'
|
||||||
|
import dayjs from 'dayjs'
|
||||||
|
import { getDashboardSummary, getDashboardTasks, getSystemConfig } from '../api/index.js'
|
||||||
|
|
||||||
|
const summary = ref({
|
||||||
|
total_agents: 0, active_agents: 0,
|
||||||
|
total_tasks: 0, active_tasks: 0,
|
||||||
|
total_executions: 0,
|
||||||
|
recent_executions_ok: 0, recent_executions_failed: 0,
|
||||||
|
pending_alerts: 0,
|
||||||
|
})
|
||||||
|
const tasks = ref([])
|
||||||
|
const showOnboarding = ref(true)
|
||||||
|
const showScript = ref(false)
|
||||||
|
const scriptTab = ref('curl')
|
||||||
|
const baseUrl = ref(window.location.origin)
|
||||||
|
|
||||||
|
const curlScript = computed(() => {
|
||||||
|
const b = baseUrl.value
|
||||||
|
return `# 让 AI Agent 执行以下命令注册自己:
|
||||||
|
curl -X POST ${b}/api/agents \\
|
||||||
|
-H "Content-Type: application/json" \\
|
||||||
|
-d '{"name": "my-agent", "description": "我的 AI Agent"}'
|
||||||
|
|
||||||
|
# 保存返回的 api_key,后续汇报用
|
||||||
|
|
||||||
|
# 注册定时任务(替换 YOUR_API_KEY):
|
||||||
|
curl -X POST "${b}/api/tasks?agent_id=<ID>" \\
|
||||||
|
-H "Content-Type: application/json" \\
|
||||||
|
-H "Authorization: Bearer <YOUR_API_KEY>" \\
|
||||||
|
-d '{
|
||||||
|
"name": "data-sync",
|
||||||
|
"cron_expression": "*/5 * * * *",
|
||||||
|
"grace_period": 300
|
||||||
|
}'
|
||||||
|
|
||||||
|
# 执行后汇报:
|
||||||
|
curl -X POST "${b}/api/tasks/<TASK_ID>/executions" \\
|
||||||
|
-H "Content-Type: application/json" \\
|
||||||
|
-H "Authorization: Bearer <YOUR_API_KEY>" \\
|
||||||
|
-d '{
|
||||||
|
"status": "success",
|
||||||
|
"duration_ms": 1500,
|
||||||
|
"log": "任务执行完毕"
|
||||||
|
}'`
|
||||||
|
})
|
||||||
|
|
||||||
|
const pythonScript = computed(() => {
|
||||||
|
const b = baseUrl.value
|
||||||
|
return `import requests
|
||||||
|
|
||||||
|
BASE = "${b}"
|
||||||
|
|
||||||
|
# 1. AI Agent 注册自己
|
||||||
|
resp = requests.post(f"{BASE}/api/agents", json={
|
||||||
|
"name": "my-agent",
|
||||||
|
"description": "我的 AI Agent"
|
||||||
|
})
|
||||||
|
agent = resp.json()
|
||||||
|
API_KEY = agent["api_key"] # 保存此 Key
|
||||||
|
AGENT_ID = agent["id"]
|
||||||
|
|
||||||
|
# 2. 注册定时任务
|
||||||
|
resp = requests.post(f"{BASE}/api/tasks",
|
||||||
|
params={"agent_id": AGENT_ID},
|
||||||
|
headers={"Authorization": f"Bearer {API_KEY}"},
|
||||||
|
json={"name": "data-sync", "cron_expression": "*/5 * * * *"}
|
||||||
|
)
|
||||||
|
task = resp.json()
|
||||||
|
TASK_ID = task["id"]
|
||||||
|
|
||||||
|
# 3. 每次执行后汇报
|
||||||
|
requests.post(f"{BASE}/api/tasks/{TASK_ID}/executions",
|
||||||
|
headers={"Authorization": f"Bearer {API_KEY}"},
|
||||||
|
json={"status": "success", "duration_ms": 1500, "log": "done"})`
|
||||||
|
})
|
||||||
|
|
||||||
|
onMounted(async () => {
|
||||||
|
try {
|
||||||
|
const [sumRes, taskRes, cfgRes] = await Promise.all([
|
||||||
|
getDashboardSummary(),
|
||||||
|
getDashboardTasks(),
|
||||||
|
getSystemConfig(),
|
||||||
|
])
|
||||||
|
summary.value = sumRes.data
|
||||||
|
tasks.value = taskRes.data
|
||||||
|
baseUrl.value = cfgRes.data.base_url
|
||||||
|
} catch (e) {
|
||||||
|
console.error('Failed to load dashboard', e)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
const copyScript = async () => {
|
||||||
|
const text = scriptTab.value === 'curl' ? curlScript.value : pythonScript.value
|
||||||
|
try {
|
||||||
|
await navigator.clipboard.writeText(text)
|
||||||
|
const { ElMessage } = await import('element-plus')
|
||||||
|
ElMessage.success('已复制')
|
||||||
|
} catch {
|
||||||
|
const { ElMessage } = await import('element-plus')
|
||||||
|
ElMessage.warning('复制失败,请手动复制')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.dashboard {
|
||||||
|
max-width: 1200px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── Onboarding Panel ─────────────────────────── */
|
||||||
|
.onboarding-panel {
|
||||||
|
position: relative;
|
||||||
|
background: linear-gradient(135deg, rgba(102,126,234,0.08) 0%, rgba(118,75,162,0.08) 100%);
|
||||||
|
border: 1px solid rgba(102,126,234,0.15);
|
||||||
|
border-radius: 16px;
|
||||||
|
padding: 24px;
|
||||||
|
margin-bottom: 24px;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
.onboarding-glow {
|
||||||
|
position: absolute;
|
||||||
|
top: -50%;
|
||||||
|
right: -20%;
|
||||||
|
width: 300px;
|
||||||
|
height: 300px;
|
||||||
|
background: radial-gradient(circle, rgba(102,126,234,0.08) 0%, transparent 70%);
|
||||||
|
pointer-events: none;
|
||||||
|
}
|
||||||
|
.onboarding-content {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 16px;
|
||||||
|
position: relative;
|
||||||
|
}
|
||||||
|
.onboarding-icon {
|
||||||
|
width: 48px;
|
||||||
|
height: 48px;
|
||||||
|
background: linear-gradient(135deg, rgba(102,126,234,0.2), rgba(118,75,162,0.2));
|
||||||
|
border-radius: 12px;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
color: #a8b4ff;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
.onboarding-text h3 {
|
||||||
|
font-size: 16px;
|
||||||
|
font-weight: 600;
|
||||||
|
color: #e0e0f0;
|
||||||
|
margin-bottom: 4px;
|
||||||
|
}
|
||||||
|
.onboarding-text p {
|
||||||
|
font-size: 13px;
|
||||||
|
color: rgba(255,255,255,0.45);
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
.btn-primary {
|
||||||
|
margin-left: auto;
|
||||||
|
padding: 8px 18px;
|
||||||
|
border-radius: 8px;
|
||||||
|
border: none;
|
||||||
|
background: linear-gradient(135deg, #667eea, #764ba2);
|
||||||
|
color: #fff;
|
||||||
|
font-size: 13px;
|
||||||
|
font-weight: 500;
|
||||||
|
cursor: pointer;
|
||||||
|
white-space: nowrap;
|
||||||
|
transition: opacity 0.2s;
|
||||||
|
}
|
||||||
|
.btn-primary:hover { opacity: 0.9; }
|
||||||
|
.onboarding-close {
|
||||||
|
position: absolute;
|
||||||
|
top: 16px;
|
||||||
|
right: 16px;
|
||||||
|
background: none;
|
||||||
|
border: none;
|
||||||
|
color: rgba(255,255,255,0.3);
|
||||||
|
cursor: pointer;
|
||||||
|
padding: 4px;
|
||||||
|
}
|
||||||
|
.onboarding-close:hover { color: #fff; }
|
||||||
|
|
||||||
|
/* Script block */
|
||||||
|
.onboarding-script {
|
||||||
|
margin-top: 16px;
|
||||||
|
background: rgba(0,0,0,0.3);
|
||||||
|
border-radius: 10px;
|
||||||
|
overflow: hidden;
|
||||||
|
position: relative;
|
||||||
|
}
|
||||||
|
.script-tabs {
|
||||||
|
display: flex;
|
||||||
|
border-bottom: 1px solid rgba(255,255,255,0.06);
|
||||||
|
}
|
||||||
|
.script-tabs button {
|
||||||
|
padding: 8px 16px;
|
||||||
|
background: none;
|
||||||
|
border: none;
|
||||||
|
color: rgba(255,255,255,0.4);
|
||||||
|
font-size: 12px;
|
||||||
|
cursor: pointer;
|
||||||
|
border-bottom: 2px solid transparent;
|
||||||
|
}
|
||||||
|
.script-tabs button.active {
|
||||||
|
color: #a8b4ff;
|
||||||
|
border-bottom-color: #667eea;
|
||||||
|
}
|
||||||
|
.script-body {
|
||||||
|
padding: 16px;
|
||||||
|
overflow-x: auto;
|
||||||
|
}
|
||||||
|
.script-body pre {
|
||||||
|
font-family: 'JetBrains Mono', 'Fira Code', 'Consolas', monospace;
|
||||||
|
font-size: 12px;
|
||||||
|
line-height: 1.7;
|
||||||
|
color: rgba(255,255,255,0.75);
|
||||||
|
white-space: pre;
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
.btn-copy {
|
||||||
|
position: absolute;
|
||||||
|
top: 48px;
|
||||||
|
right: 12px;
|
||||||
|
padding: 4px 12px;
|
||||||
|
border-radius: 6px;
|
||||||
|
border: 1px solid rgba(255,255,255,0.1);
|
||||||
|
background: rgba(255,255,255,0.06);
|
||||||
|
color: rgba(255,255,255,0.5);
|
||||||
|
font-size: 11px;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
.btn-copy:hover {
|
||||||
|
background: rgba(255,255,255,0.1);
|
||||||
|
color: #fff;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── Stats Grid ───────────────────────────────── */
|
||||||
|
.stats-grid {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(4, 1fr);
|
||||||
|
gap: 16px;
|
||||||
|
margin-bottom: 24px;
|
||||||
|
}
|
||||||
|
.stat-card {
|
||||||
|
background: var(--bg-card);
|
||||||
|
border: 1px solid var(--border-color);
|
||||||
|
border-radius: 14px;
|
||||||
|
padding: 20px;
|
||||||
|
display: flex;
|
||||||
|
gap: 16px;
|
||||||
|
transition: all 0.25s;
|
||||||
|
}
|
||||||
|
.stat-card:hover {
|
||||||
|
border-color: var(--card-accent, rgba(255,255,255,0.1));
|
||||||
|
box-shadow: 0 0 20px rgba(var(--card-accent, 255), 0.05);
|
||||||
|
transform: translateY(-2px);
|
||||||
|
}
|
||||||
|
.stat-icon {
|
||||||
|
width: 44px;
|
||||||
|
height: 44px;
|
||||||
|
border-radius: 12px;
|
||||||
|
background: linear-gradient(135deg, rgba(255,255,255,0.04), rgba(255,255,255,0.02));
|
||||||
|
border: 1px solid rgba(255,255,255,0.06);
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
color: var(--card-accent, #667eea);
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
.stat-body { flex: 1; }
|
||||||
|
.stat-label { font-size: 12px; color: var(--text-secondary); margin-bottom: 4px; }
|
||||||
|
.stat-value { font-size: 28px; font-weight: 700; color: #e8e8f0; line-height: 1.1; margin-bottom: 6px; }
|
||||||
|
.stat-sub { font-size: 12px; color: var(--text-muted); display: flex; align-items: center; gap: 6px; }
|
||||||
|
.stat-dot {
|
||||||
|
width: 5px; height: 5px; border-radius: 50%;
|
||||||
|
}
|
||||||
|
.stat-dot.active { background: #22c55e; box-shadow: 0 0 6px rgba(34,197,94,0.5); }
|
||||||
|
|
||||||
|
/* ── Task Table Section ───────────────────────── */
|
||||||
|
.section {
|
||||||
|
background: var(--bg-card);
|
||||||
|
border: 1px solid var(--border-color);
|
||||||
|
border-radius: 14px;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
.section-header {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: center;
|
||||||
|
padding: 16px 20px;
|
||||||
|
border-bottom: 1px solid var(--border-color);
|
||||||
|
}
|
||||||
|
.section-header h3 {
|
||||||
|
font-size: 15px;
|
||||||
|
font-weight: 600;
|
||||||
|
color: #e0e0f0;
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
.section-link {
|
||||||
|
font-size: 13px;
|
||||||
|
color: var(--accent-1);
|
||||||
|
text-decoration: none;
|
||||||
|
}
|
||||||
|
.section-link:hover { text-decoration: underline; }
|
||||||
|
|
||||||
|
.table-wrap {
|
||||||
|
overflow-x: auto;
|
||||||
|
}
|
||||||
|
.data-table {
|
||||||
|
width: 100%;
|
||||||
|
border-collapse: collapse;
|
||||||
|
}
|
||||||
|
.data-table th {
|
||||||
|
text-align: left;
|
||||||
|
padding: 12px 16px;
|
||||||
|
font-size: 11px;
|
||||||
|
font-weight: 500;
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 0.5px;
|
||||||
|
color: var(--text-secondary);
|
||||||
|
background: rgba(255,255,255,0.02);
|
||||||
|
border-bottom: 1px solid var(--border-color);
|
||||||
|
}
|
||||||
|
.data-table td {
|
||||||
|
padding: 12px 16px;
|
||||||
|
font-size: 13px;
|
||||||
|
color: var(--text-primary);
|
||||||
|
border-bottom: 1px solid var(--border-color);
|
||||||
|
}
|
||||||
|
.data-table tbody tr:hover {
|
||||||
|
background: rgba(255,255,255,0.02);
|
||||||
|
}
|
||||||
|
.cell-badge {
|
||||||
|
display: inline-block;
|
||||||
|
padding: 2px 10px;
|
||||||
|
border-radius: 20px;
|
||||||
|
background: rgba(255,255,255,0.04);
|
||||||
|
color: var(--text-secondary);
|
||||||
|
font-size: 12px;
|
||||||
|
}
|
||||||
|
.cell-name {
|
||||||
|
font-weight: 500;
|
||||||
|
}
|
||||||
|
.cell-code {
|
||||||
|
font-family: 'JetBrains Mono', 'Fira Code', monospace;
|
||||||
|
font-size: 12px;
|
||||||
|
color: #a8b4ff;
|
||||||
|
background: rgba(102,126,234,0.1);
|
||||||
|
padding: 2px 8px;
|
||||||
|
border-radius: 4px;
|
||||||
|
}
|
||||||
|
.cell-mono {
|
||||||
|
font-family: 'JetBrains Mono', monospace;
|
||||||
|
font-size: 12px;
|
||||||
|
color: var(--text-secondary);
|
||||||
|
}
|
||||||
|
.cell-muted { color: var(--text-muted); }
|
||||||
|
.cell-empty {
|
||||||
|
text-align: center;
|
||||||
|
color: var(--text-muted);
|
||||||
|
padding: 40px 16px !important;
|
||||||
|
}
|
||||||
|
.cell-link {
|
||||||
|
color: var(--accent-1);
|
||||||
|
text-decoration: none;
|
||||||
|
font-size: 13px;
|
||||||
|
}
|
||||||
|
.cell-link:hover { text-decoration: underline; }
|
||||||
|
|
||||||
|
/* Tags */
|
||||||
|
.tag {
|
||||||
|
display: inline-block;
|
||||||
|
padding: 2px 10px;
|
||||||
|
border-radius: 20px;
|
||||||
|
font-size: 12px;
|
||||||
|
font-weight: 500;
|
||||||
|
}
|
||||||
|
.tag-green {
|
||||||
|
background: rgba(34,197,94,0.12);
|
||||||
|
color: #4ade80;
|
||||||
|
}
|
||||||
|
.tag-red {
|
||||||
|
background: rgba(239,68,68,0.12);
|
||||||
|
color: #f87171;
|
||||||
|
}
|
||||||
|
.tag-gray {
|
||||||
|
background: rgba(255,255,255,0.05);
|
||||||
|
color: var(--text-secondary);
|
||||||
|
}
|
||||||
|
</style>
|
||||||
431
frontend/src/views/Settings.vue
Normal file
431
frontend/src/views/Settings.vue
Normal file
@ -0,0 +1,431 @@
|
|||||||
|
<template>
|
||||||
|
<div class="settings-page">
|
||||||
|
<!-- System Configuration -->
|
||||||
|
<div class="section mb-24">
|
||||||
|
<div class="section-header">
|
||||||
|
<h3>系统配置</h3>
|
||||||
|
</div>
|
||||||
|
<div class="config-body">
|
||||||
|
<div class="config-row">
|
||||||
|
<div class="config-info">
|
||||||
|
<div class="config-label">公网访问地址 (Base URL)</div>
|
||||||
|
<div class="config-desc">AI Agent 可通过此地址访问本系统 API。修改后页面上的接入指令会自动更新。</div>
|
||||||
|
</div>
|
||||||
|
<div class="config-input-group">
|
||||||
|
<input v-model="configForm.base_url" class="input config-input" placeholder="https://your-domain.com" />
|
||||||
|
<button class="btn-primary" @click="handleSaveConfig" :disabled="savingConfig">
|
||||||
|
{{ savingConfig ? '保存中...' : '保存' }}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div v-if="configSaved" class="config-success">配置已保存</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Notification Channels -->
|
||||||
|
<div class="section mb-24">
|
||||||
|
<div class="section-header">
|
||||||
|
<h3>通知渠道</h3>
|
||||||
|
<button class="btn-primary" @click="showChannelDialog = true">
|
||||||
|
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><line x1="12" y1="5" x2="12" y2="19"/><line x1="5" y1="12" x2="19" y2="12"/></svg>
|
||||||
|
添加渠道
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="card-grid">
|
||||||
|
<div v-for="c in channels" :key="c.id" class="channel-card">
|
||||||
|
<div class="channel-top">
|
||||||
|
<div class="channel-icon" :class="c.channel_type">
|
||||||
|
<svg v-if="c.channel_type === 'feishu_cli'" width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5"><path d="M12 2a4 4 0 0 1 4 4v2a4 4 0 0 1-8 0V6a4 4 0 0 1 4-4z"/><path d="M5 15h14l-1.5 6H6.5L5 15z"/></svg>
|
||||||
|
<svg v-else-if="c.channel_type === 'email'" width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5"><path d="M4 4h16c1.1 0 2 .9 2 2v12c0 1.1-.9 2-2 2H4c-1.1 0-2-.9-2-2V6c0-1.1.9-2 2-2z"/><polyline points="22,6 12,13 2,6"/></svg>
|
||||||
|
<svg v-else width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5"><path d="M10 13a5 5 0 0 0 7.54.54l3-3a5 5 0 0 0-7.07-7.07l-1.72 1.71"/><path d="M14 11a5 5 0 0 0-7.54-.54l-3 3a5 5 0 0 0 7.07 7.07l1.71-1.71"/></svg>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<div class="channel-name">{{ c.name }}</div>
|
||||||
|
<div class="channel-type">{{ c.channel_type === 'feishu_cli' ? '飞书' : c.channel_type === 'email' ? '邮件' : 'Webhook' }}</div>
|
||||||
|
</div>
|
||||||
|
<div class="channel-spacer"></div>
|
||||||
|
<label class="toggle">
|
||||||
|
<input type="checkbox" :checked="c.enabled" @change="(e) => toggleChannel(c, e.target.checked)">
|
||||||
|
<span class="toggle-slider"></span>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
<div class="channel-config">{{ c.config }}</div>
|
||||||
|
<button class="channel-delete" @click="handleDeleteChannel(c)">
|
||||||
|
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><polyline points="3 6 5 6 21 6"/><path d="M19 6v14a2 2 0 0 1-2 2H7a2 2 0 0 1-2-2V6m3 0V4a2 2 0 0 1 2-2h4a2 2 0 0 1 2 2v2"/></svg>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<div v-if="channels.length === 0" class="empty-state">
|
||||||
|
<p>暂无通知渠道 — 添加飞书或邮件通知</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Alert Rules -->
|
||||||
|
<div class="section mb-24">
|
||||||
|
<div class="section-header">
|
||||||
|
<h3>告警规则</h3>
|
||||||
|
<button class="btn-primary" @click="showRuleDialog = true">
|
||||||
|
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><line x1="12" y1="5" x2="12" y2="19"/><line x1="5" y1="12" x2="19" y2="12"/></svg>
|
||||||
|
添加规则
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<div class="table-wrap">
|
||||||
|
<table class="data-table">
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th>ID</th>
|
||||||
|
<th>任务</th>
|
||||||
|
<th>通知渠道</th>
|
||||||
|
<th>告警类型</th>
|
||||||
|
<th></th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
<tr v-for="r in rules" :key="r.id">
|
||||||
|
<td class="cell-mono">#{{ r.id }}</td>
|
||||||
|
<td class="cell-name">任务 #{{ r.task_id }}</td>
|
||||||
|
<td>{{ r.channel_name }}</td>
|
||||||
|
<td><span class="tag" :class="r.alert_type === 'missed_run' ? 'tag-warn' : 'tag-red'">{{ r.alert_type === 'missed_run' ? '未按时执行' : '执行失败' }}</span></td>
|
||||||
|
<td><button class="cell-link-btn" @click="handleDeleteRule(r)">删除</button></td>
|
||||||
|
</tr>
|
||||||
|
<tr v-if="rules.length === 0">
|
||||||
|
<td colspan="5" class="cell-empty">暂无规则</td>
|
||||||
|
</tr>
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Add Channel Modal -->
|
||||||
|
<div v-if="showChannelDialog" class="modal-overlay" @click.self="showChannelDialog = false">
|
||||||
|
<div class="modal">
|
||||||
|
<div class="modal-header">
|
||||||
|
<h4>添加通知渠道</h4>
|
||||||
|
<button class="modal-close" @click="showChannelDialog = false"><svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><line x1="18" y1="6" x2="6" y2="18"/><line x1="6" y1="6" x2="18" y2="18"/></svg></button>
|
||||||
|
</div>
|
||||||
|
<div class="modal-body">
|
||||||
|
<div class="field">
|
||||||
|
<label>渠道名称</label>
|
||||||
|
<input v-model="channelForm.name" placeholder="例如:我的飞书" class="input" />
|
||||||
|
</div>
|
||||||
|
<div class="field">
|
||||||
|
<label>渠道类型</label>
|
||||||
|
<select v-model="channelForm.channel_type" class="input">
|
||||||
|
<option value="feishu_cli">飞书 Webhook</option>
|
||||||
|
<option value="email">邮件 (SMTP)</option>
|
||||||
|
<option value="webhook">通用 Webhook</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<div class="field">
|
||||||
|
<label>配置 (JSON)</label>
|
||||||
|
<textarea v-model="channelForm.config" rows="4" class="input" placeholder='飞书: {"webhook_url":"..."}'></textarea>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="modal-footer">
|
||||||
|
<button class="btn-secondary" @click="showChannelDialog = false">取消</button>
|
||||||
|
<button class="btn-primary" @click="handleAddChannel">添加</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Add Rule Modal -->
|
||||||
|
<div v-if="showRuleDialog" class="modal-overlay" @click.self="showRuleDialog = false">
|
||||||
|
<div class="modal">
|
||||||
|
<div class="modal-header">
|
||||||
|
<h4>添加告警规则</h4>
|
||||||
|
<button class="modal-close" @click="showRuleDialog = false"><svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><line x1="18" y1="6" x2="6" y2="18"/><line x1="6" y1="6" x2="18" y2="18"/></svg></button>
|
||||||
|
</div>
|
||||||
|
<div class="modal-body">
|
||||||
|
<div class="field">
|
||||||
|
<label>任务</label>
|
||||||
|
<select v-model="ruleForm.task_id" class="input">
|
||||||
|
<option v-for="t in allTasks" :key="t.id" :value="t.id">[{{ t.id }}] {{ t.name }}</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<div class="field">
|
||||||
|
<label>通知渠道</label>
|
||||||
|
<select v-model="ruleForm.channel_id" class="input">
|
||||||
|
<option v-for="c in channels" :key="c.id" :value="c.id">{{ c.name }}</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<div class="field">
|
||||||
|
<label>告警类型</label>
|
||||||
|
<select v-model="ruleForm.alert_type" class="input">
|
||||||
|
<option value="missed_run">未按时执行</option>
|
||||||
|
<option value="failure">执行失败</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="modal-footer">
|
||||||
|
<button class="btn-secondary" @click="showRuleDialog = false">取消</button>
|
||||||
|
<button class="btn-primary" @click="handleAddRule">添加</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup>
|
||||||
|
import { ref, onMounted } from 'vue'
|
||||||
|
import {
|
||||||
|
listChannels, createChannel, updateChannel, deleteChannel,
|
||||||
|
listAlertRules, createAlertRule, deleteAlertRule,
|
||||||
|
listTasks, getSystemConfig, saveSystemConfig,
|
||||||
|
} from '../api/index.js'
|
||||||
|
|
||||||
|
const channels = ref([])
|
||||||
|
const rules = ref([])
|
||||||
|
const allTasks = ref([])
|
||||||
|
const showChannelDialog = ref(false)
|
||||||
|
const showRuleDialog = ref(false)
|
||||||
|
const channelForm = ref({ name: '', channel_type: 'feishu_cli', config: '' })
|
||||||
|
const ruleForm = ref({ task_id: null, channel_id: null, alert_type: 'missed_run' })
|
||||||
|
|
||||||
|
// System config
|
||||||
|
const configForm = ref({ base_url: '' })
|
||||||
|
const savingConfig = ref(false)
|
||||||
|
const configSaved = ref(false)
|
||||||
|
|
||||||
|
const loadData = async () => {
|
||||||
|
const [chRes, ruleRes, taskRes, cfgRes] = await Promise.all([
|
||||||
|
listChannels(), listAlertRules(), listTasks(), getSystemConfig(),
|
||||||
|
])
|
||||||
|
channels.value = chRes.data
|
||||||
|
rules.value = ruleRes.data
|
||||||
|
allTasks.value = taskRes.data
|
||||||
|
configForm.value.base_url = cfgRes.data.base_url
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleSaveConfig = async () => {
|
||||||
|
savingConfig.value = true
|
||||||
|
configSaved.value = false
|
||||||
|
try {
|
||||||
|
const res = await saveSystemConfig({ base_url: configForm.value.base_url })
|
||||||
|
configForm.value.base_url = res.data.base_url
|
||||||
|
configSaved.value = true
|
||||||
|
setTimeout(() => { configSaved.value = false }, 3000)
|
||||||
|
} catch (e) {
|
||||||
|
console.error(e)
|
||||||
|
} finally {
|
||||||
|
savingConfig.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleAddChannel = async () => {
|
||||||
|
try {
|
||||||
|
await createChannel(channelForm.value)
|
||||||
|
showChannelDialog.value = false
|
||||||
|
channelForm.value = { name: '', channel_type: 'feishu_cli', config: '' }
|
||||||
|
await loadData()
|
||||||
|
} catch (e) { console.error(e) }
|
||||||
|
}
|
||||||
|
|
||||||
|
const toggleChannel = async (row, enabled) => {
|
||||||
|
try { await updateChannel(row.id, { enabled }) } catch (e) { console.error(e) }
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleDeleteChannel = async (row) => {
|
||||||
|
try { await deleteChannel(row.id); await loadData() } catch (e) { console.error(e) }
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleAddRule = async () => {
|
||||||
|
try {
|
||||||
|
await createAlertRule(ruleForm.value)
|
||||||
|
showRuleDialog.value = false
|
||||||
|
ruleForm.value = { task_id: null, channel_id: null, alert_type: 'missed_run' }
|
||||||
|
await loadData()
|
||||||
|
} catch (e) { console.error(e) }
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleDeleteRule = async (row) => {
|
||||||
|
try { await deleteAlertRule(row.id); await loadData() } catch (e) { console.error(e) }
|
||||||
|
}
|
||||||
|
|
||||||
|
onMounted(loadData)
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.settings-page { max-width: 1200px; }
|
||||||
|
.mb-24 { margin-bottom: 24px; }
|
||||||
|
|
||||||
|
.section {
|
||||||
|
background: var(--bg-card);
|
||||||
|
border: 1px solid var(--border-color);
|
||||||
|
border-radius: 14px;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
.section-header {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: center;
|
||||||
|
padding: 16px 20px;
|
||||||
|
border-bottom: 1px solid var(--border-color);
|
||||||
|
}
|
||||||
|
.section-header h3 { font-size: 15px; font-weight: 600; color: #e0e0f0; margin: 0; }
|
||||||
|
|
||||||
|
/* System Config */
|
||||||
|
.config-body { padding: 20px; }
|
||||||
|
.config-row { display: flex; align-items: flex-start; gap: 20px; }
|
||||||
|
.config-info { flex: 1; }
|
||||||
|
.config-label { font-size: 14px; font-weight: 500; color: var(--text-primary); margin-bottom: 4px; }
|
||||||
|
.config-desc { font-size: 12px; color: var(--text-muted); line-height: 1.5; }
|
||||||
|
.config-input-group { display: flex; gap: 8px; align-items: center; flex-shrink: 0; }
|
||||||
|
.config-input { width: 320px; }
|
||||||
|
.config-success {
|
||||||
|
margin-top: 12px;
|
||||||
|
font-size: 13px;
|
||||||
|
color: #4ade80;
|
||||||
|
padding: 6px 12px;
|
||||||
|
background: rgba(34,197,94,0.1);
|
||||||
|
border-radius: 6px;
|
||||||
|
display: inline-block;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Cards */
|
||||||
|
.card-grid { padding: 16px; display: flex; flex-direction: column; gap: 12px; }
|
||||||
|
.channel-card {
|
||||||
|
position: relative;
|
||||||
|
background: rgba(255,255,255,0.02);
|
||||||
|
border: 1px solid var(--border-color);
|
||||||
|
border-radius: 10px;
|
||||||
|
padding: 16px;
|
||||||
|
}
|
||||||
|
.channel-top { display: flex; align-items: center; gap: 12px; }
|
||||||
|
.channel-icon {
|
||||||
|
width: 40px; height: 40px; border-radius: 10px;
|
||||||
|
display: flex; align-items: center; justify-content: center;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
.channel-icon.feishu_cli { background: rgba(102,126,234,0.15); color: #a8b4ff; }
|
||||||
|
.channel-icon.email { background: rgba(34,197,94,0.15); color: #4ade80; }
|
||||||
|
.channel-icon.webhook { background: rgba(245,158,11,0.15); color: #fbbf24; }
|
||||||
|
.channel-spacer { flex: 1; }
|
||||||
|
.channel-name { font-size: 14px; font-weight: 500; color: var(--text-primary); }
|
||||||
|
.channel-type { font-size: 11px; color: var(--text-muted); }
|
||||||
|
.channel-config {
|
||||||
|
margin-top: 8px;
|
||||||
|
font-family: 'JetBrains Mono', monospace;
|
||||||
|
font-size: 11px;
|
||||||
|
color: var(--text-muted);
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
.channel-delete {
|
||||||
|
position: absolute; top: 12px; right: 12px;
|
||||||
|
background: none; border: none;
|
||||||
|
color: var(--text-muted); cursor: pointer;
|
||||||
|
}
|
||||||
|
.channel-delete:hover { color: var(--danger); }
|
||||||
|
|
||||||
|
/* Toggle */
|
||||||
|
.toggle { position: relative; display: inline-block; width: 36px; height: 20px; }
|
||||||
|
.toggle input { opacity: 0; width: 0; height: 0; }
|
||||||
|
.toggle-slider {
|
||||||
|
position: absolute; inset: 0;
|
||||||
|
background: rgba(255,255,255,0.1);
|
||||||
|
border-radius: 10px;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: 0.2s;
|
||||||
|
}
|
||||||
|
.toggle-slider::before {
|
||||||
|
content: '';
|
||||||
|
position: absolute;
|
||||||
|
width: 16px; height: 16px;
|
||||||
|
left: 2px; top: 2px;
|
||||||
|
background: #fff;
|
||||||
|
border-radius: 50%;
|
||||||
|
transition: 0.2s;
|
||||||
|
}
|
||||||
|
.toggle input:checked + .toggle-slider { background: var(--accent-1); }
|
||||||
|
.toggle input:checked + .toggle-slider::before { transform: translateX(16px); }
|
||||||
|
|
||||||
|
/* Table */
|
||||||
|
.table-wrap { overflow-x: auto; }
|
||||||
|
.data-table { width: 100%; border-collapse: collapse; }
|
||||||
|
.data-table th {
|
||||||
|
text-align: left; padding: 12px 16px; font-size: 11px; font-weight: 500;
|
||||||
|
text-transform: uppercase; letter-spacing: 0.5px;
|
||||||
|
color: var(--text-secondary); background: rgba(255,255,255,0.02);
|
||||||
|
border-bottom: 1px solid var(--border-color);
|
||||||
|
}
|
||||||
|
.data-table td {
|
||||||
|
padding: 12px 16px; font-size: 13px;
|
||||||
|
color: var(--text-primary); border-bottom: 1px solid var(--border-color);
|
||||||
|
}
|
||||||
|
.data-table tbody tr:hover { background: rgba(255,255,255,0.02); }
|
||||||
|
.cell-mono { font-family: 'JetBrains Mono', monospace; font-size: 12px; color: var(--text-secondary); }
|
||||||
|
.cell-name { font-weight: 500; }
|
||||||
|
.cell-empty { text-align: center; color: var(--text-muted); padding: 40px 16px !important; }
|
||||||
|
.cell-link-btn { background: none; border: none; color: var(--danger); font-size: 13px; cursor: pointer; }
|
||||||
|
.cell-link-btn:hover { text-decoration: underline; }
|
||||||
|
|
||||||
|
.tag { display: inline-block; padding: 2px 10px; border-radius: 20px; font-size: 11px; font-weight: 500; }
|
||||||
|
.tag-warn { background: rgba(245,158,11,0.12); color: #fbbf24; }
|
||||||
|
.tag-red { background: rgba(239,68,68,0.12); color: #f87171; }
|
||||||
|
|
||||||
|
.empty-state { text-align: center; padding: 40px 20px; color: var(--text-muted); }
|
||||||
|
|
||||||
|
/* Buttons */
|
||||||
|
.btn-primary {
|
||||||
|
display: inline-flex; align-items: center; gap: 6px;
|
||||||
|
padding: 7px 16px; border-radius: 8px;
|
||||||
|
border: none;
|
||||||
|
background: linear-gradient(135deg, #667eea, #764ba2);
|
||||||
|
color: #fff; font-size: 13px; font-weight: 500;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
.btn-primary:hover { opacity: 0.9; }
|
||||||
|
.btn-secondary {
|
||||||
|
padding: 7px 16px; border-radius: 8px;
|
||||||
|
border: 1px solid var(--border-color);
|
||||||
|
background: transparent;
|
||||||
|
color: var(--text-secondary); font-size: 13px;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
.btn-secondary:hover { color: #fff; border-color: rgba(255,255,255,0.15); }
|
||||||
|
|
||||||
|
/* Modal */
|
||||||
|
.modal-overlay {
|
||||||
|
position: fixed; inset: 0; z-index: 1000;
|
||||||
|
background: rgba(0,0,0,0.6);
|
||||||
|
backdrop-filter: blur(4px);
|
||||||
|
display: flex; align-items: center; justify-content: center;
|
||||||
|
}
|
||||||
|
.modal {
|
||||||
|
width: 500px; max-width: 90vw;
|
||||||
|
background: var(--bg-card);
|
||||||
|
border: 1px solid var(--border-color);
|
||||||
|
border-radius: 14px;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
.modal-header {
|
||||||
|
display: flex; justify-content: space-between; align-items: center;
|
||||||
|
padding: 16px 20px; border-bottom: 1px solid var(--border-color);
|
||||||
|
}
|
||||||
|
.modal-header h4 { font-size: 15px; font-weight: 600; color: #e0e0f0; margin: 0; }
|
||||||
|
.modal-close { background: none; border: none; color: var(--text-muted); cursor: pointer; }
|
||||||
|
.modal-close:hover { color: #fff; }
|
||||||
|
.modal-body { padding: 20px; display: flex; flex-direction: column; gap: 16px; }
|
||||||
|
.modal-footer {
|
||||||
|
display: flex; justify-content: flex-end; gap: 8px;
|
||||||
|
padding: 16px 20px; border-top: 1px solid var(--border-color);
|
||||||
|
}
|
||||||
|
|
||||||
|
.field { display: flex; flex-direction: column; gap: 6px; }
|
||||||
|
.field label { font-size: 12px; color: var(--text-secondary); }
|
||||||
|
.input {
|
||||||
|
padding: 8px 12px;
|
||||||
|
border-radius: 8px;
|
||||||
|
border: 1px solid var(--border-color);
|
||||||
|
background: rgba(255,255,255,0.04);
|
||||||
|
color: var(--text-primary);
|
||||||
|
font-size: 13px;
|
||||||
|
outline: none;
|
||||||
|
transition: border-color 0.15s;
|
||||||
|
}
|
||||||
|
.input:focus { border-color: var(--accent-1); }
|
||||||
|
textarea.input { resize: vertical; font-family: 'JetBrains Mono', monospace; font-size: 12px; }
|
||||||
|
select.input { cursor: pointer; }
|
||||||
|
</style>
|
||||||
242
frontend/src/views/TaskDetail.vue
Normal file
242
frontend/src/views/TaskDetail.vue
Normal file
@ -0,0 +1,242 @@
|
|||||||
|
<template>
|
||||||
|
<div class="task-detail">
|
||||||
|
<button class="back-btn" @click="$router.back()">
|
||||||
|
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><line x1="19" y1="12" x2="5" y2="12"/><polyline points="12 19 5 12 12 5"/></svg>
|
||||||
|
返回
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<div v-if="task" class="section mb-24">
|
||||||
|
<div class="section-header">
|
||||||
|
<h3>{{ task.name }}</h3>
|
||||||
|
<span class="tag" :class="task.status === 'active' ? 'tag-green' : 'tag-gray'">
|
||||||
|
{{ task.status === 'active' ? '运行中' : task.status === 'paused' ? '已暂停' : '已停止' }}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div class="detail-grid">
|
||||||
|
<div class="detail-item"><span class="detail-label">Agent</span><span class="detail-value">{{ task.agent_name }}</span></div>
|
||||||
|
<div class="detail-item"><span class="detail-label">Cron</span><code class="detail-code">{{ task.cron_expression }}</code></div>
|
||||||
|
<div class="detail-item"><span class="detail-label">容忍窗口</span><span class="detail-value">{{ task.grace_period }}s</span></div>
|
||||||
|
<div class="detail-item"><span class="detail-label">最近运行</span><span class="detail-value mono">{{ task.last_run_at ? dayjs(task.last_run_at).format('YYYY-MM-DD HH:mm:ss') : '—' }}</span></div>
|
||||||
|
<div class="detail-item">
|
||||||
|
<span class="detail-label">最近结果</span>
|
||||||
|
<span v-if="task.last_run_result" class="tag" :class="task.last_run_result === 'success' ? 'tag-green' : 'tag-red'">
|
||||||
|
{{ task.last_run_result === 'success' ? '成功' : '失败' }}
|
||||||
|
</span>
|
||||||
|
<span v-else class="detail-value">—</span>
|
||||||
|
</div>
|
||||||
|
<div class="detail-item"><span class="detail-label">执行次数</span><span class="detail-value mono">{{ task.total_run_count }}</span></div>
|
||||||
|
<div class="detail-item"><span class="detail-label">预计下次</span><span class="detail-value mono">{{ task.next_run_at ? dayjs(task.next_run_at).format('YYYY-MM-DD HH:mm:ss') : '待计算' }}</span></div>
|
||||||
|
<div class="detail-item"><span class="detail-label">描述</span><span class="detail-value">{{ task.description || '无' }}</span></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Execution History -->
|
||||||
|
<div class="section">
|
||||||
|
<div class="section-header">
|
||||||
|
<h3>执行历史</h3>
|
||||||
|
</div>
|
||||||
|
<div class="table-wrap">
|
||||||
|
<table class="data-table">
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th>ID</th>
|
||||||
|
<th>状态</th>
|
||||||
|
<th>开始时间</th>
|
||||||
|
<th>耗时(ms)</th>
|
||||||
|
<th>结果摘要</th>
|
||||||
|
<th></th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
<tr v-for="e in executions" :key="e.id">
|
||||||
|
<td class="cell-mono">#{{ e.id }}</td>
|
||||||
|
<td>
|
||||||
|
<span class="tag" :class="e.status === 'success' ? 'tag-green' : e.status === 'failed' ? 'tag-red' : 'tag-warn'">
|
||||||
|
{{ e.status === 'success' ? '成功' : e.status === 'failed' ? '失败' : '运行中' }}
|
||||||
|
</span>
|
||||||
|
</td>
|
||||||
|
<td class="cell-mono">{{ dayjs(e.started_at).format('MM-DD HH:mm:ss') }}</td>
|
||||||
|
<td class="cell-mono">{{ e.duration_ms ?? '—' }}</td>
|
||||||
|
<td class="cell-muted cell-ellipsis">{{ e.result || '—' }}</td>
|
||||||
|
<td><button class="cell-link-btn" @click="showLog(e)">日志</button></td>
|
||||||
|
</tr>
|
||||||
|
<tr v-if="executions.length === 0">
|
||||||
|
<td colspan="6" class="cell-empty">暂无执行记录</td>
|
||||||
|
</tr>
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Log Dialog -->
|
||||||
|
<div v-if="logVisible" class="modal-overlay" @click.self="logVisible = false">
|
||||||
|
<div class="modal">
|
||||||
|
<div class="modal-header">
|
||||||
|
<h4>执行日志 #{{ logExecution?.id }}</h4>
|
||||||
|
<button class="modal-close" @click="logVisible = false">
|
||||||
|
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><line x1="18" y1="6" x2="6" y2="18"/><line x1="6" y1="6" x2="18" y2="18"/></svg>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<pre class="log-viewer">{{ currentLog || '暂无日志' }}</pre>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup>
|
||||||
|
import { ref, onMounted } from 'vue'
|
||||||
|
import { useRoute } from 'vue-router'
|
||||||
|
import dayjs from 'dayjs'
|
||||||
|
import { getTask, listExecutions } from '../api/index.js'
|
||||||
|
|
||||||
|
const route = useRoute()
|
||||||
|
const taskId = Number(route.params.id)
|
||||||
|
const task = ref(null)
|
||||||
|
const executions = ref([])
|
||||||
|
const logVisible = ref(false)
|
||||||
|
const currentLog = ref('')
|
||||||
|
const logExecution = ref(null)
|
||||||
|
|
||||||
|
onMounted(async () => {
|
||||||
|
try {
|
||||||
|
const [taskRes, execRes] = await Promise.all([
|
||||||
|
getTask(taskId),
|
||||||
|
listExecutions(taskId, { limit: 100 }),
|
||||||
|
])
|
||||||
|
task.value = taskRes.data
|
||||||
|
executions.value = execRes.data
|
||||||
|
} catch (e) {
|
||||||
|
console.error(e)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
const showLog = (row) => {
|
||||||
|
logExecution.value = row
|
||||||
|
currentLog.value = row.log || '暂无日志'
|
||||||
|
logVisible.value = true
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.task-detail { max-width: 1200px; }
|
||||||
|
|
||||||
|
.back-btn {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 6px;
|
||||||
|
background: none;
|
||||||
|
border: 1px solid var(--border-color);
|
||||||
|
border-radius: 8px;
|
||||||
|
padding: 6px 14px;
|
||||||
|
color: var(--text-secondary);
|
||||||
|
font-size: 13px;
|
||||||
|
cursor: pointer;
|
||||||
|
margin-bottom: 16px;
|
||||||
|
}
|
||||||
|
.back-btn:hover { color: #fff; border-color: rgba(255,255,255,0.15); }
|
||||||
|
|
||||||
|
.mb-24 { margin-bottom: 24px; }
|
||||||
|
.section {
|
||||||
|
background: var(--bg-card);
|
||||||
|
border: 1px solid var(--border-color);
|
||||||
|
border-radius: 14px;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
.section-header {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: center;
|
||||||
|
padding: 16px 20px;
|
||||||
|
border-bottom: 1px solid var(--border-color);
|
||||||
|
}
|
||||||
|
.section-header h3 { font-size: 15px; font-weight: 600; color: #e0e0f0; margin: 0; }
|
||||||
|
|
||||||
|
.detail-grid {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: 1fr 1fr;
|
||||||
|
gap: 0;
|
||||||
|
}
|
||||||
|
.detail-item {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 4px;
|
||||||
|
padding: 14px 20px;
|
||||||
|
border-bottom: 1px solid var(--border-color);
|
||||||
|
}
|
||||||
|
.detail-item:nth-child(odd) { border-right: 1px solid var(--border-color); }
|
||||||
|
.detail-label { font-size: 11px; color: var(--text-muted); text-transform: uppercase; letter-spacing: 0.5px; }
|
||||||
|
.detail-value { font-size: 13px; color: var(--text-primary); }
|
||||||
|
.detail-value.mono, .cell-mono { font-family: 'JetBrains Mono', monospace; font-size: 12px; color: var(--text-secondary); }
|
||||||
|
.detail-code {
|
||||||
|
font-family: 'JetBrains Mono', monospace;
|
||||||
|
font-size: 12px; color: #a8b4ff;
|
||||||
|
background: rgba(102,126,234,0.1);
|
||||||
|
padding: 2px 8px; border-radius: 4px;
|
||||||
|
display: inline-block;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Table */
|
||||||
|
.table-wrap { overflow-x: auto; }
|
||||||
|
.data-table { width: 100%; border-collapse: collapse; }
|
||||||
|
.data-table th {
|
||||||
|
text-align: left; padding: 12px 16px; font-size: 11px; font-weight: 500;
|
||||||
|
text-transform: uppercase; letter-spacing: 0.5px;
|
||||||
|
color: var(--text-secondary); background: rgba(255,255,255,0.02);
|
||||||
|
border-bottom: 1px solid var(--border-color);
|
||||||
|
}
|
||||||
|
.data-table td {
|
||||||
|
padding: 12px 16px; font-size: 13px;
|
||||||
|
color: var(--text-primary); border-bottom: 1px solid var(--border-color);
|
||||||
|
}
|
||||||
|
.data-table tbody tr:hover { background: rgba(255,255,255,0.02); }
|
||||||
|
.cell-mono { font-family: 'JetBrains Mono', monospace; font-size: 12px; color: var(--text-secondary); }
|
||||||
|
.cell-muted { color: var(--text-muted); }
|
||||||
|
.cell-empty { text-align: center; color: var(--text-muted); padding: 40px 16px !important; }
|
||||||
|
.cell-ellipsis { max-width: 200px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
||||||
|
.cell-link-btn {
|
||||||
|
background: none; border: none; color: var(--accent-1);
|
||||||
|
font-size: 13px; cursor: pointer;
|
||||||
|
}
|
||||||
|
.cell-link-btn:hover { text-decoration: underline; }
|
||||||
|
|
||||||
|
.tag { display: inline-block; padding: 2px 10px; border-radius: 20px; font-size: 12px; font-weight: 500; }
|
||||||
|
.tag-green { background: rgba(34,197,94,0.12); color: #4ade80; }
|
||||||
|
.tag-red { background: rgba(239,68,68,0.12); color: #f87171; }
|
||||||
|
.tag-warn { background: rgba(245,158,11,0.12); color: #fbbf24; }
|
||||||
|
.tag-gray { background: rgba(255,255,255,0.05); color: var(--text-secondary); }
|
||||||
|
|
||||||
|
/* Log Modal */
|
||||||
|
.modal-overlay {
|
||||||
|
position: fixed; inset: 0; z-index: 1000;
|
||||||
|
background: rgba(0,0,0,0.6);
|
||||||
|
backdrop-filter: blur(4px);
|
||||||
|
display: flex; align-items: center; justify-content: center;
|
||||||
|
}
|
||||||
|
.modal {
|
||||||
|
width: 700px; max-width: 90vw;
|
||||||
|
background: var(--bg-card);
|
||||||
|
border: 1px solid var(--border-color);
|
||||||
|
border-radius: 14px;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
.modal-header {
|
||||||
|
display: flex; justify-content: space-between; align-items: center;
|
||||||
|
padding: 16px 20px; border-bottom: 1px solid var(--border-color);
|
||||||
|
}
|
||||||
|
.modal-header h4 { font-size: 15px; font-weight: 600; color: #e0e0f0; margin: 0; }
|
||||||
|
.modal-close { background: none; border: none; color: var(--text-muted); cursor: pointer; }
|
||||||
|
.modal-close:hover { color: #fff; }
|
||||||
|
.log-viewer {
|
||||||
|
background: #0d0d14;
|
||||||
|
color: #c0c4d0;
|
||||||
|
padding: 20px;
|
||||||
|
font-family: 'JetBrains Mono', 'Fira Code', monospace;
|
||||||
|
font-size: 13px;
|
||||||
|
line-height: 1.6;
|
||||||
|
max-height: 500px;
|
||||||
|
overflow: auto;
|
||||||
|
white-space: pre-wrap;
|
||||||
|
word-break: break-all;
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
220
frontend/src/views/Tasks.vue
Normal file
220
frontend/src/views/Tasks.vue
Normal file
@ -0,0 +1,220 @@
|
|||||||
|
<template>
|
||||||
|
<div class="tasks-page">
|
||||||
|
<div class="section mb-24">
|
||||||
|
<div class="section-header">
|
||||||
|
<h3>定时任务 — 由 AI Agent 自动管理</h3>
|
||||||
|
</div>
|
||||||
|
<div class="onboard-body">
|
||||||
|
<p>Agent 注册后,通过下方 API 汇报任务执行情况,无需手动创建。</p>
|
||||||
|
<div class="code-block">
|
||||||
|
<div class="code-tabs">
|
||||||
|
<button :class="{ active: tab === 'report' }" @click="tab = 'report'">汇报执行</button>
|
||||||
|
<button :class="{ active: tab === 'register' }" @click="tab = 'register'">注册任务</button>
|
||||||
|
<button :class="{ active: tab === 'batch' }" @click="tab = 'batch'">批量注册</button>
|
||||||
|
</div>
|
||||||
|
<div class="code-body">
|
||||||
|
<pre>{{ codes[tab] }}</pre>
|
||||||
|
</div>
|
||||||
|
<button class="btn-copy" @click="copyCode">复制</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Task list -->
|
||||||
|
<div class="section">
|
||||||
|
<div class="section-header">
|
||||||
|
<h3>所有任务</h3>
|
||||||
|
</div>
|
||||||
|
<div class="table-wrap">
|
||||||
|
<table class="data-table">
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th>ID</th>
|
||||||
|
<th>Agent</th>
|
||||||
|
<th>任务名称</th>
|
||||||
|
<th>Cron</th>
|
||||||
|
<th>状态</th>
|
||||||
|
<th>最近运行</th>
|
||||||
|
<th>结果</th>
|
||||||
|
<th>执行次数</th>
|
||||||
|
<th></th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
<tr v-for="t in tasks" :key="t.id">
|
||||||
|
<td class="cell-mono">#{{ t.id }}</td>
|
||||||
|
<td><span class="cell-badge">{{ t.agent_name }}</span></td>
|
||||||
|
<td class="cell-name">{{ t.name }}</td>
|
||||||
|
<td><code class="cell-code">{{ t.cron_expression }}</code></td>
|
||||||
|
<td>
|
||||||
|
<span class="tag" :class="t.status === 'active' ? 'tag-green' : 'tag-gray'">
|
||||||
|
{{ t.status === 'active' ? '运行中' : t.status === 'paused' ? '已暂停' : '已停止' }}
|
||||||
|
</span>
|
||||||
|
</td>
|
||||||
|
<td class="cell-mono">{{ t.last_run_at ? dayjs(t.last_run_at).format('MM-DD HH:mm') : '—' }}</td>
|
||||||
|
<td>
|
||||||
|
<span v-if="t.last_run_result" class="tag" :class="t.last_run_result === 'success' ? 'tag-green' : 'tag-red'">
|
||||||
|
{{ t.last_run_result === 'success' ? '成功' : '失败' }}
|
||||||
|
</span>
|
||||||
|
<span v-else class="cell-muted">—</span>
|
||||||
|
</td>
|
||||||
|
<td class="cell-mono">{{ t.total_run_count }}</td>
|
||||||
|
<td>
|
||||||
|
<router-link :to="`/tasks/${t.id}`" class="cell-link">详情</router-link>
|
||||||
|
<button class="btn-icon" @click="handleDelete(t)" title="删除">
|
||||||
|
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><polyline points="3 6 5 6 21 6"/><path d="M19 6v14a2 2 0 0 1-2 2H7a2 2 0 0 1-2-2V6m3 0V4a2 2 0 0 1 2-2h4a2 2 0 0 1 2 2v2"/></svg>
|
||||||
|
</button>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
<tr v-if="tasks.length === 0">
|
||||||
|
<td colspan="9" class="cell-empty">暂无任务 — Agent 注册后会自动出现在这里</td>
|
||||||
|
</tr>
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup>
|
||||||
|
import { ref, onMounted, computed } from 'vue'
|
||||||
|
import dayjs from 'dayjs'
|
||||||
|
import { listTasks, deleteTask, getSystemConfig } from '../api/index.js'
|
||||||
|
|
||||||
|
const tasks = ref([])
|
||||||
|
const tab = ref('report')
|
||||||
|
const baseUrl = ref(window.location.origin)
|
||||||
|
|
||||||
|
const loadData = async () => {
|
||||||
|
const [taskRes, cfgRes] = await Promise.all([
|
||||||
|
listTasks(),
|
||||||
|
getSystemConfig(),
|
||||||
|
])
|
||||||
|
tasks.value = taskRes.data
|
||||||
|
baseUrl.value = cfgRes.data.base_url
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleDelete = async (row) => {
|
||||||
|
try {
|
||||||
|
await deleteTask(row.id)
|
||||||
|
await loadData()
|
||||||
|
} catch (e) {
|
||||||
|
console.error(e)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const codes = computed(() => {
|
||||||
|
const b = baseUrl.value
|
||||||
|
return {
|
||||||
|
report: `curl -X POST ${b}/api/tasks/<TASK_ID>/executions \\
|
||||||
|
-H "Content-Type: application/json" \\
|
||||||
|
-H "Authorization: Bearer <YOUR_API_KEY>" \\
|
||||||
|
-d '{"status":"success","duration_ms":2500,"log":"done"}'`,
|
||||||
|
register: `curl -X POST "${b}/api/tasks?agent_id=<AGENT_ID>" \\
|
||||||
|
-H "Content-Type: application/json" \\
|
||||||
|
-H "Authorization: Bearer <YOUR_API_KEY>" \\
|
||||||
|
-d '{"name":"data-sync","cron_expression":"*/5 * * * *","grace_period":300}'`,
|
||||||
|
batch: `curl -X POST ${b}/api/agents/register-with-tasks \\
|
||||||
|
-H "Content-Type: application/json" \\
|
||||||
|
-d '{"name":"agent","tasks":[{"name":"sync","cron_expression":"*/5 * * * *"}]}'`,
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
const copyCode = async () => {
|
||||||
|
try {
|
||||||
|
await navigator.clipboard.writeText(codes.value[tab.value])
|
||||||
|
const { ElMessage } = await import('element-plus')
|
||||||
|
ElMessage.success('已复制')
|
||||||
|
} catch {}
|
||||||
|
}
|
||||||
|
|
||||||
|
onMounted(loadData)
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.tasks-page { max-width: 1200px; }
|
||||||
|
.mb-24 { margin-bottom: 24px; }
|
||||||
|
|
||||||
|
.section {
|
||||||
|
background: var(--bg-card);
|
||||||
|
border: 1px solid var(--border-color);
|
||||||
|
border-radius: 14px;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
.section-header {
|
||||||
|
padding: 16px 20px;
|
||||||
|
border-bottom: 1px solid var(--border-color);
|
||||||
|
}
|
||||||
|
.section-header h3 { font-size: 15px; font-weight: 600; color: #e0e0f0; margin: 0; }
|
||||||
|
|
||||||
|
.onboard-body { padding: 20px; }
|
||||||
|
.onboard-body > p { font-size: 13px; color: var(--text-secondary); margin-bottom: 16px; }
|
||||||
|
|
||||||
|
.code-block {
|
||||||
|
position: relative;
|
||||||
|
background: rgba(0,0,0,0.3);
|
||||||
|
border-radius: 10px;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
.code-tabs { display: flex; border-bottom: 1px solid rgba(255,255,255,0.06); }
|
||||||
|
.code-tabs button {
|
||||||
|
padding: 8px 16px; background: none; border: none;
|
||||||
|
color: rgba(255,255,255,0.4); font-size: 12px; cursor: pointer;
|
||||||
|
border-bottom: 2px solid transparent;
|
||||||
|
}
|
||||||
|
.code-tabs button.active { color: #a8b4ff; border-bottom-color: #667eea; }
|
||||||
|
.code-body { padding: 16px; overflow-x: auto; }
|
||||||
|
.code-body pre {
|
||||||
|
font-family: 'JetBrains Mono', 'Fira Code', monospace;
|
||||||
|
font-size: 12px; line-height: 1.7; color: rgba(255,255,255,0.75);
|
||||||
|
white-space: pre; margin: 0;
|
||||||
|
}
|
||||||
|
.btn-copy {
|
||||||
|
position: absolute; top: 44px; right: 12px;
|
||||||
|
padding: 4px 12px; border-radius: 6px;
|
||||||
|
border: 1px solid rgba(255,255,255,0.1);
|
||||||
|
background: rgba(255,255,255,0.06);
|
||||||
|
color: rgba(255,255,255,0.5); font-size: 11px; cursor: pointer;
|
||||||
|
}
|
||||||
|
.btn-copy:hover { background: rgba(255,255,255,0.1); color: #fff; }
|
||||||
|
|
||||||
|
.table-wrap { overflow-x: auto; }
|
||||||
|
.data-table { width: 100%; border-collapse: collapse; }
|
||||||
|
.data-table th {
|
||||||
|
text-align: left; padding: 12px 16px; font-size: 11px; font-weight: 500;
|
||||||
|
text-transform: uppercase; letter-spacing: 0.5px;
|
||||||
|
color: var(--text-secondary); background: rgba(255,255,255,0.02);
|
||||||
|
border-bottom: 1px solid var(--border-color);
|
||||||
|
}
|
||||||
|
.data-table td {
|
||||||
|
padding: 12px 16px; font-size: 13px;
|
||||||
|
color: var(--text-primary); border-bottom: 1px solid var(--border-color);
|
||||||
|
}
|
||||||
|
.data-table tbody tr:hover { background: rgba(255,255,255,0.02); }
|
||||||
|
.cell-badge {
|
||||||
|
display: inline-block; padding: 2px 10px; border-radius: 20px;
|
||||||
|
background: rgba(255,255,255,0.04); color: var(--text-secondary); font-size: 12px;
|
||||||
|
}
|
||||||
|
.cell-name { font-weight: 500; }
|
||||||
|
.cell-code {
|
||||||
|
font-family: 'JetBrains Mono', monospace; font-size: 12px;
|
||||||
|
color: #a8b4ff; background: rgba(102,126,234,0.1);
|
||||||
|
padding: 2px 8px; border-radius: 4px;
|
||||||
|
}
|
||||||
|
.cell-mono { font-family: 'JetBrains Mono', monospace; font-size: 12px; color: var(--text-secondary); }
|
||||||
|
.cell-muted { color: var(--text-muted); }
|
||||||
|
.cell-empty { text-align: center; color: var(--text-muted); padding: 40px 16px !important; }
|
||||||
|
.cell-link { color: var(--accent-1); text-decoration: none; font-size: 13px; margin-right: 8px; }
|
||||||
|
.cell-link:hover { text-decoration: underline; }
|
||||||
|
|
||||||
|
.tag { display: inline-block; padding: 2px 10px; border-radius: 20px; font-size: 12px; font-weight: 500; }
|
||||||
|
.tag-green { background: rgba(34,197,94,0.12); color: #4ade80; }
|
||||||
|
.tag-red { background: rgba(239,68,68,0.12); color: #f87171; }
|
||||||
|
.tag-gray { background: rgba(255,255,255,0.05); color: var(--text-secondary); }
|
||||||
|
|
||||||
|
.btn-icon {
|
||||||
|
background: none; border: none; color: var(--text-muted);
|
||||||
|
cursor: pointer; padding: 4px; vertical-align: middle;
|
||||||
|
}
|
||||||
|
.btn-icon:hover { color: var(--danger); }
|
||||||
|
</style>
|
||||||
19
frontend/vite.config.js
Normal file
19
frontend/vite.config.js
Normal file
@ -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',
|
||||||
|
},
|
||||||
|
})
|
||||||
Loading…
Reference in New Issue
Block a user