Initial commit: LogHive centralized log management system
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,22 @@
|
||||
{
|
||||
"permissions": {
|
||||
"allow": [
|
||||
"Bash(docker --version)",
|
||||
"Bash(docker compose *)",
|
||||
"Bash(curl *)",
|
||||
"Bash(docker cp *)",
|
||||
"Bash(docker stop *)",
|
||||
"Bash(docker rm *)",
|
||||
"Bash(docker network *)",
|
||||
"Bash(docker volume *)",
|
||||
"Bash(python -c \"import sys,json; d=json.load\\(sys.stdin\\); print\\('timestamp:', d['hits'][0]['timestamp']\\)\")",
|
||||
"Bash(python -c ' *)",
|
||||
"Bash(python -c \"import sys,json; d=json.load\\(sys.stdin\\); print\\('bucket:', d['time_buckets'][0]['key'] if d['time_buckets'] else 'none'\\)\")",
|
||||
"Bash(python -c \"import sys,json; d=json.load\\(sys.stdin\\); print\\('found in range:', d['total'], 'logs'\\)\")",
|
||||
"Bash(git init *)",
|
||||
"Bash(git remote *)",
|
||||
"Bash(git add *)",
|
||||
"Bash(git config *)"
|
||||
]
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,27 @@
|
||||
# LogHive Environment Configuration
|
||||
|
||||
# ── API ─────────────────────────────────────────────────────
|
||||
API_HOST=0.0.0.0
|
||||
API_PORT=8000
|
||||
DEBUG=false
|
||||
CORS_ORIGINS=*
|
||||
|
||||
# ── Security ────────────────────────────────────────────────
|
||||
SECRET_KEY=change-me-to-a-random-string
|
||||
TOKEN_EXPIRE_HOURS=720
|
||||
|
||||
# ── PostgreSQL ──────────────────────────────────────────────
|
||||
DATABASE_URL=postgresql+asyncpg://LogHive:LogHive@localhost:5432/LogHive
|
||||
|
||||
# ── Redis ───────────────────────────────────────────────────
|
||||
REDIS_URL=redis://localhost:6379/0
|
||||
CELERY_BROKER_URL=redis://localhost:6379/1
|
||||
CELERY_RESULT_BACKEND=redis://localhost:6379/2
|
||||
|
||||
# ── Log Retention ───────────────────────────────────────────
|
||||
LOG_RETENTION_DAYS=30
|
||||
LOG_COLD_STORAGE_DAYS=7
|
||||
|
||||
# ── Notifications (optional) ────────────────────────────────
|
||||
FEISHU_WEBHOOK_URL=
|
||||
DINGTALK_WEBHOOK_URL=
|
||||
+27
@@ -0,0 +1,27 @@
|
||||
# Python
|
||||
__pycache__/
|
||||
*.py[cod]
|
||||
*.egg-info/
|
||||
dist/
|
||||
build/
|
||||
.venv/
|
||||
venv/
|
||||
|
||||
# Environment
|
||||
.env
|
||||
|
||||
# Node / Frontend
|
||||
node_modules/
|
||||
frontend/dist/
|
||||
|
||||
# IDE
|
||||
.idea/
|
||||
.vscode/
|
||||
*.swp
|
||||
|
||||
# Docker
|
||||
docker-compose.override.yml
|
||||
|
||||
# OS
|
||||
.DS_Store
|
||||
Thumbs.db
|
||||
@@ -0,0 +1,219 @@
|
||||
# 🐝 LogHive — 集中式日志管理系统
|
||||
|
||||
LogHive 是一个集中式的日志管理系统,可以从你的多个 Python 项目中收集日志,并提供搜索、分析和告警能力。
|
||||
|
||||
## 架构概览
|
||||
|
||||
```
|
||||
┌──────────────┐ ┌──────────────┐ ┌──────────────┐
|
||||
│ Python 项目 │ │ Python 项目 │ │ Python 项目 │
|
||||
│ (LogHive SDK)│ │ (LogHive SDK)│ │ (LogHive SDK)│
|
||||
└──────┬───────┘ └──────┬───────┘ └──────┬───────┘
|
||||
│ │ │
|
||||
└───────────────────┼───────────────────┘
|
||||
│ HTTPS / REST API
|
||||
▼
|
||||
┌─────────────────┐
|
||||
│ LogHive Backend │
|
||||
│ (FastAPI) │
|
||||
└────────┬─────────┘
|
||||
│
|
||||
┌────────────┼────────────┐
|
||||
▼ ▼ ▼
|
||||
┌──────────┐ ┌──────────┐ ┌──────────┐
|
||||
│ ES │ │PostgreSQL│ │ Redis │
|
||||
│ 日志存储 │ │ 元数据 │ │ 队列/缓存 │
|
||||
└──────────┘ └──────────┘ └──────────┘
|
||||
│
|
||||
┌──────┴──────┐
|
||||
▼ ▼
|
||||
┌─────────┐ ┌──────────┐
|
||||
│ Celery │ │ Vue 3 │
|
||||
│ 异步任务 │ │ 前端 │
|
||||
└─────────┘ └──────────┘
|
||||
```
|
||||
|
||||
## 功能特性
|
||||
|
||||
- **📥 日志收集** — 通过 REST API 接收 JSON 结构化日志,支持同步/异步客户端
|
||||
- **🔍 全文检索** — 基于 Elasticsearch,支持按项目、级别、关键词、Trace ID、时间范围过滤
|
||||
- **📊 统计分析** — 错误率趋势、级别分布、TOP N 错误、时序图表
|
||||
- **🔔 告警规则** — 可配置的阈值告警,支持飞书/钉钉通知
|
||||
- **🔗 链路追踪** — 通过 Trace ID 关联同一请求的跨模块日志
|
||||
- **🐍 Python SDK** — 一行代码集成到现有项目,支持标准 logging handler
|
||||
- **🔄 前后端分离** — FastAPI 后端 + Vue 3 前端
|
||||
|
||||
## 快速启动
|
||||
|
||||
### 前置条件
|
||||
|
||||
- Docker & Docker Compose
|
||||
- Python 3.10+(本地开发)
|
||||
|
||||
### 使用 Docker Compose(推荐)
|
||||
|
||||
```bash
|
||||
# 克隆项目
|
||||
cd LogHive
|
||||
|
||||
# 启动所有服务
|
||||
docker compose up -d
|
||||
|
||||
# 检查状态
|
||||
docker compose ps
|
||||
```
|
||||
|
||||
启动后访问:
|
||||
- **前端**:http://localhost:3000
|
||||
- **API**:http://localhost:8000
|
||||
- **API 健康检查**:http://localhost:8000/api/health
|
||||
- **Kibana**:http://localhost:5601
|
||||
|
||||
### 本地开发
|
||||
|
||||
```bash
|
||||
# 1. 启动依赖服务
|
||||
docker compose up -d postgres elasticsearch redis
|
||||
|
||||
# 2. 启动后端
|
||||
cd backend
|
||||
pip install -r requirements.txt
|
||||
uvicorn app.main:app --reload --port 8000
|
||||
|
||||
# 3. 启动前端
|
||||
cd frontend
|
||||
npm install
|
||||
npm run dev
|
||||
```
|
||||
|
||||
## 项目结构
|
||||
|
||||
```
|
||||
LogHive/
|
||||
├── backend/ # FastAPI 后端
|
||||
│ ├── app/
|
||||
│ │ ├── api/ # API 路由
|
||||
│ │ │ ├── logs.py # 日志收录 & 查询
|
||||
│ │ │ ├── projects.py # 项目管理
|
||||
│ │ │ ├── alerts.py # 告警管理
|
||||
│ │ │ └── dashboard.py # 仪表盘
|
||||
│ │ ├── models/ # SQLAlchemy 模型
|
||||
│ │ ├── schemas/ # Pydantic 数据模型
|
||||
│ │ ├── services/ # 业务逻辑层
|
||||
│ │ ├── core/ # 认证 & 工具
|
||||
│ │ ├── config.py # 配置
|
||||
│ │ ├── database.py # 数据库连接
|
||||
│ │ └── main.py # 入口
|
||||
│ ├── celery_worker/ # Celery 异步任务
|
||||
│ ├── requirements.txt
|
||||
│ └── Dockerfile
|
||||
├── client/ # Python SDK
|
||||
│ ├── loghive_client/
|
||||
│ │ ├── client.py # 同步客户端
|
||||
│ │ ├── async_client.py # 异步客户端
|
||||
│ │ └── handler.py # logging 集成
|
||||
│ ├── setup.py
|
||||
│ └── README.md
|
||||
├── frontend/ # Vue 3 前端
|
||||
│ ├── src/
|
||||
│ │ ├── api/ # API 调用层
|
||||
│ │ ├── router/ # 路由
|
||||
│ │ └── views/ # 页面
|
||||
│ ├── package.json
|
||||
│ ├── vite.config.js
|
||||
│ └── Dockerfile
|
||||
├── docker-compose.yml
|
||||
├── .env.example
|
||||
└── README.md
|
||||
```
|
||||
|
||||
## 使用指南
|
||||
|
||||
### 1. 创建项目 & 获取 API Key
|
||||
|
||||
```bash
|
||||
# 注册项目
|
||||
curl -X POST http://localhost:8000/api/projects \
|
||||
-H "Content-Type: application/json" \
|
||||
-d '{"name": "my-app", "description": "我的 Python 应用"}'
|
||||
|
||||
# 获取 API Key(在响应的 api_key 字段)
|
||||
```
|
||||
|
||||
### 2. 在你的项目中使用 SDK
|
||||
|
||||
```python
|
||||
# 安装 SDK
|
||||
# pip install loghive-client
|
||||
|
||||
from loghive_client import LogHiveLogger
|
||||
|
||||
logger = LogHiveLogger(
|
||||
project="my-app",
|
||||
api_key="your-api-key",
|
||||
endpoint="http://localhost:8000",
|
||||
)
|
||||
|
||||
logger.info("服务启动成功", extra={"port": 8080})
|
||||
logger.error("数据库连接超时", exc_info=True)
|
||||
```
|
||||
|
||||
### 3. 标准 logging 集成(零代码改动)
|
||||
|
||||
```python
|
||||
import logging
|
||||
from loghive_client import LogHiveHandler
|
||||
|
||||
handler = LogHiveHandler("my-app", "api-key", "http://localhost:8000")
|
||||
logging.getLogger().addHandler(handler)
|
||||
|
||||
# 所有现有的日志调用自动转发到 LogHive
|
||||
logging.info("这条日志也会发送到 LogHive")
|
||||
```
|
||||
|
||||
### 4. 直接通过 API 推送日志
|
||||
|
||||
```bash
|
||||
curl -X POST http://localhost:8000/api/logs/ingest \
|
||||
-H "Authorization: Bearer your-api-key" \
|
||||
-H "Content-Type: application/json" \
|
||||
-d '{
|
||||
"project": "my-app",
|
||||
"entries": [
|
||||
{
|
||||
"level": "info",
|
||||
"message": "Hello from my app!",
|
||||
"logger": "myapp.server",
|
||||
"extra": {"user_id": 42}
|
||||
}
|
||||
]
|
||||
}'
|
||||
```
|
||||
|
||||
## 技术栈
|
||||
|
||||
| 层级 | 技术 |
|
||||
|------|------|
|
||||
| 后端框架 | FastAPI (Python 3.12) |
|
||||
| 日志存储 | Elasticsearch 8.x |
|
||||
| 元数据存储 | PostgreSQL 16 / TimescaleDB |
|
||||
| 消息队列 | Redis 7 |
|
||||
| 异步任务 | Celery |
|
||||
| 前端 | Vue 3 + Element Plus + ECharts |
|
||||
| 部署 | Docker Compose |
|
||||
|
||||
## 配置
|
||||
|
||||
通过环境变量配置,详见 `.env.example`。主要配置项:
|
||||
|
||||
| 变量 | 默认值 | 说明 |
|
||||
|------|--------|------|
|
||||
| `SECRET_KEY` | — | JWT 密钥(生产必改) |
|
||||
| `ES_HOST` | localhost | Elasticsearch 地址 |
|
||||
| `DATABASE_URL` | — | PostgreSQL 连接串 |
|
||||
| `LOG_RETENTION_DAYS` | 30 | 日志保留天数 |
|
||||
| `FEISHU_WEBHOOK_URL` | — | 飞书机器人 Webhook |
|
||||
|
||||
## License
|
||||
|
||||
MIT
|
||||
@@ -0,0 +1,21 @@
|
||||
FROM python:3.12-slim
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
# Install system dependencies
|
||||
RUN apt-get update && apt-get install -y --no-install-recommends \
|
||||
gcc \
|
||||
&& rm -rf /var/lib/apt/lists/*
|
||||
|
||||
# Install Python dependencies
|
||||
COPY requirements.txt .
|
||||
RUN pip install --no-cache-dir -r requirements.txt
|
||||
|
||||
# Copy application
|
||||
COPY . .
|
||||
|
||||
# Expose port
|
||||
EXPOSE 8000
|
||||
|
||||
# Run with uvicorn
|
||||
CMD ["uvicorn", "app.main:app", "--host", "0.0.0.0", "--port", "8000"]
|
||||
@@ -0,0 +1,180 @@
|
||||
"""Alert management API routes."""
|
||||
|
||||
from datetime import datetime, timezone
|
||||
from typing import List, Optional
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException, Query, status
|
||||
from sqlalchemy import select, func
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.database import get_db
|
||||
from app.models.alert import AlertRule, AlertHistory, AlertLevel, AlertOperator
|
||||
from app.schemas.alert import (
|
||||
AlertRuleCreate,
|
||||
AlertRuleUpdate,
|
||||
AlertRuleResponse,
|
||||
AlertHistoryResponse,
|
||||
)
|
||||
|
||||
router = APIRouter(prefix="/api/alerts", tags=["alerts"])
|
||||
|
||||
|
||||
# ── Rule CRUD ──────────────────────────────────────────────────
|
||||
|
||||
|
||||
@router.post("/rules", response_model=AlertRuleResponse, status_code=201)
|
||||
async def create_alert_rule(
|
||||
data: AlertRuleCreate,
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
"""Create a new alert rule."""
|
||||
rule = AlertRule(
|
||||
project_id=data.project_id,
|
||||
name=data.name,
|
||||
level=data.level,
|
||||
field=data.field,
|
||||
operator=data.operator,
|
||||
threshold=data.threshold,
|
||||
window_minutes=data.window_minutes,
|
||||
notify_channels=",".join(data.notify_channels) if data.notify_channels else "",
|
||||
)
|
||||
db.add(rule)
|
||||
await db.flush()
|
||||
await db.refresh(rule)
|
||||
return _rule_to_response(rule)
|
||||
|
||||
|
||||
@router.get("/rules", response_model=List[AlertRuleResponse])
|
||||
async def list_alert_rules(
|
||||
project_id: Optional[str] = Query(None),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
"""List alert rules, optionally filtered by project."""
|
||||
stmt = select(AlertRule)
|
||||
if project_id:
|
||||
stmt = stmt.where(AlertRule.project_id == project_id)
|
||||
stmt = stmt.order_by(AlertRule.created_at.desc())
|
||||
result = await db.execute(stmt)
|
||||
return [_rule_to_response(r) for r in result.scalars().all()]
|
||||
|
||||
|
||||
@router.get("/rules/{rule_id}", response_model=AlertRuleResponse)
|
||||
async def get_alert_rule(rule_id: str, db: AsyncSession = Depends(get_db)):
|
||||
"""Get a single alert rule."""
|
||||
result = await db.execute(select(AlertRule).where(AlertRule.id == rule_id))
|
||||
rule = result.scalar_one_or_none()
|
||||
if not rule:
|
||||
raise HTTPException(status_code=404, detail="Alert rule not found")
|
||||
return _rule_to_response(rule)
|
||||
|
||||
|
||||
@router.patch("/rules/{rule_id}", response_model=AlertRuleResponse)
|
||||
async def update_alert_rule(
|
||||
rule_id: str,
|
||||
data: AlertRuleUpdate,
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
"""Update an alert rule."""
|
||||
result = await db.execute(select(AlertRule).where(AlertRule.id == rule_id))
|
||||
rule = result.scalar_one_or_none()
|
||||
if not rule:
|
||||
raise HTTPException(status_code=404, detail="Alert rule not found")
|
||||
|
||||
if data.name is not None:
|
||||
rule.name = data.name
|
||||
if data.level is not None:
|
||||
rule.level = data.level
|
||||
if data.field is not None:
|
||||
rule.field = data.field
|
||||
if data.operator is not None:
|
||||
rule.operator = data.operator
|
||||
if data.threshold is not None:
|
||||
rule.threshold = data.threshold
|
||||
if data.window_minutes is not None:
|
||||
rule.window_minutes = data.window_minutes
|
||||
if data.is_enabled is not None:
|
||||
rule.is_enabled = data.is_enabled
|
||||
if data.notify_channels is not None:
|
||||
rule.notify_channels = ",".join(data.notify_channels)
|
||||
|
||||
await db.flush()
|
||||
await db.refresh(rule)
|
||||
return _rule_to_response(rule)
|
||||
|
||||
|
||||
@router.delete("/rules/{rule_id}", status_code=204)
|
||||
async def delete_alert_rule(rule_id: str, db: AsyncSession = Depends(get_db)):
|
||||
"""Delete an alert rule."""
|
||||
result = await db.execute(select(AlertRule).where(AlertRule.id == rule_id))
|
||||
rule = result.scalar_one_or_none()
|
||||
if not rule:
|
||||
raise HTTPException(status_code=404, detail="Alert rule not found")
|
||||
await db.delete(rule)
|
||||
|
||||
|
||||
# ── Alert History ──────────────────────────────────────────────
|
||||
|
||||
|
||||
@router.get("/history", response_model=List[AlertHistoryResponse])
|
||||
async def list_alert_history(
|
||||
project_id: Optional[str] = Query(None),
|
||||
rule_id: Optional[str] = Query(None),
|
||||
limit: int = Query(100, ge=1, le=1000),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
"""List alert trigger history."""
|
||||
stmt = select(AlertHistory).order_by(AlertHistory.triggered_at.desc()).limit(limit)
|
||||
if project_id:
|
||||
stmt = stmt.where(AlertHistory.project_id == project_id)
|
||||
if rule_id:
|
||||
stmt = stmt.where(AlertHistory.rule_id == rule_id)
|
||||
result = await db.execute(stmt)
|
||||
return [_history_to_response(h) for h in result.scalars().all()]
|
||||
|
||||
|
||||
@router.post("/history/{history_id}/acknowledge", response_model=AlertHistoryResponse)
|
||||
async def acknowledge_alert(
|
||||
history_id: str,
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
"""Mark an alert as acknowledged."""
|
||||
result = await db.execute(select(AlertHistory).where(AlertHistory.id == history_id))
|
||||
history = result.scalar_one_or_none()
|
||||
if not history:
|
||||
raise HTTPException(status_code=404, detail="Alert history not found")
|
||||
history.is_acknowledged = True
|
||||
await db.flush()
|
||||
await db.refresh(history)
|
||||
return _history_to_response(history)
|
||||
|
||||
|
||||
# ── Helpers ────────────────────────────────────────────────────
|
||||
|
||||
|
||||
def _rule_to_response(rule: AlertRule) -> AlertRuleResponse:
|
||||
return AlertRuleResponse(
|
||||
id=rule.id,
|
||||
project_id=rule.project_id,
|
||||
name=rule.name,
|
||||
level=rule.level.value if hasattr(rule.level, "value") else rule.level,
|
||||
field=rule.field,
|
||||
operator=rule.operator.value if hasattr(rule.operator, "value") else rule.operator,
|
||||
threshold=rule.threshold,
|
||||
window_minutes=rule.window_minutes,
|
||||
is_enabled=rule.is_enabled,
|
||||
notify_channels=rule.notify_channels.split(",") if rule.notify_channels else [],
|
||||
created_at=rule.created_at,
|
||||
)
|
||||
|
||||
|
||||
def _history_to_response(history: AlertHistory) -> AlertHistoryResponse:
|
||||
return AlertHistoryResponse(
|
||||
id=history.id,
|
||||
rule_id=history.rule_id,
|
||||
project_id=history.project_id,
|
||||
level=history.level.value if hasattr(history.level, "value") else history.level,
|
||||
message=history.message,
|
||||
triggered_value=history.triggered_value,
|
||||
is_acknowledged=history.is_acknowledged,
|
||||
triggered_at=history.triggered_at,
|
||||
)
|
||||
@@ -0,0 +1,34 @@
|
||||
"""Dashboard API routes."""
|
||||
|
||||
from typing import Optional
|
||||
|
||||
from fastapi import APIRouter, Depends, Query
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.database import get_db
|
||||
from app.services.analytics_service import AnalyticsService
|
||||
|
||||
router = APIRouter(prefix="/api/dashboard", tags=["dashboard"])
|
||||
|
||||
|
||||
@router.get("/stats")
|
||||
async def dashboard_stats(
|
||||
project: Optional[str] = Query(None),
|
||||
hours: int = Query(24, ge=1, le=720),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
"""Get overview statistics for the dashboard."""
|
||||
service = AnalyticsService(db)
|
||||
return await service.get_dashboard_stats(project=project, hours=hours)
|
||||
|
||||
|
||||
@router.get("/timeseries")
|
||||
async def dashboard_timeseries(
|
||||
project: Optional[str] = Query(None),
|
||||
hours: int = Query(24, ge=1, le=720),
|
||||
interval: str = Query("5m", max_length=16),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
"""Get time-series data for chart rendering."""
|
||||
service = AnalyticsService(db)
|
||||
return await service.get_time_series(project=project, hours=hours, interval=interval)
|
||||
@@ -0,0 +1,253 @@
|
||||
"""Log ingestion and query API routes."""
|
||||
|
||||
import logging
|
||||
from datetime import datetime, timezone, timedelta
|
||||
from typing import Optional
|
||||
|
||||
from fastapi import APIRouter, Depends, Header, HTTPException, Query, status
|
||||
from sqlalchemy import func, select, delete, and_
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.config import settings, to_display_time, from_query_time
|
||||
from app.database import get_db
|
||||
from app.schemas.log import (
|
||||
LogBatchRequest,
|
||||
LogEntry as LogEntrySchema,
|
||||
LogQueryParams,
|
||||
LogSearchResult,
|
||||
LogStatsResult,
|
||||
)
|
||||
from app.models.project import Project
|
||||
from app.models.log import LogEntry
|
||||
from app.core.auth import verify_api_key, resolve_project_by_api_key
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
router = APIRouter(prefix="/api/logs", tags=["logs"])
|
||||
|
||||
|
||||
# ── Ingestion ─────────────────────────────────────────────────
|
||||
|
||||
|
||||
@router.post("/ingest", status_code=201)
|
||||
async def ingest_logs(
|
||||
batch: LogBatchRequest,
|
||||
authorization: Optional[str] = Header(None),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
"""Receive a batch of log entries from a project."""
|
||||
api_key = await verify_api_key(authorization)
|
||||
project = await resolve_project_by_api_key(db, api_key)
|
||||
|
||||
now = datetime.now(timezone.utc)
|
||||
rows = []
|
||||
for entry in batch.entries:
|
||||
row = LogEntry(
|
||||
project_id=project.id,
|
||||
project_name=project.name,
|
||||
timestamp=entry.timestamp or now,
|
||||
level=entry.level,
|
||||
logger=entry.logger,
|
||||
message=entry.message,
|
||||
module=entry.module,
|
||||
function=entry.function,
|
||||
line_no=entry.line_no,
|
||||
trace_id=entry.trace_id,
|
||||
exception=entry.exception,
|
||||
extra=entry.extra,
|
||||
)
|
||||
rows.append(row)
|
||||
|
||||
db.add_all(rows)
|
||||
await db.flush()
|
||||
return {"accepted": len(rows), "project": project.name}
|
||||
|
||||
|
||||
@router.post("/ingest/single", status_code=201)
|
||||
async def ingest_single_log(
|
||||
entry: LogEntrySchema,
|
||||
project_name: str = Query(..., description="Project name"),
|
||||
authorization: Optional[str] = Header(None),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
"""Receive a single log entry (useful for testing / simple clients)."""
|
||||
api_key = await verify_api_key(authorization)
|
||||
project = await resolve_project_by_api_key(db, api_key)
|
||||
|
||||
now = datetime.now(timezone.utc)
|
||||
row = LogEntry(
|
||||
project_id=project.id,
|
||||
project_name=project.name,
|
||||
timestamp=entry.timestamp or now,
|
||||
level=entry.level,
|
||||
logger=entry.logger,
|
||||
message=entry.message,
|
||||
module=entry.module,
|
||||
function=entry.function,
|
||||
line_no=entry.line_no,
|
||||
trace_id=entry.trace_id,
|
||||
exception=entry.exception,
|
||||
extra=entry.extra,
|
||||
)
|
||||
db.add(row)
|
||||
await db.flush()
|
||||
return {"accepted": 1, "project": project.name}
|
||||
|
||||
|
||||
# ── Query ──────────────────────────────────────────────────────
|
||||
|
||||
|
||||
@router.get("/search", response_model=LogSearchResult)
|
||||
async def search_logs(
|
||||
project: Optional[str] = Query(None, description="Filter by project name"),
|
||||
level: Optional[str] = Query(None, pattern="^(debug|info|warning|error|critical)$"),
|
||||
query: Optional[str] = Query(None, max_length=1024),
|
||||
trace_id: Optional[str] = Query(None, max_length=64),
|
||||
start_time: Optional[datetime] = None,
|
||||
end_time: Optional[datetime] = None,
|
||||
page: int = Query(1, ge=1),
|
||||
page_size: int = Query(50, ge=1, le=1000),
|
||||
sort_by: str = Query("timestamp", max_length=32),
|
||||
sort_order: str = Query("desc", pattern="^(asc|desc)$"),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
"""Search logs with full-text query and filters."""
|
||||
conditions = []
|
||||
|
||||
if project:
|
||||
conditions.append(LogEntry.project_name == project)
|
||||
if level:
|
||||
conditions.append(LogEntry.level == level)
|
||||
if trace_id:
|
||||
conditions.append(LogEntry.trace_id == trace_id)
|
||||
if start_time:
|
||||
conditions.append(LogEntry.timestamp >= from_query_time(start_time))
|
||||
if end_time:
|
||||
conditions.append(LogEntry.timestamp <= from_query_time(end_time))
|
||||
if query:
|
||||
conditions.append(
|
||||
func.to_tsvector(
|
||||
"english",
|
||||
func.concat(
|
||||
LogEntry.message, " ",
|
||||
func.coalesce(LogEntry.logger, ""), " ",
|
||||
func.coalesce(LogEntry.module, ""),
|
||||
),
|
||||
).bool_op("@@")(
|
||||
func.plainto_tsquery("english", query)
|
||||
)
|
||||
)
|
||||
|
||||
# Sort
|
||||
sort_col = getattr(LogEntry, sort_by, LogEntry.timestamp)
|
||||
order = sort_col.desc() if sort_order == "desc" else sort_col.asc()
|
||||
|
||||
# Count total
|
||||
count_stmt = select(func.count(LogEntry.id))
|
||||
if conditions:
|
||||
count_stmt = count_stmt.where(and_(*conditions))
|
||||
total = (await db.execute(count_stmt)).scalar() or 0
|
||||
|
||||
# Fetch page
|
||||
stmt = select(LogEntry).order_by(order).offset((page - 1) * page_size).limit(page_size)
|
||||
if conditions:
|
||||
stmt = stmt.where(and_(*conditions))
|
||||
rows = (await db.execute(stmt)).scalars().all()
|
||||
|
||||
hits = []
|
||||
for r in rows:
|
||||
hits.append({
|
||||
"project_id": r.project_id,
|
||||
"project_name": r.project_name,
|
||||
"timestamp": to_display_time(r.timestamp),
|
||||
"level": r.level,
|
||||
"logger": r.logger,
|
||||
"message": r.message,
|
||||
"module": r.module,
|
||||
"function": r.function,
|
||||
"line_no": r.line_no,
|
||||
"trace_id": r.trace_id,
|
||||
"exception": r.exception,
|
||||
"extra": r.extra,
|
||||
})
|
||||
|
||||
return LogSearchResult(total=total, page=page, page_size=page_size, hits=hits)
|
||||
|
||||
|
||||
@router.get("/stats", response_model=LogStatsResult)
|
||||
async def log_stats(
|
||||
project: Optional[str] = Query(None),
|
||||
start_time: Optional[datetime] = None,
|
||||
end_time: Optional[datetime] = None,
|
||||
interval: str = Query("5m", max_length=16),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
"""Get aggregated log statistics over a time range."""
|
||||
conditions = []
|
||||
if project:
|
||||
conditions.append(LogEntry.project_name == project)
|
||||
if start_time:
|
||||
conditions.append(LogEntry.timestamp >= from_query_time(start_time))
|
||||
if end_time:
|
||||
conditions.append(LogEntry.timestamp <= from_query_time(end_time))
|
||||
|
||||
def apply(stmt):
|
||||
if conditions:
|
||||
stmt = stmt.where(and_(*conditions))
|
||||
return stmt
|
||||
|
||||
# Time buckets (hourly)
|
||||
stmt = apply(
|
||||
select(
|
||||
func.date_trunc("hour", LogEntry.timestamp).label("bucket"),
|
||||
func.count(LogEntry.id).label("cnt"),
|
||||
).group_by("bucket").order_by("bucket")
|
||||
)
|
||||
time_rows = (await db.execute(stmt)).all()
|
||||
time_buckets = [
|
||||
{"key": to_display_time(r.bucket), "doc_count": r.cnt}
|
||||
for r in time_rows
|
||||
]
|
||||
|
||||
# Level counts
|
||||
stmt = apply(
|
||||
select(LogEntry.level, func.count(LogEntry.id).label("cnt"))
|
||||
.group_by(LogEntry.level)
|
||||
)
|
||||
level_rows = (await db.execute(stmt)).all()
|
||||
level_counts = {r.level: r.cnt for r in level_rows}
|
||||
|
||||
# Top loggers
|
||||
stmt = apply(
|
||||
select(LogEntry.logger, func.count(LogEntry.id).label("cnt"))
|
||||
.group_by(LogEntry.logger)
|
||||
.order_by(func.count(LogEntry.id).desc())
|
||||
.limit(20)
|
||||
)
|
||||
logger_rows = (await db.execute(stmt)).all()
|
||||
top_loggers = [{"logger": r.logger, "count": r.cnt} for r in logger_rows]
|
||||
|
||||
# Total count
|
||||
total = (await db.execute(apply(select(func.count(LogEntry.id))))).scalar() or 0
|
||||
|
||||
return LogStatsResult(
|
||||
project=project,
|
||||
time_buckets=time_buckets,
|
||||
level_counts=level_counts,
|
||||
top_loggers=top_loggers,
|
||||
total=total,
|
||||
)
|
||||
|
||||
|
||||
@router.delete("/cleanup")
|
||||
async def cleanup_old_logs(
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
"""Delete log entries older than the retention period."""
|
||||
cutoff = datetime.now(timezone.utc) - timedelta(days=settings.LOG_RETENTION_DAYS)
|
||||
|
||||
result = await db.execute(
|
||||
delete(LogEntry).where(LogEntry.timestamp < cutoff)
|
||||
)
|
||||
await db.flush()
|
||||
|
||||
return {"deleted_count": result.rowcount}
|
||||
@@ -0,0 +1,123 @@
|
||||
"""Project management API routes."""
|
||||
|
||||
import uuid
|
||||
from datetime import datetime, timezone
|
||||
from typing import List
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException, status
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.database import get_db
|
||||
from app.models.project import Project
|
||||
from app.schemas.project import ProjectCreate, ProjectUpdate, ProjectResponse, ProjectWithToken
|
||||
|
||||
router = APIRouter(prefix="/api/projects", tags=["projects"])
|
||||
|
||||
|
||||
@router.post("", response_model=ProjectWithToken, status_code=201)
|
||||
async def create_project(
|
||||
data: ProjectCreate,
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
"""Register a new project for log collection."""
|
||||
existing = await db.execute(select(Project).where(Project.name == data.name))
|
||||
if existing.scalar_one_or_none():
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_409_CONFLICT,
|
||||
detail=f"Project '{data.name}' already exists",
|
||||
)
|
||||
|
||||
project = Project(
|
||||
name=data.name,
|
||||
description=data.description,
|
||||
api_key=uuid.uuid4().hex,
|
||||
)
|
||||
db.add(project)
|
||||
await db.flush()
|
||||
await db.refresh(project)
|
||||
return project
|
||||
|
||||
|
||||
@router.get("", response_model=List[ProjectResponse])
|
||||
async def list_projects(
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
"""List all registered projects."""
|
||||
result = await db.execute(select(Project).order_by(Project.created_at.desc()))
|
||||
return result.scalars().all()
|
||||
|
||||
|
||||
@router.get("/{project_id}", response_model=ProjectResponse)
|
||||
async def get_project(
|
||||
project_id: str,
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
"""Get a single project by ID."""
|
||||
result = await db.execute(select(Project).where(Project.id == project_id))
|
||||
project = result.scalar_one_or_none()
|
||||
if not project:
|
||||
raise HTTPException(status_code=404, detail="Project not found")
|
||||
return project
|
||||
|
||||
|
||||
@router.patch("/{project_id}", response_model=ProjectResponse)
|
||||
async def update_project(
|
||||
project_id: str,
|
||||
data: ProjectUpdate,
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
"""Update a project."""
|
||||
result = await db.execute(select(Project).where(Project.id == project_id))
|
||||
project = result.scalar_one_or_none()
|
||||
if not project:
|
||||
raise HTTPException(status_code=404, detail="Project not found")
|
||||
|
||||
if data.name is not None:
|
||||
# Check uniqueness
|
||||
existing = await db.execute(
|
||||
select(Project).where(Project.name == data.name, Project.id != project_id)
|
||||
)
|
||||
if existing.scalar_one_or_none():
|
||||
raise HTTPException(status_code=409, detail="Name already taken")
|
||||
project.name = data.name
|
||||
if data.description is not None:
|
||||
project.description = data.description
|
||||
if data.is_active is not None:
|
||||
project.is_active = data.is_active
|
||||
|
||||
project.updated_at = datetime.now(timezone.utc)
|
||||
await db.flush()
|
||||
await db.refresh(project)
|
||||
return project
|
||||
|
||||
|
||||
@router.delete("/{project_id}", status_code=204)
|
||||
async def delete_project(
|
||||
project_id: str,
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
"""Delete a project."""
|
||||
result = await db.execute(select(Project).where(Project.id == project_id))
|
||||
project = result.scalar_one_or_none()
|
||||
if not project:
|
||||
raise HTTPException(status_code=404, detail="Project not found")
|
||||
await db.delete(project)
|
||||
|
||||
|
||||
@router.post("/{project_id}/rotate-key", response_model=ProjectWithToken)
|
||||
async def rotate_api_key(
|
||||
project_id: str,
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
"""Rotate a project's API key."""
|
||||
result = await db.execute(select(Project).where(Project.id == project_id))
|
||||
project = result.scalar_one_or_none()
|
||||
if not project:
|
||||
raise HTTPException(status_code=404, detail="Project not found")
|
||||
|
||||
project.api_key = uuid.uuid4().hex
|
||||
project.updated_at = datetime.now(timezone.utc)
|
||||
await db.flush()
|
||||
await db.refresh(project)
|
||||
return project
|
||||
@@ -0,0 +1,79 @@
|
||||
"""LogHive configuration."""
|
||||
|
||||
import os
|
||||
from datetime import datetime, timezone, timedelta
|
||||
from typing import Optional
|
||||
|
||||
# Beijing timezone offset
|
||||
_BEIJING_TZ = timezone(timedelta(hours=8))
|
||||
|
||||
|
||||
def to_display_time(dt: datetime) -> str:
|
||||
"""Convert a UTC datetime to Beijing time for display.
|
||||
Returns ISO format without timezone suffix (e.g. '2026-05-08T14:30:00')."""
|
||||
if dt is None:
|
||||
return None
|
||||
if dt.tzinfo is None:
|
||||
dt = dt.replace(tzinfo=timezone.utc)
|
||||
local = dt.astimezone(_BEIJING_TZ)
|
||||
return local.strftime("%Y-%m-%dT%H:%M:%S")
|
||||
|
||||
|
||||
def from_query_time(dt: Optional[datetime]) -> Optional[datetime]:
|
||||
"""Interpret a naive datetime query param as Beijing time, return UTC datetime."""
|
||||
if dt is None:
|
||||
return None
|
||||
if dt.tzinfo is None:
|
||||
dt = dt.replace(tzinfo=_BEIJING_TZ)
|
||||
return dt.astimezone(timezone.utc)
|
||||
|
||||
|
||||
class Settings:
|
||||
"""Application settings loaded from environment variables."""
|
||||
|
||||
# Service name
|
||||
SERVICE_NAME: str = "LogHive"
|
||||
|
||||
# API
|
||||
API_HOST: str = os.getenv("API_HOST", "0.0.0.0")
|
||||
API_PORT: int = int(os.getenv("API_PORT", "8000"))
|
||||
DEBUG: bool = os.getenv("DEBUG", "false").lower() == "true"
|
||||
CORS_ORIGINS: list[str] = os.getenv("CORS_ORIGINS", "*").split(",")
|
||||
|
||||
# Security
|
||||
SECRET_KEY: str = os.getenv("SECRET_KEY", "change-me-in-production")
|
||||
TOKEN_EXPIRE_HOURS: int = int(os.getenv("TOKEN_EXPIRE_HOURS", "720"))
|
||||
|
||||
# PostgreSQL
|
||||
DATABASE_URL: str = os.getenv(
|
||||
"DATABASE_URL",
|
||||
"postgresql+asyncpg://LogHive:LogHive@localhost:5432/LogHive",
|
||||
)
|
||||
|
||||
# Redis
|
||||
REDIS_URL: str = os.getenv("REDIS_URL", "redis://localhost:6379/0")
|
||||
|
||||
# Celery
|
||||
CELERY_BROKER_URL: str = os.getenv("CELERY_BROKER_URL", "redis://localhost:6379/1")
|
||||
CELERY_RESULT_BACKEND: str = os.getenv(
|
||||
"CELERY_RESULT_BACKEND", "redis://localhost:6379/2"
|
||||
)
|
||||
|
||||
# Log retention (days)
|
||||
LOG_RETENTION_DAYS: int = int(os.getenv("LOG_RETENTION_DAYS", "30"))
|
||||
LOG_COLD_STORAGE_DAYS: int = int(os.getenv("LOG_COLD_STORAGE_DAYS", "7"))
|
||||
|
||||
# Rate limit
|
||||
RATE_LIMIT_PER_SECOND: int = int(os.getenv("RATE_LIMIT_PER_SECOND", "1000"))
|
||||
|
||||
# Alert
|
||||
ALERT_CHECK_INTERVAL_SECONDS: int = int(
|
||||
os.getenv("ALERT_CHECK_INTERVAL_SECONDS", "60")
|
||||
)
|
||||
|
||||
# Notification
|
||||
FEISHU_WEBHOOK_URL: Optional[str] = os.getenv("FEISHU_WEBHOOK_URL", None)
|
||||
DINGTALK_WEBHOOK_URL: Optional[str] = os.getenv("DINGTALK_WEBHOOK_URL", None)
|
||||
|
||||
|
||||
settings = Settings()
|
||||
@@ -0,0 +1 @@
|
||||
from .auth import verify_api_key, resolve_project_by_api_key
|
||||
@@ -0,0 +1,50 @@
|
||||
"""Auth utilities — API key verification."""
|
||||
|
||||
import hmac
|
||||
from typing import Optional
|
||||
|
||||
from fastapi import Header, HTTPException, status
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.database import get_db
|
||||
from app.models.project import Project
|
||||
|
||||
|
||||
async def verify_api_key(
|
||||
authorization: Optional[str] = Header(None),
|
||||
) -> str:
|
||||
"""Extract and verify the API key from the Authorization header.
|
||||
|
||||
Returns the project_id on success.
|
||||
"""
|
||||
if not authorization:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_401_UNAUTHORIZED,
|
||||
detail="Missing Authorization header",
|
||||
)
|
||||
|
||||
scheme, _, key = authorization.partition(" ")
|
||||
if scheme.lower() != "bearer" or not key.strip():
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_401_UNAUTHORIZED,
|
||||
detail="Invalid Authorization format. Use: Bearer <api_key>",
|
||||
)
|
||||
|
||||
return key.strip()
|
||||
|
||||
|
||||
async def resolve_project_by_api_key(
|
||||
db: AsyncSession, api_key: str
|
||||
) -> Project:
|
||||
"""Look up a project by its API key."""
|
||||
result = await db.execute(
|
||||
select(Project).where(Project.api_key == api_key, Project.is_active == True)
|
||||
)
|
||||
project = result.scalar_one_or_none()
|
||||
if not project:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_401_UNAUTHORIZED,
|
||||
detail="Invalid or inactive API key",
|
||||
)
|
||||
return project
|
||||
@@ -0,0 +1 @@
|
||||
"""Core auth logic."""
|
||||
@@ -0,0 +1,37 @@
|
||||
"""Database connections and session management."""
|
||||
|
||||
from sqlalchemy.ext.asyncio import AsyncSession, create_async_engine, async_sessionmaker
|
||||
from sqlalchemy.orm import DeclarativeBase
|
||||
|
||||
from app.config import settings
|
||||
|
||||
# ── PostgreSQL ────────────────────────────────────────────────────
|
||||
engine = create_async_engine(settings.DATABASE_URL, echo=settings.DEBUG)
|
||||
async_session_factory = async_sessionmaker(engine, expire_on_commit=False)
|
||||
|
||||
|
||||
class Base(DeclarativeBase):
|
||||
pass
|
||||
|
||||
|
||||
async def get_db() -> AsyncSession:
|
||||
"""Yield a DB session per request."""
|
||||
async with async_session_factory() as session:
|
||||
try:
|
||||
yield session
|
||||
await session.commit()
|
||||
except Exception:
|
||||
await session.rollback()
|
||||
raise
|
||||
finally:
|
||||
await session.close()
|
||||
|
||||
|
||||
async def init_db() -> None:
|
||||
"""Create all tables (use Alembic in production)."""
|
||||
async with engine.begin() as conn:
|
||||
await conn.run_sync(Base.metadata.create_all)
|
||||
|
||||
|
||||
async def close_db() -> None:
|
||||
await engine.dispose()
|
||||
@@ -0,0 +1,65 @@
|
||||
"""LogHive — Centralized Log Management System.
|
||||
|
||||
FastAPI application that receives, stores, searches and analyzes logs
|
||||
from multiple Python projects.
|
||||
"""
|
||||
|
||||
import logging
|
||||
from contextlib import asynccontextmanager
|
||||
|
||||
from fastapi import FastAPI
|
||||
from fastapi.middleware.cors import CORSMiddleware
|
||||
|
||||
from app.config import settings
|
||||
from app.database import init_db, close_db
|
||||
from app.api import logs, projects, alerts, dashboard
|
||||
|
||||
# ── Logging ────────────────────────────────────────────────────
|
||||
logging.basicConfig(
|
||||
level=logging.INFO if not settings.DEBUG else logging.DEBUG,
|
||||
format="%(asctime)s [%(levelname)s] %(name)s: %(message)s",
|
||||
)
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
# ── Lifecycle ──────────────────────────────────────────────────
|
||||
|
||||
|
||||
@asynccontextmanager
|
||||
async def lifespan(app: FastAPI):
|
||||
"""Startup and shutdown lifecycle."""
|
||||
logger.info("🚀 LogHive starting up...")
|
||||
await init_db()
|
||||
yield
|
||||
logger.info("🛑 LogHive shutting down...")
|
||||
await close_db()
|
||||
|
||||
|
||||
# ── App ────────────────────────────────────────────────────────
|
||||
|
||||
app = FastAPI(
|
||||
title="LogHive API",
|
||||
description="Centralized log management system — ingest, search, monitor, and alert on logs from your Python services.",
|
||||
version="0.1.0",
|
||||
lifespan=lifespan,
|
||||
)
|
||||
|
||||
app.add_middleware(
|
||||
CORSMiddleware,
|
||||
allow_origins=settings.CORS_ORIGINS,
|
||||
allow_credentials=True,
|
||||
allow_methods=["*"],
|
||||
allow_headers=["*"],
|
||||
)
|
||||
|
||||
# ── Routes ─────────────────────────────────────────────────────
|
||||
|
||||
app.include_router(logs.router)
|
||||
app.include_router(projects.router)
|
||||
app.include_router(alerts.router)
|
||||
app.include_router(dashboard.router)
|
||||
|
||||
|
||||
@app.get("/api/health")
|
||||
async def health():
|
||||
return {"status": "ok", "service": settings.SERVICE_NAME, "version": "0.1.0"}
|
||||
@@ -0,0 +1,5 @@
|
||||
from app.models.project import Project
|
||||
from app.models.alert import AlertRule, AlertHistory
|
||||
from app.models.log import LogEntry
|
||||
|
||||
__all__ = ["Project", "AlertRule", "AlertHistory", "LogEntry"]
|
||||
@@ -0,0 +1,71 @@
|
||||
"""Alert models — rules and history."""
|
||||
|
||||
import uuid
|
||||
from datetime import datetime, timezone
|
||||
|
||||
from sqlalchemy import String, Integer, Float, Boolean, DateTime, Text, Enum as SAEnum
|
||||
from sqlalchemy.orm import Mapped, mapped_column
|
||||
import enum
|
||||
|
||||
from app.database import Base
|
||||
|
||||
|
||||
class AlertLevel(str, enum.Enum):
|
||||
INFO = "info"
|
||||
WARNING = "warning"
|
||||
CRITICAL = "critical"
|
||||
|
||||
|
||||
class AlertOperator(str, enum.Enum):
|
||||
GT = "gt"
|
||||
GTE = "gte"
|
||||
LT = "lt"
|
||||
LTE = "lte"
|
||||
EQ = "eq"
|
||||
|
||||
|
||||
class AlertRule(Base):
|
||||
__tablename__ = "alert_rules"
|
||||
|
||||
id: Mapped[str] = mapped_column(
|
||||
String(36), primary_key=True, default=lambda: str(uuid.uuid4())
|
||||
)
|
||||
project_id: Mapped[str] = mapped_column(String(36), nullable=False, index=True)
|
||||
name: Mapped[str] = mapped_column(String(128), nullable=False)
|
||||
level: Mapped[AlertLevel] = mapped_column(
|
||||
SAEnum(AlertLevel), default=AlertLevel.WARNING
|
||||
)
|
||||
field: Mapped[str] = mapped_column(
|
||||
String(64), nullable=False, default="level"
|
||||
) # log field to evaluate
|
||||
operator: Mapped[AlertOperator] = mapped_column(
|
||||
SAEnum(AlertOperator), default=AlertOperator.GTE
|
||||
)
|
||||
threshold: Mapped[float] = mapped_column(Float, nullable=False)
|
||||
window_minutes: Mapped[int] = mapped_column(Integer, default=5)
|
||||
is_enabled: Mapped[bool] = mapped_column(Boolean, default=True)
|
||||
notify_channels: Mapped[str] = mapped_column(
|
||||
String(256), default=""
|
||||
) # comma-separated
|
||||
created_at: Mapped[datetime] = mapped_column(
|
||||
DateTime(timezone=True), default=lambda: datetime.now(timezone.utc)
|
||||
)
|
||||
|
||||
|
||||
class AlertHistory(Base):
|
||||
__tablename__ = "alert_history"
|
||||
|
||||
id: Mapped[str] = mapped_column(
|
||||
String(36), primary_key=True, default=lambda: str(uuid.uuid4())
|
||||
)
|
||||
rule_id: Mapped[str] = mapped_column(
|
||||
String(36), nullable=False, index=True
|
||||
)
|
||||
project_id: Mapped[str] = mapped_column(String(36), nullable=False, index=True)
|
||||
level: Mapped[AlertLevel] = mapped_column(SAEnum(AlertLevel))
|
||||
message: Mapped[str] = mapped_column(Text, nullable=False)
|
||||
triggered_value: Mapped[float] = mapped_column(Float, nullable=True)
|
||||
is_acknowledged: Mapped[bool] = mapped_column(Boolean, default=False)
|
||||
triggered_at: Mapped[datetime] = mapped_column(
|
||||
DateTime(timezone=True), default=lambda: datetime.now(timezone.utc)
|
||||
)
|
||||
@@ -0,0 +1,37 @@
|
||||
"""Log entry model — stored in PostgreSQL."""
|
||||
|
||||
import uuid
|
||||
from datetime import datetime, timezone
|
||||
|
||||
from sqlalchemy import DateTime, Index, Integer, String, Text
|
||||
from sqlalchemy.dialects.postgresql import JSONB
|
||||
from sqlalchemy.orm import Mapped, mapped_column
|
||||
|
||||
from app.database import Base
|
||||
|
||||
|
||||
class LogEntry(Base):
|
||||
__tablename__ = "log_entries"
|
||||
|
||||
id: Mapped[str] = mapped_column(
|
||||
String(36), primary_key=True, default=lambda: str(uuid.uuid4())
|
||||
)
|
||||
project_id: Mapped[str] = mapped_column(String(36), nullable=False, index=True)
|
||||
project_name: Mapped[str] = mapped_column(String(128), nullable=False, index=True)
|
||||
timestamp: Mapped[datetime] = mapped_column(
|
||||
DateTime(timezone=True), nullable=False, index=True,
|
||||
default=lambda: datetime.now(timezone.utc),
|
||||
)
|
||||
level: Mapped[str] = mapped_column(String(16), nullable=False, index=True)
|
||||
logger: Mapped[str] = mapped_column(String(128), default="root")
|
||||
message: Mapped[str] = mapped_column(Text, nullable=False)
|
||||
module: Mapped[str | None] = mapped_column(String(256), nullable=True)
|
||||
function: Mapped[str | None] = mapped_column(String(128), nullable=True)
|
||||
line_no: Mapped[int | None] = mapped_column(Integer, nullable=True)
|
||||
trace_id: Mapped[str | None] = mapped_column(String(64), nullable=True, index=True)
|
||||
exception: Mapped[str | None] = mapped_column(Text, nullable=True)
|
||||
extra: Mapped[dict] = mapped_column(JSONB, default=dict)
|
||||
|
||||
__table_args__ = (
|
||||
Index("ix_log_entries_project_time", "project_id", timestamp.desc()),
|
||||
)
|
||||
@@ -0,0 +1,34 @@
|
||||
"""Project model — represents an external service that pushes logs."""
|
||||
|
||||
import uuid
|
||||
from datetime import datetime, timezone
|
||||
|
||||
from sqlalchemy import String, Boolean, DateTime, Text
|
||||
from sqlalchemy.orm import Mapped, mapped_column
|
||||
|
||||
from app.database import Base
|
||||
|
||||
|
||||
class Project(Base):
|
||||
__tablename__ = "projects"
|
||||
|
||||
id: Mapped[str] = mapped_column(
|
||||
String(36), primary_key=True, default=lambda: str(uuid.uuid4())
|
||||
)
|
||||
name: Mapped[str] = mapped_column(String(128), unique=True, nullable=False)
|
||||
description: Mapped[str] = mapped_column(Text, default="")
|
||||
api_key: Mapped[str] = mapped_column(
|
||||
String(64), unique=True, nullable=False, default=lambda: uuid.uuid4().hex
|
||||
)
|
||||
is_active: Mapped[bool] = mapped_column(Boolean, default=True)
|
||||
created_at: Mapped[datetime] = mapped_column(
|
||||
DateTime(timezone=True), default=lambda: datetime.now(timezone.utc)
|
||||
)
|
||||
updated_at: Mapped[datetime] = mapped_column(
|
||||
DateTime(timezone=True),
|
||||
default=lambda: datetime.now(timezone.utc),
|
||||
onupdate=lambda: datetime.now(timezone.utc),
|
||||
)
|
||||
|
||||
def __repr__(self) -> str:
|
||||
return f"<Project {self.name}>"
|
||||
@@ -0,0 +1,35 @@
|
||||
from app.schemas.log import (
|
||||
LogEntry,
|
||||
LogBatchRequest,
|
||||
LogQueryParams,
|
||||
LogSearchResult,
|
||||
LogStatsResult,
|
||||
)
|
||||
from app.schemas.project import (
|
||||
ProjectCreate,
|
||||
ProjectUpdate,
|
||||
ProjectResponse,
|
||||
ProjectWithToken,
|
||||
)
|
||||
from app.schemas.alert import (
|
||||
AlertRuleCreate,
|
||||
AlertRuleUpdate,
|
||||
AlertRuleResponse,
|
||||
AlertHistoryResponse,
|
||||
)
|
||||
|
||||
__all__ = [
|
||||
"LogEntry",
|
||||
"LogBatchRequest",
|
||||
"LogQueryParams",
|
||||
"LogSearchResult",
|
||||
"LogStatsResult",
|
||||
"ProjectCreate",
|
||||
"ProjectUpdate",
|
||||
"ProjectResponse",
|
||||
"ProjectWithToken",
|
||||
"AlertRuleCreate",
|
||||
"AlertRuleUpdate",
|
||||
"AlertRuleResponse",
|
||||
"AlertHistoryResponse",
|
||||
]
|
||||
@@ -0,0 +1,63 @@
|
||||
"""Alert schemas."""
|
||||
|
||||
from datetime import datetime
|
||||
from typing import List, Optional
|
||||
|
||||
from pydantic import BaseModel, Field
|
||||
|
||||
|
||||
# ── Alert Rule ─────────────────────────────────────────────────
|
||||
|
||||
|
||||
class AlertRuleCreate(BaseModel):
|
||||
project_id: str = Field(..., min_length=1, max_length=36)
|
||||
name: str = Field(..., min_length=1, max_length=128)
|
||||
level: str = Field(default="warning", pattern="^(info|warning|critical)$")
|
||||
field: str = Field(default="level", max_length=64)
|
||||
operator: str = Field(default="gte", pattern="^(gt|gte|lt|lte|eq)$")
|
||||
threshold: float = Field(..., ge=0)
|
||||
window_minutes: int = Field(default=5, ge=1, le=1440)
|
||||
notify_channels: List[str] = Field(default_factory=list)
|
||||
|
||||
|
||||
class AlertRuleUpdate(BaseModel):
|
||||
name: Optional[str] = Field(default=None, max_length=128)
|
||||
level: Optional[str] = Field(default=None, pattern="^(info|warning|critical)$")
|
||||
field: Optional[str] = Field(default=None, max_length=64)
|
||||
operator: Optional[str] = Field(default=None, pattern="^(gt|gte|lt|lte|eq)$")
|
||||
threshold: Optional[float] = Field(default=None, ge=0)
|
||||
window_minutes: Optional[int] = Field(default=None, ge=1, le=1440)
|
||||
is_enabled: Optional[bool] = None
|
||||
notify_channels: Optional[List[str]] = None
|
||||
|
||||
|
||||
class AlertRuleResponse(BaseModel):
|
||||
id: str
|
||||
project_id: str
|
||||
name: str
|
||||
level: str
|
||||
field: str
|
||||
operator: str
|
||||
threshold: float
|
||||
window_minutes: int
|
||||
is_enabled: bool
|
||||
notify_channels: List[str]
|
||||
created_at: datetime
|
||||
|
||||
model_config = {"from_attributes": True}
|
||||
|
||||
|
||||
# ── Alert History ──────────────────────────────────────────────
|
||||
|
||||
|
||||
class AlertHistoryResponse(BaseModel):
|
||||
id: str
|
||||
rule_id: str
|
||||
project_id: str
|
||||
level: str
|
||||
message: str
|
||||
triggered_value: Optional[float] = None
|
||||
is_acknowledged: bool
|
||||
triggered_at: datetime
|
||||
|
||||
model_config = {"from_attributes": True}
|
||||
@@ -0,0 +1,64 @@
|
||||
"""Log entry schemas."""
|
||||
|
||||
from datetime import datetime
|
||||
from typing import Any, Dict, List, Optional
|
||||
|
||||
from pydantic import BaseModel, Field
|
||||
|
||||
|
||||
class LogEntry(BaseModel):
|
||||
"""A single log entry pushed by a project."""
|
||||
|
||||
timestamp: Optional[datetime] = None
|
||||
level: str = Field(default="info", pattern="^(debug|info|warning|error|critical)$")
|
||||
logger: str = Field(default="root", max_length=128)
|
||||
message: str = Field(..., min_length=1, max_length=65536)
|
||||
module: Optional[str] = Field(default=None, max_length=256)
|
||||
function: Optional[str] = Field(default=None, max_length=128)
|
||||
line_no: Optional[int] = None
|
||||
trace_id: Optional[str] = Field(default=None, max_length=64)
|
||||
exception: Optional[str] = None
|
||||
extra: Dict[str, Any] = Field(default_factory=dict)
|
||||
|
||||
|
||||
class LogBatchRequest(BaseModel):
|
||||
"""Batch of log entries pushed in a single request."""
|
||||
|
||||
project: str = Field(..., min_length=1, max_length=128)
|
||||
entries: List[LogEntry] = Field(..., min_length=1, max_length=5000)
|
||||
|
||||
|
||||
class LogQueryParams(BaseModel):
|
||||
"""Parameters for log search / query."""
|
||||
|
||||
project: Optional[str] = None
|
||||
level: Optional[str] = Field(
|
||||
default=None, pattern="^(debug|info|warning|error|critical)$"
|
||||
)
|
||||
query: Optional[str] = Field(default=None, max_length=1024)
|
||||
trace_id: Optional[str] = Field(default=None, max_length=64)
|
||||
start_time: Optional[datetime] = None
|
||||
end_time: Optional[datetime] = None
|
||||
page: int = Field(default=1, ge=1)
|
||||
page_size: int = Field(default=50, ge=1, le=1000)
|
||||
sort_by: str = Field(default="timestamp", max_length=32)
|
||||
sort_order: str = Field(default="desc", pattern="^(asc|desc)$")
|
||||
|
||||
|
||||
class LogSearchResult(BaseModel):
|
||||
"""Search result wrapper."""
|
||||
|
||||
total: int
|
||||
page: int
|
||||
page_size: int
|
||||
hits: List[Dict[str, Any]]
|
||||
|
||||
|
||||
class LogStatsResult(BaseModel):
|
||||
"""Aggregated log statistics."""
|
||||
|
||||
project: Optional[str] = None
|
||||
time_buckets: List[Dict[str, Any]]
|
||||
level_counts: Dict[str, int]
|
||||
top_loggers: List[Dict[str, Any]]
|
||||
total: int
|
||||
@@ -0,0 +1,34 @@
|
||||
"""Project schemas."""
|
||||
|
||||
from datetime import datetime
|
||||
from typing import Optional
|
||||
|
||||
from pydantic import BaseModel, Field
|
||||
|
||||
|
||||
class ProjectCreate(BaseModel):
|
||||
name: str = Field(..., min_length=1, max_length=128, description="Project name")
|
||||
description: str = Field(default="", max_length=1024)
|
||||
|
||||
|
||||
class ProjectUpdate(BaseModel):
|
||||
name: Optional[str] = Field(default=None, max_length=128)
|
||||
description: Optional[str] = Field(default=None, max_length=1024)
|
||||
is_active: Optional[bool] = None
|
||||
|
||||
|
||||
class ProjectResponse(BaseModel):
|
||||
id: str
|
||||
name: str
|
||||
description: str
|
||||
is_active: bool
|
||||
created_at: datetime
|
||||
updated_at: datetime
|
||||
|
||||
model_config = {"from_attributes": True}
|
||||
|
||||
|
||||
class ProjectWithToken(ProjectResponse):
|
||||
api_key: str
|
||||
|
||||
model_config = {"from_attributes": True}
|
||||
@@ -0,0 +1,142 @@
|
||||
"""Alert evaluation and notification service."""
|
||||
|
||||
import logging
|
||||
from datetime import datetime, timezone, timedelta
|
||||
from typing import Optional
|
||||
|
||||
from sqlalchemy import func, select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.config import settings
|
||||
from app.models.alert import AlertRule, AlertHistory, AlertLevel, AlertOperator
|
||||
from app.models.log import LogEntry
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class AlertService:
|
||||
"""Evaluates alert rules against recent log data and triggers notifications."""
|
||||
|
||||
def __init__(self, db: AsyncSession):
|
||||
self.db = db
|
||||
|
||||
async def evaluate_rules(self) -> list[AlertHistory]:
|
||||
"""Check all enabled alert rules and trigger any that match."""
|
||||
result = await self.db.execute(
|
||||
select(AlertRule).where(AlertRule.is_enabled == True)
|
||||
)
|
||||
rules: list[AlertRule] = result.scalars().all()
|
||||
triggered: list[AlertHistory] = []
|
||||
|
||||
for rule in rules:
|
||||
try:
|
||||
hit = await self._evaluate_single(rule)
|
||||
if hit:
|
||||
triggered.append(hit)
|
||||
except Exception as e:
|
||||
logger.error("Error evaluating rule %s: %s", rule.id, e)
|
||||
|
||||
return triggered
|
||||
|
||||
async def _evaluate_single(self, rule: AlertRule) -> Optional[AlertHistory]:
|
||||
"""Evaluate a single rule. Returns AlertHistory if triggered."""
|
||||
now = datetime.now(timezone.utc)
|
||||
since = now - timedelta(minutes=rule.window_minutes)
|
||||
|
||||
stmt = (
|
||||
select(func.count(LogEntry.id))
|
||||
.where(LogEntry.project_id == rule.project_id)
|
||||
.where(LogEntry.timestamp >= since)
|
||||
.where(LogEntry.level == rule.level.value)
|
||||
)
|
||||
count = (await self.db.execute(stmt)).scalar() or 0
|
||||
|
||||
operator_map = {
|
||||
AlertOperator.GT: lambda v, t: v > t,
|
||||
AlertOperator.GTE: lambda v, t: v >= t,
|
||||
AlertOperator.LT: lambda v, t: v < t,
|
||||
AlertOperator.LTE: lambda v, t: v <= t,
|
||||
AlertOperator.EQ: lambda v, t: v == t,
|
||||
}
|
||||
|
||||
op_fn = operator_map.get(rule.operator)
|
||||
if op_fn and op_fn(float(count), rule.threshold):
|
||||
history = AlertHistory(
|
||||
rule_id=rule.id,
|
||||
project_id=rule.project_id,
|
||||
level=rule.level,
|
||||
message=(
|
||||
f"Alert '{rule.name}': {rule.level.value} logs count ({count}) "
|
||||
f"{rule.operator.value} threshold ({rule.threshold}) "
|
||||
f"in last {rule.window_minutes} min"
|
||||
),
|
||||
triggered_value=float(count),
|
||||
)
|
||||
self.db.add(history)
|
||||
await self.db.flush()
|
||||
logger.info("Alert triggered: %s", history.message)
|
||||
|
||||
if rule.notify_channels:
|
||||
await self._notify(rule, history)
|
||||
|
||||
return history
|
||||
|
||||
return None
|
||||
|
||||
async def _notify(self, rule: AlertRule, history: AlertHistory) -> None:
|
||||
"""Send notification via configured channels."""
|
||||
channels = [c.strip() for c in rule.notify_channels.split(",") if c.strip()]
|
||||
for channel in channels:
|
||||
if channel == "feishu" and settings.FEISHU_WEBHOOK_URL:
|
||||
await self._send_feishu(rule, history)
|
||||
elif channel == "dingtalk" and settings.DINGTALK_WEBHOOK_URL:
|
||||
await self._send_dingtalk(rule, history)
|
||||
|
||||
async def _send_feishu(self, rule: AlertRule, history: AlertHistory) -> None:
|
||||
"""Send a notification to Feishu webhook."""
|
||||
import httpx
|
||||
|
||||
payload = {
|
||||
"msg_type": "post",
|
||||
"content": {
|
||||
"post": {
|
||||
"zh_cn": {
|
||||
"title": f"[LogHive] Alert: {rule.name}",
|
||||
"content": [
|
||||
[{"tag": "text", "text": history.message}],
|
||||
[
|
||||
{
|
||||
"tag": "text",
|
||||
"text": f"Project: {rule.project_id} | Level: {rule.level.value}",
|
||||
}
|
||||
],
|
||||
],
|
||||
}
|
||||
}
|
||||
},
|
||||
}
|
||||
try:
|
||||
async with httpx.AsyncClient() as client:
|
||||
await client.post(settings.FEISHU_WEBHOOK_URL, json=payload, timeout=10)
|
||||
except Exception as e:
|
||||
logger.error("Feishu notification failed: %s", e)
|
||||
|
||||
async def _send_dingtalk(self, rule: AlertRule, history: AlertHistory) -> None:
|
||||
"""Send a notification to DingTalk webhook."""
|
||||
import httpx
|
||||
|
||||
payload = {
|
||||
"msgtype": "text",
|
||||
"text": {
|
||||
"content": (
|
||||
f"[LogHive] Alert: {rule.name}\n"
|
||||
f"{history.message}\n"
|
||||
f"Project: {rule.project_id} | Level: {rule.level.value}"
|
||||
)
|
||||
},
|
||||
}
|
||||
try:
|
||||
async with httpx.AsyncClient() as client:
|
||||
await client.post(settings.DINGTALK_WEBHOOK_URL, json=payload, timeout=10)
|
||||
except Exception as e:
|
||||
logger.error("DingTalk notification failed: %s", e)
|
||||
@@ -0,0 +1,125 @@
|
||||
"""Log analytics service — provides aggregated statistics and insights."""
|
||||
|
||||
import logging
|
||||
from datetime import datetime, timezone, timedelta
|
||||
from typing import Optional
|
||||
|
||||
from sqlalchemy import func, select, text
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.config import to_display_time
|
||||
from app.models.log import LogEntry
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class AnalyticsService:
|
||||
"""Time-series analytics over log data."""
|
||||
|
||||
def __init__(self, db: AsyncSession):
|
||||
self.db = db
|
||||
|
||||
def _build_filters(self, project: Optional[str], since: datetime):
|
||||
"""Build SQLAlchemy filter list."""
|
||||
filters = [LogEntry.timestamp >= since]
|
||||
if project:
|
||||
filters.append(LogEntry.project_name == project)
|
||||
return filters
|
||||
|
||||
async def get_dashboard_stats(
|
||||
self, project: Optional[str] = None, hours: int = 24
|
||||
) -> dict:
|
||||
"""Get overview statistics for the dashboard."""
|
||||
now = datetime.now(timezone.utc)
|
||||
since = now - timedelta(hours=hours)
|
||||
filters = self._build_filters(project, since)
|
||||
|
||||
# Total log count
|
||||
total = (await self.db.execute(
|
||||
select(func.count(LogEntry.id)).where(*filters)
|
||||
)).scalar() or 0
|
||||
|
||||
# Error count
|
||||
error_count = (await self.db.execute(
|
||||
select(func.count(LogEntry.id)).where(
|
||||
*filters, LogEntry.level.in_(["error", "critical"])
|
||||
)
|
||||
)).scalar() or 0
|
||||
|
||||
# Level breakdown
|
||||
level_rows = (await self.db.execute(
|
||||
select(LogEntry.level, func.count(LogEntry.id))
|
||||
.where(*filters)
|
||||
.group_by(LogEntry.level)
|
||||
)).all()
|
||||
level_breakdown = {row[0]: row[1] for row in level_rows}
|
||||
|
||||
# Error rate
|
||||
error_rate = round((error_count / total * 100), 2) if total > 0 else 0
|
||||
|
||||
# Top error messages
|
||||
top_rows = (await self.db.execute(
|
||||
select(LogEntry.message, func.count(LogEntry.id).label("cnt"))
|
||||
.where(*filters, LogEntry.level.in_(["error", "critical"]))
|
||||
.group_by(LogEntry.message)
|
||||
.order_by(func.count(LogEntry.id).desc())
|
||||
.limit(10)
|
||||
)).all()
|
||||
top_errors = [{"message": row[0], "count": row[1]} for row in top_rows]
|
||||
|
||||
return {
|
||||
"total_logs": total,
|
||||
"error_count": error_count,
|
||||
"error_rate": error_rate,
|
||||
"level_breakdown": level_breakdown,
|
||||
"time_range_hours": hours,
|
||||
"top_errors": top_errors,
|
||||
}
|
||||
|
||||
async def get_time_series(
|
||||
self,
|
||||
project: Optional[str] = None,
|
||||
hours: int = 24,
|
||||
interval: str = "5m",
|
||||
) -> list[dict]:
|
||||
"""Get time-series data for log volume charts."""
|
||||
now = datetime.now(timezone.utc)
|
||||
since = now - timedelta(hours=hours)
|
||||
|
||||
n = int("".join(c for c in interval if c.isdigit()) or "5")
|
||||
unit = "".join(c for c in interval if c.isalpha()) or "m"
|
||||
if unit == "h":
|
||||
n = n * 60
|
||||
n = max(n, 1)
|
||||
|
||||
# Build WHERE clauses
|
||||
where_clauses = [f"timestamp >= '{since.isoformat()}'"]
|
||||
params: dict = {}
|
||||
if project:
|
||||
where_clauses.append("project_name = :project")
|
||||
params["project"] = project
|
||||
where_sql = " AND ".join(where_clauses)
|
||||
|
||||
sql = text(
|
||||
f"SELECT "
|
||||
f" date_trunc('hour', timestamp) + "
|
||||
f" ((floor(extract(minute from timestamp) / {n})::int * {n}) || ' min')::interval AS bucket, "
|
||||
f" level, "
|
||||
f" count(*) AS cnt "
|
||||
f"FROM log_entries "
|
||||
f"WHERE {where_sql} "
|
||||
f"GROUP BY bucket, level "
|
||||
f"ORDER BY bucket"
|
||||
)
|
||||
|
||||
rows = (await self.db.execute(sql, params)).all()
|
||||
|
||||
buckets: dict[str, dict] = {}
|
||||
for row in rows:
|
||||
ts = to_display_time(row.bucket) if isinstance(row.bucket, datetime) else str(row.bucket)
|
||||
if ts not in buckets:
|
||||
buckets[ts] = {"timestamp": ts, "total": 0, "levels": {}}
|
||||
buckets[ts]["total"] += row.cnt
|
||||
buckets[ts]["levels"][row.level] = row.cnt
|
||||
|
||||
return list(buckets.values())
|
||||
@@ -0,0 +1,33 @@
|
||||
"""Celery application for async background tasks."""
|
||||
|
||||
from celery import Celery
|
||||
|
||||
from app.config import settings
|
||||
|
||||
celery_app = Celery(
|
||||
"loghive",
|
||||
broker=settings.CELERY_BROKER_URL,
|
||||
backend=settings.CELERY_RESULT_BACKEND,
|
||||
include=["celery_worker.tasks"],
|
||||
)
|
||||
|
||||
celery_app.conf.update(
|
||||
task_serializer="json",
|
||||
accept_content=["json"],
|
||||
result_serializer="json",
|
||||
timezone="UTC",
|
||||
enable_utc=True,
|
||||
task_track_started=True,
|
||||
task_acks_late=True,
|
||||
worker_prefetch_multiplier=1,
|
||||
beat_schedule={
|
||||
"evaluate-alert-rules": {
|
||||
"task": "celery_worker.tasks.evaluate_alerts",
|
||||
"schedule": settings.ALERT_CHECK_INTERVAL_SECONDS,
|
||||
},
|
||||
"cleanup-old-log-indices": {
|
||||
"task": "celery_worker.tasks.cleanup_old_indices",
|
||||
"schedule": 3600, # once per hour
|
||||
},
|
||||
},
|
||||
)
|
||||
@@ -0,0 +1,82 @@
|
||||
"""Celery tasks for periodic and async processing."""
|
||||
|
||||
import asyncio
|
||||
import logging
|
||||
from datetime import datetime, timezone, timedelta
|
||||
|
||||
from sqlalchemy import delete
|
||||
from sqlalchemy.ext.asyncio import create_async_engine, async_sessionmaker
|
||||
|
||||
from app.config import settings
|
||||
from app.models.log import LogEntry
|
||||
from app.services.alert_service import AlertService
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
from celery_worker.celery_app import celery_app
|
||||
|
||||
|
||||
@celery_app.task(bind=True, max_retries=3)
|
||||
def evaluate_alerts(self):
|
||||
"""Evaluate all alert rules and trigger notifications.
|
||||
|
||||
Runs periodically (default: every 60 seconds via beat schedule).
|
||||
"""
|
||||
try:
|
||||
loop = asyncio.get_event_loop()
|
||||
if loop.is_closed():
|
||||
loop = asyncio.new_event_loop()
|
||||
asyncio.set_event_loop(loop)
|
||||
|
||||
result = loop.run_until_complete(_async_evaluate_alerts())
|
||||
logger.info("Alert evaluation complete: %d triggers", len(result))
|
||||
return {"triggered": len(result)}
|
||||
except Exception as e:
|
||||
logger.error("Alert evaluation failed: %s", e)
|
||||
raise self.retry(exc=e, countdown=30)
|
||||
|
||||
|
||||
async def _async_evaluate_alerts():
|
||||
"""Async body of alert evaluation."""
|
||||
engine = create_async_engine(settings.DATABASE_URL)
|
||||
factory = async_sessionmaker(engine, expire_on_commit=False)
|
||||
|
||||
try:
|
||||
async with factory() as db:
|
||||
service = AlertService(db)
|
||||
triggered = await service.evaluate_rules()
|
||||
await db.commit()
|
||||
return triggered
|
||||
finally:
|
||||
await engine.dispose()
|
||||
|
||||
|
||||
@celery_app.task
|
||||
def cleanup_old_indices():
|
||||
"""Delete log entries older than the retention period."""
|
||||
try:
|
||||
loop = asyncio.get_event_loop()
|
||||
if loop.is_closed():
|
||||
loop = asyncio.new_event_loop()
|
||||
asyncio.set_event_loop(loop)
|
||||
result = loop.run_until_complete(_async_cleanup())
|
||||
logger.info("Cleanup complete: %d entries deleted", result)
|
||||
return {"deleted": result}
|
||||
except Exception as e:
|
||||
logger.error("Cleanup failed: %s", e)
|
||||
return {"error": str(e)}
|
||||
|
||||
|
||||
async def _async_cleanup() -> int:
|
||||
"""Delete old log entries."""
|
||||
engine = create_async_engine(settings.DATABASE_URL)
|
||||
factory = async_sessionmaker(engine, expire_on_commit=False)
|
||||
|
||||
try:
|
||||
cutoff = datetime.now(timezone.utc) - timedelta(days=settings.LOG_RETENTION_DAYS)
|
||||
async with factory() as db:
|
||||
result = await db.execute(delete(LogEntry).where(LogEntry.timestamp < cutoff))
|
||||
await db.commit()
|
||||
return result.rowcount
|
||||
finally:
|
||||
await engine.dispose()
|
||||
@@ -0,0 +1,25 @@
|
||||
# LogHive Backend Dependencies
|
||||
|
||||
# Framework
|
||||
fastapi>=0.111.0
|
||||
uvicorn[standard]>=0.30.0
|
||||
pydantic>=2.7.0
|
||||
pydantic-settings>=2.3.0
|
||||
|
||||
# Database
|
||||
sqlalchemy[asyncio]>=2.0.30
|
||||
asyncpg>=0.29.0
|
||||
|
||||
# Task Queue
|
||||
celery>=5.4.0
|
||||
redis>=5.0.0
|
||||
|
||||
# HTTP Client (for webhook notifications)
|
||||
httpx>=0.27.0
|
||||
|
||||
# CORS / Middleware
|
||||
python-multipart>=0.0.9
|
||||
|
||||
# Utilities
|
||||
python-dateutil>=2.9.0
|
||||
orjson>=3.10.0
|
||||
@@ -0,0 +1,84 @@
|
||||
# LogHive Client SDK
|
||||
|
||||
Python client SDK for sending logs to [LogHive](https://github.com/your-org/loghive).
|
||||
|
||||
## Installation
|
||||
|
||||
```bash
|
||||
pip install loghive-client
|
||||
```
|
||||
|
||||
Or install from source:
|
||||
|
||||
```bash
|
||||
cd client
|
||||
pip install .
|
||||
```
|
||||
|
||||
## Quick Start
|
||||
|
||||
### Sync mode (recommended for scripts, Django, Flask)
|
||||
|
||||
```python
|
||||
from loghive_client import LogHiveLogger
|
||||
|
||||
logger = LogHiveLogger(
|
||||
project="my-awesome-app",
|
||||
api_key="your-api-key",
|
||||
endpoint="http://localhost:8000",
|
||||
)
|
||||
|
||||
logger.info("Server started", extra={"port": 8080})
|
||||
logger.error("Database timeout", exc_info=True)
|
||||
```
|
||||
|
||||
### Async mode (for FastAPI, aiohttp, asyncio)
|
||||
|
||||
```python
|
||||
from loghive_client import AsyncLogHiveLogger
|
||||
import asyncio
|
||||
|
||||
async def main():
|
||||
async with AsyncLogHiveLogger(
|
||||
project="my-api",
|
||||
api_key="your-api-key",
|
||||
endpoint="http://localhost:8000",
|
||||
) as logger:
|
||||
await logger.info("API started")
|
||||
# ...
|
||||
|
||||
asyncio.run(main())
|
||||
```
|
||||
|
||||
### Standard logging integration (zero code change)
|
||||
|
||||
Add the handler to your existing logger:
|
||||
|
||||
```python
|
||||
import logging
|
||||
from loghive_client import LogHiveHandler
|
||||
|
||||
handler = LogHiveHandler("my-project", "api-key", "http://localhost:8000")
|
||||
logging.getLogger().addHandler(handler)
|
||||
|
||||
# All existing logger calls now forward to LogHive
|
||||
logging.info("This goes to LogHive too!")
|
||||
```
|
||||
|
||||
## Configuration
|
||||
|
||||
| Param | Default | Description |
|
||||
|-------|---------|-------------|
|
||||
| `project` | (required) | Your project name in LogHive |
|
||||
| `api_key` | (required) | Your project's API key |
|
||||
| `endpoint` | `http://localhost:8000` | LogHive server URL |
|
||||
| `batch_size` | 50 | Max entries per HTTP request |
|
||||
| `flush_interval` | 2.0 | Seconds between flushes |
|
||||
| `max_retries` | 3 | Retries on failure |
|
||||
| `timeout` | 5.0 | HTTP request timeout |
|
||||
|
||||
## Trace ID (request correlation)
|
||||
|
||||
```python
|
||||
logger.set_trace_id("req-abc-123")
|
||||
```
|
||||
@@ -0,0 +1,20 @@
|
||||
"""LogHive Client — Send logs from your Python projects to LogHive.
|
||||
|
||||
Usage:
|
||||
from loghive_client import LogHiveLogger
|
||||
|
||||
logger = LogHiveLogger(
|
||||
project="my-project",
|
||||
api_key="your-api-key",
|
||||
endpoint="http://localhost:8000",
|
||||
)
|
||||
|
||||
logger.info("User logged in", extra={"user_id": 42})
|
||||
logger.error("Database connection failed", exc_info=True)
|
||||
"""
|
||||
|
||||
from loghive_client.client import LogHiveLogger
|
||||
from loghive_client.async_client import AsyncLogHiveLogger
|
||||
from loghive_client.handler import LogHiveHandler
|
||||
|
||||
__all__ = ["LogHiveLogger", "AsyncLogHiveLogger", "LogHiveHandler"]
|
||||
@@ -0,0 +1,178 @@
|
||||
"""Async LogHive client — for use in asyncio-based projects (e.g., FastAPI, aiohttp)."""
|
||||
|
||||
import asyncio
|
||||
import logging
|
||||
import traceback
|
||||
from datetime import datetime, timezone
|
||||
from typing import Any, Dict, List, Optional
|
||||
from urllib.parse import urljoin
|
||||
|
||||
import httpx
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class AsyncLogHiveLogger:
|
||||
"""Async logger for asyncio applications.
|
||||
|
||||
Uses an async background task to batch and send log entries.
|
||||
Ideal for FastAPI / Starlette / aiohttp projects.
|
||||
|
||||
Usage:
|
||||
logger = AsyncLogHiveLogger("my-project", "api-key", "http://localhost:8000")
|
||||
await logger.start()
|
||||
|
||||
await logger.info("Request processed", extra={"path": "/api/users"})
|
||||
|
||||
await logger.stop()
|
||||
"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
project: str,
|
||||
api_key: str,
|
||||
endpoint: str = "http://localhost:8000",
|
||||
batch_size: int = 50,
|
||||
flush_interval: float = 2.0,
|
||||
max_retries: int = 3,
|
||||
timeout: float = 5.0,
|
||||
):
|
||||
self.project = project
|
||||
self.api_key = api_key
|
||||
self.endpoint = endpoint.rstrip("/")
|
||||
self.batch_size = batch_size
|
||||
self.flush_interval = flush_interval
|
||||
self.max_retries = max_retries
|
||||
self.timeout = timeout
|
||||
|
||||
self._queue: asyncio.Queue = asyncio.Queue()
|
||||
self._task: Optional[asyncio.Task] = None
|
||||
self._client: Optional[httpx.AsyncClient] = None
|
||||
self._stop_event = asyncio.Event()
|
||||
|
||||
async def start(self):
|
||||
"""Start the background flush task."""
|
||||
if self._task and not self._task.done():
|
||||
return
|
||||
self._client = httpx.AsyncClient(timeout=self.timeout)
|
||||
self._stop_event.clear()
|
||||
self._task = asyncio.create_task(self._flush_loop())
|
||||
logger.debug("AsyncLogHiveLogger started for project '%s'", self.project)
|
||||
|
||||
async def stop(self, flush: bool = True):
|
||||
"""Stop the background task."""
|
||||
self._stop_event.set()
|
||||
if flush:
|
||||
await self._flush_now()
|
||||
if self._task:
|
||||
self._task.cancel()
|
||||
try:
|
||||
await self._task
|
||||
except asyncio.CancelledError:
|
||||
pass
|
||||
if self._client:
|
||||
await self._client.aclose()
|
||||
logger.debug("AsyncLogHiveLogger stopped for project '%s'", self.project)
|
||||
|
||||
async def __aenter__(self):
|
||||
await self.start()
|
||||
return self
|
||||
|
||||
async def __aexit__(self, *args):
|
||||
await self.stop()
|
||||
|
||||
async def _flush_loop(self):
|
||||
"""Background loop that periodically flushes the queue."""
|
||||
while not self._stop_event.is_set():
|
||||
await self._flush_now()
|
||||
await asyncio.sleep(self.flush_interval)
|
||||
|
||||
async def _flush_now(self):
|
||||
"""Flush all currently queued entries."""
|
||||
entries = []
|
||||
while len(entries) < self.batch_size:
|
||||
try:
|
||||
entry = self._queue.get_nowait()
|
||||
entries.append(entry)
|
||||
except asyncio.QueueEmpty:
|
||||
break
|
||||
|
||||
if not entries:
|
||||
return
|
||||
|
||||
await self._send_batch(entries)
|
||||
|
||||
async def _send_batch(self, entries: List[Dict[str, Any]]):
|
||||
"""Send a batch with retries."""
|
||||
url = urljoin(self.endpoint, "/api/logs/ingest")
|
||||
payload = {"project": self.project, "entries": entries}
|
||||
|
||||
for attempt in range(self.max_retries):
|
||||
try:
|
||||
resp = await self._client.post(
|
||||
url,
|
||||
json=payload,
|
||||
headers={"Authorization": f"Bearer {self.api_key}"},
|
||||
)
|
||||
if resp.status_code == 201:
|
||||
return
|
||||
elif resp.status_code == 401:
|
||||
logger.error("LogHive: Invalid API key — dropping batch")
|
||||
return
|
||||
else:
|
||||
logger.warning(
|
||||
"LogHive: HTTP %d (attempt %d/%d)",
|
||||
resp.status_code,
|
||||
attempt + 1,
|
||||
self.max_retries,
|
||||
)
|
||||
except httpx.RequestError as e:
|
||||
logger.warning(
|
||||
"LogHive: Connection error (attempt %d/%d): %s",
|
||||
attempt + 1,
|
||||
self.max_retries,
|
||||
e,
|
||||
)
|
||||
|
||||
if attempt < self.max_retries - 1:
|
||||
await asyncio.sleep(2 ** attempt)
|
||||
|
||||
logger.error("LogHive: Failed to send %d entries after %d retries", len(entries), self.max_retries)
|
||||
|
||||
def _enqueue(self, level: str, message: str, **kwargs):
|
||||
"""Enqueue a log entry."""
|
||||
entry = {
|
||||
"timestamp": datetime.now(timezone.utc).isoformat(),
|
||||
"level": level,
|
||||
"message": message,
|
||||
"logger": kwargs.pop("logger", None) or __name__,
|
||||
"extra": kwargs,
|
||||
}
|
||||
|
||||
exc_info = kwargs.pop("exc_info", None)
|
||||
if exc_info:
|
||||
if isinstance(exc_info, BaseException):
|
||||
entry["exception"] = "".join(
|
||||
traceback.format_exception(type(exc_info), exc_info, exc_info.__traceback__)
|
||||
)
|
||||
elif exc_info is True:
|
||||
entry["exception"] = traceback.format_exc()
|
||||
|
||||
self._queue.put_nowait(entry)
|
||||
|
||||
# ── Public API ─────────────────────────────────────────────
|
||||
|
||||
async def debug(self, message: str, **kwargs):
|
||||
self._enqueue("debug", message, **kwargs)
|
||||
|
||||
async def info(self, message: str, **kwargs):
|
||||
self._enqueue("info", message, **kwargs)
|
||||
|
||||
async def warning(self, message: str, **kwargs):
|
||||
self._enqueue("warning", message, **kwargs)
|
||||
|
||||
async def error(self, message: str, **kwargs):
|
||||
self._enqueue("error", message, **kwargs)
|
||||
|
||||
async def critical(self, message: str, **kwargs):
|
||||
self._enqueue("critical", message, **kwargs)
|
||||
@@ -0,0 +1,183 @@
|
||||
"""Synchronous LogHive client — uses threading for non-blocking sends."""
|
||||
|
||||
import json
|
||||
import logging
|
||||
import threading
|
||||
import time
|
||||
import traceback
|
||||
from datetime import datetime, timezone
|
||||
from queue import Queue, Empty
|
||||
from typing import Any, Dict, List, Optional
|
||||
from urllib.parse import urljoin
|
||||
|
||||
import httpx
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class LogHiveLogger:
|
||||
"""Synchronous logger that sends logs to LogHive in the background.
|
||||
|
||||
Uses a background thread with a queue to avoid blocking the main
|
||||
application on network I/O.
|
||||
|
||||
Usage:
|
||||
logger = LogHiveLogger("my-project", "api-key-here", "http://localhost:8000")
|
||||
logger.info("Hello, world!")
|
||||
logger.error("Something broke", exc_info=True)
|
||||
"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
project: str,
|
||||
api_key: str,
|
||||
endpoint: str = "http://localhost:8000",
|
||||
batch_size: int = 50,
|
||||
flush_interval: float = 2.0,
|
||||
max_retries: int = 3,
|
||||
timeout: float = 5.0,
|
||||
auto_start: bool = True,
|
||||
):
|
||||
self.project = project
|
||||
self.api_key = api_key
|
||||
self.endpoint = endpoint.rstrip("/")
|
||||
self.batch_size = batch_size
|
||||
self.flush_interval = flush_interval
|
||||
self.max_retries = max_retries
|
||||
self.timeout = timeout
|
||||
|
||||
self._queue: Queue = Queue()
|
||||
self._stop_event = threading.Event()
|
||||
self._thread: Optional[threading.Thread] = None
|
||||
|
||||
if auto_start:
|
||||
self.start()
|
||||
|
||||
def start(self):
|
||||
"""Start the background flush thread."""
|
||||
if self._thread and self._thread.is_alive():
|
||||
return
|
||||
self._stop_event.clear()
|
||||
self._thread = threading.Thread(target=self._flush_loop, daemon=True)
|
||||
self._thread.start()
|
||||
|
||||
def stop(self, flush: bool = True):
|
||||
"""Stop the background thread, optionally flushing remaining logs."""
|
||||
self._stop_event.set()
|
||||
if flush and self._thread:
|
||||
self._flush_now()
|
||||
if self._thread:
|
||||
self._thread.join(timeout=5)
|
||||
|
||||
def _flush_loop(self):
|
||||
"""Background loop that periodically flushes the queue."""
|
||||
while not self._stop_event.is_set():
|
||||
self._flush_now()
|
||||
self._stop_event.wait(self.flush_interval)
|
||||
|
||||
def _flush_now(self):
|
||||
"""Flush all currently queued log entries."""
|
||||
entries = []
|
||||
while len(entries) < self.batch_size:
|
||||
try:
|
||||
entry = self._queue.get_nowait()
|
||||
entries.append(entry)
|
||||
except Empty:
|
||||
break
|
||||
|
||||
if not entries:
|
||||
return
|
||||
|
||||
self._send_batch(entries)
|
||||
|
||||
def _send_batch(self, entries: List[Dict[str, Any]]):
|
||||
"""Send a batch of entries to the LogHive API, with retries."""
|
||||
url = urljoin(self.endpoint, "/api/logs/ingest")
|
||||
payload = {"project": self.project, "entries": entries}
|
||||
|
||||
for attempt in range(self.max_retries):
|
||||
try:
|
||||
resp = httpx.post(
|
||||
url,
|
||||
json=payload,
|
||||
headers={"Authorization": f"Bearer {self.api_key}"},
|
||||
timeout=self.timeout,
|
||||
)
|
||||
if resp.status_code == 201:
|
||||
return
|
||||
elif resp.status_code == 401:
|
||||
logger.error("LogHive: Invalid API key — dropping batch")
|
||||
return
|
||||
else:
|
||||
logger.warning(
|
||||
"LogHive: HTTP %d (attempt %d/%d)",
|
||||
resp.status_code,
|
||||
attempt + 1,
|
||||
self.max_retries,
|
||||
)
|
||||
except httpx.RequestError as e:
|
||||
logger.warning(
|
||||
"LogHive: Connection error (attempt %d/%d): %s",
|
||||
attempt + 1,
|
||||
self.max_retries,
|
||||
e,
|
||||
)
|
||||
|
||||
if attempt < self.max_retries - 1:
|
||||
time.sleep(2 ** attempt)
|
||||
|
||||
logger.error("LogHive: Failed to send %d entries after %d retries", len(entries), self.max_retries)
|
||||
|
||||
def _enqueue(self, level: str, message: str, **kwargs):
|
||||
"""Enqueue a log entry for async sending."""
|
||||
entry = {
|
||||
"timestamp": datetime.now(timezone.utc).isoformat(),
|
||||
"level": level,
|
||||
"message": message,
|
||||
"logger": kwargs.pop("logger", None) or __name__,
|
||||
"module": kwargs.pop("module", None),
|
||||
"function": kwargs.pop("function", None),
|
||||
"line_no": kwargs.pop("line_no", None),
|
||||
"trace_id": kwargs.pop("trace_id", None),
|
||||
"extra": kwargs,
|
||||
}
|
||||
|
||||
# Handle exception info
|
||||
exc_info = kwargs.pop("exc_info", None)
|
||||
if exc_info:
|
||||
if isinstance(exc_info, BaseException):
|
||||
entry["exception"] = "".join(
|
||||
traceback.format_exception(type(exc_info), exc_info, exc_info.__traceback__)
|
||||
)
|
||||
elif exc_info is True:
|
||||
entry["exception"] = traceback.format_exc()
|
||||
|
||||
self._queue.put_nowait(entry)
|
||||
|
||||
# ── Public API (matching standard logging levels) ──────────
|
||||
|
||||
def debug(self, message: str, **kwargs):
|
||||
self._enqueue("debug", message, **kwargs)
|
||||
|
||||
def info(self, message: str, **kwargs):
|
||||
self._enqueue("info", message, **kwargs)
|
||||
|
||||
def warning(self, message: str, **kwargs):
|
||||
self._enqueue("warning", message, **kwargs)
|
||||
|
||||
def error(self, message: str, **kwargs):
|
||||
self._enqueue("error", message, **kwargs)
|
||||
|
||||
def critical(self, message: str, **kwargs):
|
||||
self._enqueue("critical", message, **kwargs)
|
||||
|
||||
def log(self, level: str, message: str, **kwargs):
|
||||
"""Log a message with an explicit level string."""
|
||||
self._enqueue(level, message, **kwargs)
|
||||
|
||||
def set_trace_id(self, trace_id: str):
|
||||
"""Set a trace_id for request correlation (used in web frameworks)."""
|
||||
self._current_trace_id = trace_id
|
||||
|
||||
def __del__(self):
|
||||
self.stop(flush=True)
|
||||
@@ -0,0 +1,72 @@
|
||||
"""Python logging.Handler integration — use LogHive with the stdlib logging module.
|
||||
|
||||
This allows you to replace or augment your existing logging setup with
|
||||
zero code changes (just add a handler to your logger).
|
||||
"""
|
||||
|
||||
import logging
|
||||
from typing import Optional
|
||||
|
||||
from loghive_client.client import LogHiveLogger
|
||||
|
||||
|
||||
class LogHiveHandler(logging.Handler):
|
||||
"""A logging.Handler that sends records to LogHive.
|
||||
|
||||
Use it with Python's standard logging module:
|
||||
import logging
|
||||
from loghive_client import LogHiveHandler
|
||||
|
||||
handler = LogHiveHandler("my-project", "api-key", "http://localhost:8000")
|
||||
logging.getLogger().addHandler(handler)
|
||||
|
||||
All existing logger calls (logger.info, logger.error, etc.) will
|
||||
automatically forward to LogHive.
|
||||
"""
|
||||
|
||||
LEVEL_MAP = {
|
||||
logging.DEBUG: "debug",
|
||||
logging.INFO: "info",
|
||||
logging.WARNING: "warning",
|
||||
logging.ERROR: "error",
|
||||
logging.CRITICAL: "critical",
|
||||
}
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
project: str,
|
||||
api_key: str,
|
||||
endpoint: str = "http://localhost:8000",
|
||||
level: int = logging.INFO,
|
||||
):
|
||||
super().__init__(level=level)
|
||||
self._client = LogHiveLogger(
|
||||
project=project,
|
||||
api_key=api_key,
|
||||
endpoint=endpoint,
|
||||
)
|
||||
|
||||
def emit(self, record: logging.LogRecord):
|
||||
"""Send a log record to LogHive."""
|
||||
try:
|
||||
level = self.LEVEL_MAP.get(record.levelno, "info")
|
||||
extra = {
|
||||
"logger": record.name,
|
||||
"module": record.module,
|
||||
"function": record.funcName,
|
||||
"line_no": record.lineno,
|
||||
}
|
||||
if record.exc_info and record.exc_info[0]:
|
||||
import traceback
|
||||
extra["exception"] = "".join(
|
||||
traceback.format_exception(*record.exc_info)
|
||||
)
|
||||
|
||||
self._client._enqueue(level, record.getMessage(), **extra)
|
||||
except Exception:
|
||||
self.handleError(record)
|
||||
|
||||
def close(self):
|
||||
"""Flush and close."""
|
||||
self._client.stop(flush=True)
|
||||
super().close()
|
||||
@@ -0,0 +1,23 @@
|
||||
"""Setup script for loghive-client."""
|
||||
|
||||
from setuptools import setup, find_packages
|
||||
|
||||
setup(
|
||||
name="loghive-client",
|
||||
version="0.1.0",
|
||||
description="LogHive client SDK — push logs from your Python projects to LogHive",
|
||||
author="LogHive",
|
||||
packages=find_packages(),
|
||||
install_requires=[
|
||||
"httpx>=0.27.0",
|
||||
],
|
||||
python_requires=">=3.10",
|
||||
classifiers=[
|
||||
"Development Status :: 3 - Alpha",
|
||||
"Intended Audience :: Developers",
|
||||
"Programming Language :: Python :: 3",
|
||||
"Programming Language :: Python :: 3.10",
|
||||
"Programming Language :: Python :: 3.11",
|
||||
"Programming Language :: Python :: 3.12",
|
||||
],
|
||||
)
|
||||
@@ -0,0 +1,42 @@
|
||||
services:
|
||||
# ── LogHive Backend API ───────────────────────────────────
|
||||
backend:
|
||||
build:
|
||||
context: ./backend
|
||||
dockerfile: Dockerfile
|
||||
ports:
|
||||
- "8000:8000"
|
||||
env_file:
|
||||
- .env
|
||||
|
||||
# ── Celery Worker ─────────────────────────────────────────
|
||||
celery_worker:
|
||||
build:
|
||||
context: ./backend
|
||||
dockerfile: Dockerfile
|
||||
command: celery -A celery_worker.celery_app worker --loglevel=info
|
||||
env_file:
|
||||
- .env
|
||||
depends_on:
|
||||
- backend
|
||||
|
||||
# ── Celery Beat (periodic tasks) ──────────────────────────
|
||||
celery_beat:
|
||||
build:
|
||||
context: ./backend
|
||||
dockerfile: Dockerfile
|
||||
command: celery -A celery_worker.celery_app beat --loglevel=info
|
||||
env_file:
|
||||
- .env
|
||||
depends_on:
|
||||
- backend
|
||||
|
||||
# ── LogHive Frontend (Vue 3) ──────────────────────────────
|
||||
frontend:
|
||||
build:
|
||||
context: ./frontend
|
||||
dockerfile: Dockerfile
|
||||
ports:
|
||||
- "3000:80"
|
||||
depends_on:
|
||||
- backend
|
||||
@@ -0,0 +1,20 @@
|
||||
# ── Build Stage ──
|
||||
FROM node:20-alpine AS builder
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
COPY package.json ./
|
||||
RUN npm install
|
||||
|
||||
COPY . .
|
||||
RUN npm run build
|
||||
|
||||
# ── Production Stage (nginx) ──
|
||||
FROM nginx:stable-alpine
|
||||
|
||||
COPY --from=builder /app/dist /usr/share/nginx/html
|
||||
COPY nginx.conf /etc/nginx/conf.d/default.conf
|
||||
|
||||
EXPOSE 80
|
||||
|
||||
CMD ["nginx", "-g", "daemon off;"]
|
||||
@@ -0,0 +1,13 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="zh-CN">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>LogHive — 日志管理系统</title>
|
||||
<link rel="icon" type="image/svg+xml" href="/favicon.svg" />
|
||||
</head>
|
||||
<body>
|
||||
<div id="app"></div>
|
||||
<script type="module" src="/src/main.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,21 @@
|
||||
server {
|
||||
listen 80;
|
||||
server_name localhost;
|
||||
|
||||
root /usr/share/nginx/html;
|
||||
index index.html;
|
||||
|
||||
# Vue SPA — serve index.html for all routes
|
||||
location / {
|
||||
try_files $uri $uri/ /index.html;
|
||||
}
|
||||
|
||||
# Proxy API requests to the backend
|
||||
location /api/ {
|
||||
proxy_pass http://backend:8000;
|
||||
proxy_set_header Host $host;
|
||||
proxy_set_header X-Real-IP $remote_addr;
|
||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||
proxy_set_header X-Forwarded-Proto $scheme;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,25 @@
|
||||
{
|
||||
"name": "loghive-frontend",
|
||||
"version": "0.1.0",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
"build": "vite build",
|
||||
"preview": "vite preview"
|
||||
},
|
||||
"dependencies": {
|
||||
"vue": "^3.4.0",
|
||||
"vue-router": "^4.3.0",
|
||||
"pinia": "^2.1.0",
|
||||
"axios": "^1.7.0",
|
||||
"echarts": "^5.5.0",
|
||||
"vue-echarts": "^6.7.0",
|
||||
"element-plus": "^2.7.0",
|
||||
"@element-plus/icons-vue": "^2.3.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@vitejs/plugin-vue": "^5.0.0",
|
||||
"vite": "^5.2.0"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,4 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 64 64">
|
||||
<circle cx="32" cy="32" r="30" fill="#f0c040"/>
|
||||
<text x="32" y="42" text-anchor="middle" font-size="36" font-family="Arial" fill="#333">H</text>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 217 B |
@@ -0,0 +1,69 @@
|
||||
<template>
|
||||
<el-container style="height: 100vh">
|
||||
<el-aside width="220px" style="background: #1d1e1f; color: #fff">
|
||||
<div class="sidebar-header">
|
||||
<h2 style="margin: 0; padding: 20px; font-size: 20px">🐝 LogHive</h2>
|
||||
</div>
|
||||
<el-menu
|
||||
:default-active="route.path"
|
||||
router
|
||||
background-color="#1d1e1f"
|
||||
text-color="#bfcbd9"
|
||||
active-text-color="#409eff"
|
||||
>
|
||||
<el-menu-item index="/">
|
||||
<el-icon><DataAnalysis /></el-icon>
|
||||
<span>仪表盘</span>
|
||||
</el-menu-item>
|
||||
<el-menu-item index="/logs">
|
||||
<el-icon><Search /></el-icon>
|
||||
<span>日志检索</span>
|
||||
</el-menu-item>
|
||||
<el-menu-item index="/projects">
|
||||
<el-icon><Setting /></el-icon>
|
||||
<span>项目管理</span>
|
||||
</el-menu-item>
|
||||
<el-menu-item index="/alerts">
|
||||
<el-icon><WarningFilled /></el-icon>
|
||||
<span>告警中心</span>
|
||||
</el-menu-item>
|
||||
</el-menu>
|
||||
</el-aside>
|
||||
|
||||
<el-container>
|
||||
<el-header style="background: #fff; border-bottom: 1px solid #e4e7ed; display: flex; align-items: center; justify-content: space-between; padding: 0 20px">
|
||||
<el-breadcrumb>
|
||||
<el-breadcrumb-item :to="{ path: '/' }">LogHive</el-breadcrumb-item>
|
||||
<el-breadcrumb-item v-if="route.path === '/logs'">日志检索</el-breadcrumb-item>
|
||||
<el-breadcrumb-item v-if="route.path === '/projects'">项目管理</el-breadcrumb-item>
|
||||
<el-breadcrumb-item v-if="route.path === '/alerts'">告警中心</el-breadcrumb-item>
|
||||
</el-breadcrumb>
|
||||
<div style="font-size: 14px; color: #909399">
|
||||
LogHive v0.1.0
|
||||
</div>
|
||||
</el-header>
|
||||
|
||||
<el-main style="background: #f5f7fa">
|
||||
<router-view />
|
||||
</el-main>
|
||||
</el-container>
|
||||
</el-container>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { useRoute } from 'vue-router'
|
||||
const route = useRoute()
|
||||
</script>
|
||||
|
||||
<style>
|
||||
body {
|
||||
margin: 0;
|
||||
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, 'Helvetica Neue', Arial, sans-serif;
|
||||
}
|
||||
.el-menu {
|
||||
border-right: none !important;
|
||||
}
|
||||
.sidebar-header {
|
||||
border-bottom: 1px solid #333;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,92 @@
|
||||
import axios from 'axios'
|
||||
|
||||
const api = axios.create({
|
||||
baseURL: '/api',
|
||||
timeout: 10000,
|
||||
})
|
||||
|
||||
// Response interceptor for error handling
|
||||
api.interceptors.response.use(
|
||||
(response) => response,
|
||||
(error) => {
|
||||
const msg = error.response?.data?.detail || error.message
|
||||
console.error('API Error:', msg)
|
||||
return Promise.reject(error)
|
||||
}
|
||||
)
|
||||
|
||||
// ── Dashboard ─────────────────────────────────────────────────
|
||||
|
||||
export function fetchDashboardStats(params) {
|
||||
return api.get('/dashboard/stats', { params })
|
||||
}
|
||||
|
||||
export function fetchTimeSeries(params) {
|
||||
return api.get('/dashboard/timeseries', { params })
|
||||
}
|
||||
|
||||
// ── Logs ──────────────────────────────────────────────────────
|
||||
|
||||
export function searchLogs(params) {
|
||||
return api.get('/logs/search', { params })
|
||||
}
|
||||
|
||||
export function fetchLogStats(params) {
|
||||
return api.get('/logs/stats', { params })
|
||||
}
|
||||
|
||||
// ── Projects ──────────────────────────────────────────────────
|
||||
|
||||
export function listProjects() {
|
||||
return api.get('/projects')
|
||||
}
|
||||
|
||||
export function getProject(id) {
|
||||
return api.get(`/projects/${id}`)
|
||||
}
|
||||
|
||||
export function createProject(data) {
|
||||
return api.post('/projects', data)
|
||||
}
|
||||
|
||||
export function updateProject(id, data) {
|
||||
return api.patch(`/projects/${id}`, data)
|
||||
}
|
||||
|
||||
export function deleteProject(id) {
|
||||
return api.delete(`/projects/${id}`)
|
||||
}
|
||||
|
||||
export function rotateApiKey(id) {
|
||||
return api.post(`/projects/${id}/rotate-key`)
|
||||
}
|
||||
|
||||
// ── Alerts ────────────────────────────────────────────────────
|
||||
|
||||
export function listAlertRules(params) {
|
||||
return api.get('/alerts/rules', { params })
|
||||
}
|
||||
|
||||
export function createAlertRule(data) {
|
||||
return api.post('/alerts/rules', data)
|
||||
}
|
||||
|
||||
export function updateAlertRule(id, data) {
|
||||
return api.patch(`/alerts/rules/${id}`, data)
|
||||
}
|
||||
|
||||
export function deleteAlertRule(id) {
|
||||
return api.delete(`/alerts/rules/${id}`)
|
||||
}
|
||||
|
||||
export function listAlertHistory(params) {
|
||||
return api.get('/alerts/history', { params })
|
||||
}
|
||||
|
||||
export function acknowledgeAlert(id) {
|
||||
return api.post(`/alerts/history/${id}/acknowledge`)
|
||||
}
|
||||
|
||||
export function healthCheck() {
|
||||
return api.get('/health')
|
||||
}
|
||||
@@ -0,0 +1,20 @@
|
||||
import { createApp } from 'vue'
|
||||
import { createPinia } from 'pinia'
|
||||
import ElementPlus from 'element-plus'
|
||||
import 'element-plus/dist/index.css'
|
||||
import * as ElementPlusIconsVue from '@element-plus/icons-vue'
|
||||
|
||||
import App from './App.vue'
|
||||
import router from './router'
|
||||
|
||||
const app = createApp(App)
|
||||
|
||||
// Register all Element Plus icons
|
||||
for (const [key, component] of Object.entries(ElementPlusIconsVue)) {
|
||||
app.component(key, component)
|
||||
}
|
||||
|
||||
app.use(createPinia())
|
||||
app.use(router)
|
||||
app.use(ElementPlus)
|
||||
app.mount('#app')
|
||||
@@ -0,0 +1,19 @@
|
||||
import { createRouter, createWebHistory } from 'vue-router'
|
||||
import DashboardView from '../views/DashboardView.vue'
|
||||
import LogSearchView from '../views/LogSearchView.vue'
|
||||
import ProjectManageView from '../views/ProjectManageView.vue'
|
||||
import AlertManageView from '../views/AlertManageView.vue'
|
||||
|
||||
const routes = [
|
||||
{ path: '/', name: 'dashboard', component: DashboardView },
|
||||
{ path: '/logs', name: 'logs', component: LogSearchView },
|
||||
{ path: '/projects', name: 'projects', component: ProjectManageView },
|
||||
{ path: '/alerts', name: 'alerts', component: AlertManageView },
|
||||
]
|
||||
|
||||
const router = createRouter({
|
||||
history: createWebHistory(),
|
||||
routes,
|
||||
})
|
||||
|
||||
export default router
|
||||
@@ -0,0 +1,272 @@
|
||||
<template>
|
||||
<div class="alert-manage">
|
||||
<h2 style="margin-bottom: 20px">🔔 告警中心</h2>
|
||||
|
||||
<!-- Tabs: Rules / History -->
|
||||
<el-tabs v-model="activeTab">
|
||||
<!-- ── Rules ── -->
|
||||
<el-tab-pane label="告警规则" name="rules">
|
||||
<div style="display: flex; justify-content: flex-end; margin-bottom: 16px">
|
||||
<el-button type="primary" @click="showCreateRuleDialog" :icon="Plus">新建规则</el-button>
|
||||
</div>
|
||||
<el-card shadow="hover">
|
||||
<el-table :data="rules" stripe style="width: 100%" v-loading="rulesLoading">
|
||||
<el-table-column prop="name" label="规则名称" min-width="150" />
|
||||
<el-table-column label="级别" width="100">
|
||||
<template #default="{ row }">
|
||||
<el-tag :type="row.level === 'critical' ? 'danger' : row.level === 'warning' ? 'warning' : 'info'" size="small">
|
||||
{{ row.level?.toUpperCase() }}
|
||||
</el-tag>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column prop="field" label="字段" width="100" />
|
||||
<el-table-column label="条件" width="150">
|
||||
<template #default="{ row }">
|
||||
{{ row.field }} {{ operatorLabel(row.operator) }} {{ row.threshold }}
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="时间窗口" width="120">
|
||||
<template #default="{ row }">{{ row.window_minutes }} 分钟</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="状态" width="90">
|
||||
<template #default="{ row }">
|
||||
<el-switch :model-value="row.is_enabled" @change="(v) => toggleRule(row, v)" />
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column prop="project_id" label="项目 ID" min-width="180" show-overflow-tooltip />
|
||||
<el-table-column label="操作" width="160" fixed="right">
|
||||
<template #default="{ row }">
|
||||
<el-button size="small" @click="showEditRuleDialog(row)">编辑</el-button>
|
||||
<el-button size="small" type="danger" @click="handleDeleteRule(row)">删除</el-button>
|
||||
</template>
|
||||
</el-table-column>
|
||||
</el-table>
|
||||
</el-card>
|
||||
</el-tab-pane>
|
||||
|
||||
<!-- ── History ── -->
|
||||
<el-tab-pane label="告警历史" name="history">
|
||||
<el-card shadow="hover">
|
||||
<el-table :data="history" stripe style="width: 100%" v-loading="historyLoading">
|
||||
<el-table-column label="时间" width="170">
|
||||
<template #default="{ row }">
|
||||
{{ row.triggered_at?.substring(0, 19)?.replace('T', ' ') }}
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="级别" width="100">
|
||||
<template #default="{ row }">
|
||||
<el-tag :type="row.level === 'critical' ? 'danger' : 'warning'" size="small">
|
||||
{{ row.level?.toUpperCase() }}
|
||||
</el-tag>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="消息" min-width="400" prop="message" show-overflow-tooltip />
|
||||
<el-table-column label="触发值" width="100" prop="triggered_value" />
|
||||
<el-table-column label="状态" width="100">
|
||||
<template #default="{ row }">
|
||||
<el-tag :type="row.is_acknowledged ? 'success' : 'danger'" size="small">
|
||||
{{ row.is_acknowledged ? '已确认' : '未确认' }}
|
||||
</el-tag>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="操作" width="120">
|
||||
<template #default="{ row }">
|
||||
<el-button v-if="!row.is_acknowledged" size="small" type="primary" @click="handleAcknowledge(row)">确认</el-button>
|
||||
</template>
|
||||
</el-table-column>
|
||||
</el-table>
|
||||
</el-card>
|
||||
</el-tab-pane>
|
||||
</el-tabs>
|
||||
|
||||
<!-- Rule Dialog -->
|
||||
<el-dialog v-model="ruleDialogVisible" :title="isEditingRule ? '编辑规则' : '新建规则'" width="550px">
|
||||
<el-form :model="ruleForm" :rules="ruleRules" ref="ruleFormRef" label-width="120px">
|
||||
<el-form-item label="规则名称" prop="name">
|
||||
<el-input v-model="ruleForm.name" />
|
||||
</el-form-item>
|
||||
<el-form-item label="项目 ID" prop="project_id">
|
||||
<el-input v-model="ruleForm.project_id" placeholder="输入项目 ID" />
|
||||
</el-form-item>
|
||||
<el-form-item label="告警级别" prop="level">
|
||||
<el-select v-model="ruleForm.level" style="width: 100%">
|
||||
<el-option label="INFO" value="info" />
|
||||
<el-option label="WARNING" value="warning" />
|
||||
<el-option label="CRITICAL" value="critical" />
|
||||
</el-select>
|
||||
</el-form-item>
|
||||
<el-form-item label="字段" prop="field">
|
||||
<el-input v-model="ruleForm.field" placeholder="如: level" />
|
||||
</el-form-item>
|
||||
<el-form-item label="条件" prop="operator">
|
||||
<el-select v-model="ruleForm.operator" style="width: 120px">
|
||||
<el-option label=">" value="gt" />
|
||||
<el-option label=">=" value="gte" />
|
||||
<el-option label="<" value="lt" />
|
||||
<el-option label="<=" value="lte" />
|
||||
<el-option label="=" value="eq" />
|
||||
</el-select>
|
||||
<el-input-number v-model="ruleForm.threshold" :min="0" style="margin-left: 10px" />
|
||||
</el-form-item>
|
||||
<el-form-item label="时间窗口(分)">
|
||||
<el-input-number v-model="ruleForm.window_minutes" :min="1" :max="1440" />
|
||||
</el-form-item>
|
||||
<el-form-item label="通知渠道">
|
||||
<el-checkbox-group v-model="ruleForm.notify_channels">
|
||||
<el-checkbox label="feishu">飞书</el-checkbox>
|
||||
<el-checkbox label="dingtalk">钉钉</el-checkbox>
|
||||
</el-checkbox-group>
|
||||
</el-form-item>
|
||||
</el-form>
|
||||
<template #footer>
|
||||
<el-button @click="ruleDialogVisible = false">取消</el-button>
|
||||
<el-button type="primary" @click="handleSubmitRule" :loading="ruleSubmitting">确定</el-button>
|
||||
</template>
|
||||
</el-dialog>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, onMounted } from 'vue'
|
||||
import { listAlertRules, createAlertRule, updateAlertRule, deleteAlertRule, listAlertHistory, acknowledgeAlert } from '../api'
|
||||
import { Plus } from '@element-plus/icons-vue'
|
||||
import { ElMessage, ElMessageBox } from 'element-plus'
|
||||
|
||||
const activeTab = ref('rules')
|
||||
|
||||
// Rules
|
||||
const rules = ref([])
|
||||
const rulesLoading = ref(false)
|
||||
const ruleDialogVisible = ref(false)
|
||||
const isEditingRule = ref(false)
|
||||
const editRuleId = ref(null)
|
||||
const ruleSubmitting = ref(false)
|
||||
const ruleFormRef = ref(null)
|
||||
const ruleForm = ref({
|
||||
name: '',
|
||||
project_id: '',
|
||||
level: 'warning',
|
||||
field: 'level',
|
||||
operator: 'gte',
|
||||
threshold: 10,
|
||||
window_minutes: 5,
|
||||
notify_channels: [],
|
||||
})
|
||||
const ruleRules = {
|
||||
name: [{ required: true, message: '请输入规则名称', trigger: 'blur' }],
|
||||
project_id: [{ required: true, message: '请输入项目 ID', trigger: 'blur' }],
|
||||
}
|
||||
|
||||
// History
|
||||
const history = ref([])
|
||||
const historyLoading = ref(false)
|
||||
|
||||
function operatorLabel(op) {
|
||||
const map = { gt: '>', gte: '>=', lt: '<', lte: '<=', eq: '=' }
|
||||
return map[op] || op
|
||||
}
|
||||
|
||||
async function loadRules() {
|
||||
rulesLoading.value = true
|
||||
try {
|
||||
const res = await listAlertRules()
|
||||
rules.value = res.data
|
||||
} catch (e) {
|
||||
console.error(e)
|
||||
} finally {
|
||||
rulesLoading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function loadHistory() {
|
||||
historyLoading.value = true
|
||||
try {
|
||||
const res = await listAlertHistory()
|
||||
history.value = res.data
|
||||
} catch (e) {
|
||||
console.error(e)
|
||||
} finally {
|
||||
historyLoading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
function showCreateRuleDialog() {
|
||||
isEditingRule.value = false
|
||||
editRuleId.value = null
|
||||
ruleForm.value = { name: '', project_id: '', level: 'warning', field: 'level', operator: 'gte', threshold: 10, window_minutes: 5, notify_channels: [] }
|
||||
ruleDialogVisible.value = true
|
||||
}
|
||||
|
||||
function showEditRuleDialog(row) {
|
||||
isEditingRule.value = true
|
||||
editRuleId.value = row.id
|
||||
ruleForm.value = {
|
||||
name: row.name,
|
||||
project_id: row.project_id,
|
||||
level: row.level,
|
||||
field: row.field,
|
||||
operator: row.operator,
|
||||
threshold: row.threshold,
|
||||
window_minutes: row.window_minutes,
|
||||
notify_channels: row.notify_channels || [],
|
||||
}
|
||||
ruleDialogVisible.value = true
|
||||
}
|
||||
|
||||
async function handleSubmitRule() {
|
||||
const valid = await ruleFormRef.value.validate().catch(() => false)
|
||||
if (!valid) return
|
||||
|
||||
ruleSubmitting.value = true
|
||||
try {
|
||||
if (isEditingRule.value) {
|
||||
await updateAlertRule(editRuleId.value, ruleForm.value)
|
||||
ElMessage.success('规则已更新')
|
||||
} else {
|
||||
await createAlertRule(ruleForm.value)
|
||||
ElMessage.success('规则已创建')
|
||||
}
|
||||
ruleDialogVisible.value = false
|
||||
await loadRules()
|
||||
} catch (e) {
|
||||
ElMessage.error(e.response?.data?.detail || '操作失败')
|
||||
} finally {
|
||||
ruleSubmitting.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function handleDeleteRule(row) {
|
||||
try {
|
||||
await ElMessageBox.confirm(`确定删除规则"${row.name}"?`, '确认删除', { type: 'warning' })
|
||||
await deleteAlertRule(row.id)
|
||||
ElMessage.success('规则已删除')
|
||||
await loadRules()
|
||||
} catch (e) {
|
||||
if (e !== 'cancel') ElMessage.error('删除失败')
|
||||
}
|
||||
}
|
||||
|
||||
async function toggleRule(row, enabled) {
|
||||
try {
|
||||
await updateAlertRule(row.id, { is_enabled: enabled })
|
||||
ElMessage.success(enabled ? '规则已启用' : '规则已停用')
|
||||
} catch (e) {
|
||||
ElMessage.error('操作失败')
|
||||
}
|
||||
}
|
||||
|
||||
async function handleAcknowledge(row) {
|
||||
try {
|
||||
await acknowledgeAlert(row.id)
|
||||
ElMessage.success('已确认')
|
||||
await loadHistory()
|
||||
} catch (e) {
|
||||
ElMessage.error('确认失败')
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
loadRules()
|
||||
loadHistory()
|
||||
})
|
||||
</script>
|
||||
@@ -0,0 +1,204 @@
|
||||
<template>
|
||||
<div class="dashboard">
|
||||
<h2 style="margin-bottom: 20px">📊 仪表盘</h2>
|
||||
|
||||
<!-- Stats Cards -->
|
||||
<el-row :gutter="20" style="margin-bottom: 20px">
|
||||
<el-col :span="6">
|
||||
<el-card shadow="hover">
|
||||
<div class="stat-card">
|
||||
<div class="stat-label">总日志量</div>
|
||||
<div class="stat-value">{{ stats.total_logs.toLocaleString() }}</div>
|
||||
</div>
|
||||
</el-card>
|
||||
</el-col>
|
||||
<el-col :span="6">
|
||||
<el-card shadow="hover">
|
||||
<div class="stat-card">
|
||||
<div class="stat-label">错误数</div>
|
||||
<div class="stat-value" style="color: #f56c6c">{{ stats.error_count.toLocaleString() }}</div>
|
||||
</div>
|
||||
</el-card>
|
||||
</el-col>
|
||||
<el-col :span="6">
|
||||
<el-card shadow="hover">
|
||||
<div class="stat-card">
|
||||
<div class="stat-label">错误率</div>
|
||||
<div class="stat-value" :style="{ color: stats.error_rate > 5 ? '#f56c6c' : '#67c23a' }">
|
||||
{{ stats.error_rate }}%
|
||||
</div>
|
||||
</div>
|
||||
</el-card>
|
||||
</el-col>
|
||||
<el-col :span="6">
|
||||
<el-card shadow="hover">
|
||||
<div class="stat-card">
|
||||
<div class="stat-label">项目数</div>
|
||||
<div class="stat-value" style="color: #409eff">{{ projects.length }}</div>
|
||||
</div>
|
||||
</el-card>
|
||||
</el-col>
|
||||
</el-row>
|
||||
|
||||
<!-- Charts Row -->
|
||||
<el-row :gutter="20" style="margin-bottom: 20px">
|
||||
<el-col :span="16">
|
||||
<el-card shadow="hover">
|
||||
<template #header>
|
||||
<span>📈 日志趋势(近24h)</span>
|
||||
</template>
|
||||
<div ref="trendChart" style="height: 300px"></div>
|
||||
</el-card>
|
||||
</el-col>
|
||||
<el-col :span="8">
|
||||
<el-card shadow="hover">
|
||||
<template #header>
|
||||
<span>🥧 日志级别分布</span>
|
||||
</template>
|
||||
<div ref="pieChart" style="height: 300px"></div>
|
||||
</el-card>
|
||||
</el-col>
|
||||
</el-row>
|
||||
|
||||
<!-- Top Errors -->
|
||||
<el-card shadow="hover">
|
||||
<template #header>
|
||||
<span>⚠️ 高频错误 TOP 10</span>
|
||||
</template>
|
||||
<el-table :data="stats.top_errors" stripe style="width: 100%" v-if="stats.top_errors.length">
|
||||
<el-table-column prop="message" label="错误消息" min-width="500">
|
||||
<template #default="{ row }">
|
||||
<el-tooltip :content="row.message" placement="top">
|
||||
<span class="error-msg">{{ row.message }}</span>
|
||||
</el-tooltip>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column prop="count" label="出现次数" width="120" align="right" sortable />
|
||||
</el-table>
|
||||
<el-empty v-else description="暂无错误数据" />
|
||||
</el-card>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, onMounted, nextTick, watch } from 'vue'
|
||||
import { fetchDashboardStats, fetchTimeSeries, listProjects } from '../api'
|
||||
import * as echarts from 'echarts'
|
||||
|
||||
const stats = ref({
|
||||
total_logs: 0,
|
||||
error_count: 0,
|
||||
error_rate: 0,
|
||||
level_breakdown: {},
|
||||
top_errors: [],
|
||||
})
|
||||
const projects = ref([])
|
||||
const trendChart = ref(null)
|
||||
const pieChart = ref(null)
|
||||
|
||||
async function loadData() {
|
||||
try {
|
||||
const [statsRes, tsRes, projRes] = await Promise.all([
|
||||
fetchDashboardStats(),
|
||||
fetchTimeSeries(),
|
||||
listProjects(),
|
||||
])
|
||||
stats.value = statsRes.data
|
||||
projects.value = projRes.data
|
||||
renderCharts(tsRes.data)
|
||||
} catch (e) {
|
||||
console.error('Failed to load dashboard data', e)
|
||||
}
|
||||
}
|
||||
|
||||
function renderCharts(timeSeries) {
|
||||
nextTick(() => {
|
||||
// Trend chart
|
||||
if (trendChart.value) {
|
||||
const chart = echarts.init(trendChart.value)
|
||||
chart.setOption({
|
||||
tooltip: { trigger: 'axis' },
|
||||
legend: { data: ['总计', 'error', 'warning'] },
|
||||
grid: { left: 40, right: 20, bottom: 30 },
|
||||
xAxis: {
|
||||
type: 'category',
|
||||
data: timeSeries.map((t) => t.timestamp?.substring(11, 16) || ''),
|
||||
axisLabel: { rotate: 45 },
|
||||
},
|
||||
yAxis: { type: 'value' },
|
||||
series: [
|
||||
{
|
||||
name: '总计',
|
||||
type: 'line',
|
||||
data: timeSeries.map((t) => t.total || 0),
|
||||
smooth: true,
|
||||
lineStyle: { color: '#409eff' },
|
||||
},
|
||||
{
|
||||
name: 'error',
|
||||
type: 'line',
|
||||
data: timeSeries.map((t) => t.levels?.error || 0),
|
||||
smooth: true,
|
||||
lineStyle: { color: '#f56c6c' },
|
||||
},
|
||||
{
|
||||
name: 'warning',
|
||||
type: 'line',
|
||||
data: timeSeries.map((t) => t.levels?.warning || 0),
|
||||
smooth: true,
|
||||
lineStyle: { color: '#e6a23c' },
|
||||
},
|
||||
],
|
||||
})
|
||||
}
|
||||
|
||||
// Pie chart
|
||||
if (pieChart.value) {
|
||||
const chart = echarts.init(pieChart.value)
|
||||
const levels = stats.value.level_breakdown || {}
|
||||
const colors = { debug: '#909399', info: '#409eff', warning: '#e6a23c', error: '#f56c6c', critical: '#f56c6c' }
|
||||
chart.setOption({
|
||||
tooltip: { trigger: 'item', formatter: '{b}: {c} ({d}%)' },
|
||||
series: [
|
||||
{
|
||||
type: 'pie',
|
||||
radius: ['40%', '70%'],
|
||||
center: ['50%', '50%'],
|
||||
data: Object.entries(levels).map(([name, value]) => ({
|
||||
name,
|
||||
value,
|
||||
itemStyle: { color: colors[name] || '#909399' },
|
||||
})),
|
||||
label: { formatter: '{b}\n{d}%' },
|
||||
},
|
||||
],
|
||||
})
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
onMounted(loadData)
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.stat-card {
|
||||
text-align: center;
|
||||
padding: 10px 0;
|
||||
}
|
||||
.stat-label {
|
||||
font-size: 14px;
|
||||
color: #909399;
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
.stat-value {
|
||||
font-size: 28px;
|
||||
font-weight: bold;
|
||||
}
|
||||
.error-msg {
|
||||
display: inline-block;
|
||||
max-width: 480px;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,222 @@
|
||||
<template>
|
||||
<div class="log-search">
|
||||
<h2 style="margin-bottom: 20px">🔍 日志检索</h2>
|
||||
|
||||
<!-- Search Filters -->
|
||||
<el-card shadow="hover" style="margin-bottom: 20px">
|
||||
<el-form :model="filters" inline>
|
||||
<el-form-item label="项目">
|
||||
<el-select v-model="filters.project" placeholder="全部项目" clearable style="width: 160px">
|
||||
<el-option v-for="p in projects" :key="p.name" :label="p.name" :value="p.name" />
|
||||
</el-select>
|
||||
</el-form-item>
|
||||
<el-form-item label="级别">
|
||||
<el-select v-model="filters.level" placeholder="全部级别" clearable style="width: 120px">
|
||||
<el-option label="DEBUG" value="debug" />
|
||||
<el-option label="INFO" value="info" />
|
||||
<el-option label="WARNING" value="warning" />
|
||||
<el-option label="ERROR" value="error" />
|
||||
<el-option label="CRITICAL" value="critical" />
|
||||
</el-select>
|
||||
</el-form-item>
|
||||
<el-form-item label="关键词">
|
||||
<el-input v-model="filters.query" placeholder="搜索消息内容..." clearable style="width: 250px" />
|
||||
</el-form-item>
|
||||
<el-form-item label="Trace ID">
|
||||
<el-input v-model="filters.trace_id" placeholder="按 trace ID 过滤" clearable style="width: 200px" />
|
||||
</el-form-item>
|
||||
<el-form-item label="时间">
|
||||
<el-date-picker
|
||||
v-model="timeRange"
|
||||
type="datetimerange"
|
||||
range-separator="至"
|
||||
start-placeholder="开始时间"
|
||||
end-placeholder="结束时间"
|
||||
format="YYYY-MM-DD HH:mm:ss"
|
||||
value-format="YYYY-MM-DDTHH:mm:ss"
|
||||
style="width: 360px"
|
||||
/>
|
||||
</el-form-item>
|
||||
<el-form-item>
|
||||
<el-button type="primary" @click="doSearch" :icon="Search">搜索</el-button>
|
||||
<el-button @click="resetFilters">重置</el-button>
|
||||
</el-form-item>
|
||||
</el-form>
|
||||
</el-card>
|
||||
|
||||
<!-- Stats Summary -->
|
||||
<el-card shadow="hover" style="margin-bottom: 20px">
|
||||
<el-row :gutter="20">
|
||||
<el-col :span="8">
|
||||
<div class="stat-item">
|
||||
<span class="stat-label">匹配总数</span>
|
||||
<span class="stat-value">{{ result.total }}</span>
|
||||
</div>
|
||||
</el-col>
|
||||
<el-col :span="8">
|
||||
<div class="stat-item">
|
||||
<span class="stat-label">当前页</span>
|
||||
<span class="stat-value">{{ result.page }} / {{ totalPages }}</span>
|
||||
</div>
|
||||
</el-col>
|
||||
<el-col :span="8">
|
||||
<div class="stat-item">
|
||||
<span class="stat-label">每页</span>
|
||||
<el-select v-model="filters.page_size" style="width: 80px" @change="doSearch">
|
||||
<el-option label="20" :value="20" />
|
||||
<el-option label="50" :value="50" />
|
||||
<el-option label="100" :value="100" />
|
||||
</el-select>
|
||||
</div>
|
||||
</el-col>
|
||||
</el-row>
|
||||
</el-card>
|
||||
|
||||
<!-- Log Results -->
|
||||
<el-card shadow="hover">
|
||||
<el-table :data="result.hits" stripe style="width: 100%" v-loading="loading" @row-click="showDetail">
|
||||
<el-table-column label="时间" width="180">
|
||||
<template #default="{ row }">
|
||||
{{ row['timestamp']?.substring(0, 19)?.replace('T', ' ') || '-' }}
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="级别" width="90">
|
||||
<template #default="{ row }">
|
||||
<el-tag :type="levelTag(row.level)" size="small" effect="dark">
|
||||
{{ row.level?.toUpperCase() }}
|
||||
</el-tag>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="项目" width="130" prop="project_name" />
|
||||
<el-table-column label="Logger" width="150" prop="logger" />
|
||||
<el-table-column label="消息" min-width="300">
|
||||
<template #default="{ row }">
|
||||
<span class="log-message">{{ row.message }}</span>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="Trace ID" width="160" prop="trace_id" />
|
||||
</el-table>
|
||||
|
||||
<!-- Pagination -->
|
||||
<div style="margin-top: 16px; display: flex; justify-content: center">
|
||||
<el-pagination
|
||||
v-model:current-page="filters.page"
|
||||
:page-size="filters.page_size"
|
||||
:total="result.total"
|
||||
layout="prev, pager, next"
|
||||
@current-change="doSearch"
|
||||
/>
|
||||
</div>
|
||||
</el-card>
|
||||
|
||||
<!-- Log Detail Dialog -->
|
||||
<el-dialog v-model="detailVisible" title="日志详情" width="700px" top="5vh">
|
||||
<pre class="log-detail">{{ JSON.stringify(selectedLog, null, 2) }}</pre>
|
||||
</el-dialog>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, computed, onMounted } from 'vue'
|
||||
import { searchLogs, listProjects } from '../api'
|
||||
import { Search } from '@element-plus/icons-vue'
|
||||
|
||||
const filters = ref({
|
||||
project: null,
|
||||
level: null,
|
||||
query: '',
|
||||
trace_id: '',
|
||||
page: 1,
|
||||
page_size: 50,
|
||||
sort_by: 'timestamp',
|
||||
sort_order: 'desc',
|
||||
})
|
||||
const timeRange = ref(null)
|
||||
const result = ref({ total: 0, page: 1, page_size: 50, hits: [] })
|
||||
const projects = ref([])
|
||||
const loading = ref(false)
|
||||
const detailVisible = ref(false)
|
||||
const selectedLog = ref(null)
|
||||
|
||||
const totalPages = computed(() => Math.max(1, Math.ceil(result.value.total / filters.value.page_size)))
|
||||
|
||||
function levelTag(level) {
|
||||
const map = { debug: 'info', info: 'primary', warning: 'warning', error: 'danger', critical: 'danger' }
|
||||
return map[level] || 'info'
|
||||
}
|
||||
|
||||
async function doSearch() {
|
||||
loading.value = true
|
||||
try {
|
||||
const params = { ...filters.value }
|
||||
if (timeRange.value) {
|
||||
params.start_time = timeRange.value[0]
|
||||
params.end_time = timeRange.value[1]
|
||||
}
|
||||
// Remove null/empty values
|
||||
Object.keys(params).forEach((k) => {
|
||||
if (params[k] === null || params[k] === '' || params[k] === undefined) delete params[k]
|
||||
})
|
||||
const res = await searchLogs(params)
|
||||
result.value = res.data
|
||||
} catch (e) {
|
||||
console.error('Search failed', e)
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
function resetFilters() {
|
||||
filters.value = { project: null, level: null, query: '', trace_id: '', page: 1, page_size: 50, sort_by: 'timestamp', sort_order: 'desc' }
|
||||
timeRange.value = null
|
||||
doSearch()
|
||||
}
|
||||
|
||||
function showDetail(row) {
|
||||
selectedLog.value = row
|
||||
detailVisible.value = true
|
||||
}
|
||||
|
||||
onMounted(async () => {
|
||||
try {
|
||||
const res = await listProjects()
|
||||
projects.value = res.data
|
||||
} catch (e) {
|
||||
// ignore
|
||||
}
|
||||
doSearch()
|
||||
})
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.stat-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
}
|
||||
.stat-label {
|
||||
font-size: 14px;
|
||||
color: #909399;
|
||||
}
|
||||
.stat-value {
|
||||
font-size: 16px;
|
||||
font-weight: bold;
|
||||
}
|
||||
.log-message {
|
||||
display: inline-block;
|
||||
max-width: 500px;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
.log-detail {
|
||||
background: #f5f7fa;
|
||||
padding: 16px;
|
||||
border-radius: 4px;
|
||||
font-size: 13px;
|
||||
overflow: auto;
|
||||
max-height: 60vh;
|
||||
white-space: pre-wrap;
|
||||
word-break: break-all;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,179 @@
|
||||
<template>
|
||||
<div class="project-manage">
|
||||
<div style="display: flex; justify-content: space-between; align-items: center; margin-bottom: 20px">
|
||||
<h2 style="margin: 0">⚙️ 项目管理</h2>
|
||||
<el-button type="primary" @click="showCreateDialog" :icon="Plus">新建项目</el-button>
|
||||
</div>
|
||||
|
||||
<el-card shadow="hover">
|
||||
<el-table :data="projects" stripe style="width: 100%" v-loading="loading">
|
||||
<el-table-column prop="name" label="项目名称" min-width="150" />
|
||||
<el-table-column prop="description" label="描述" min-width="250" show-overflow-tooltip />
|
||||
<el-table-column label="状态" width="100">
|
||||
<template #default="{ row }">
|
||||
<el-tag :type="row.is_active ? 'success' : 'danger'" size="small">
|
||||
{{ row.is_active ? '启用' : '停用' }}
|
||||
</el-tag>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="创建时间" width="170">
|
||||
<template #default="{ row }">
|
||||
{{ row.created_at?.substring(0, 19)?.replace('T', ' ') }}
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="API Key" width="200">
|
||||
<template #default="{ row }">
|
||||
<el-input :model-value="row.api_key || '••••••••'" readonly size="small" style="width: 140px">
|
||||
<template #suffix>
|
||||
<el-icon style="cursor: pointer" @click="copyKey(row)"><CopyDocument /></el-icon>
|
||||
</template>
|
||||
</el-input>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="操作" width="240" fixed="right">
|
||||
<template #default="{ row }">
|
||||
<el-button size="small" @click="showEditDialog(row)">编辑</el-button>
|
||||
<el-button size="small" type="warning" @click="handleRotateKey(row)">重置密钥</el-button>
|
||||
<el-button size="small" type="danger" @click="handleDelete(row)">删除</el-button>
|
||||
</template>
|
||||
</el-table-column>
|
||||
</el-table>
|
||||
</el-card>
|
||||
|
||||
<!-- Create/Edit Dialog -->
|
||||
<el-dialog v-model="dialogVisible" :title="isEditing ? '编辑项目' : '新建项目'" width="500px">
|
||||
<el-form :model="form" :rules="rules" ref="formRef" label-width="100px">
|
||||
<el-form-item label="项目名称" prop="name">
|
||||
<el-input v-model="form.name" placeholder="输入项目名称" />
|
||||
</el-form-item>
|
||||
<el-form-item label="描述" prop="description">
|
||||
<el-input v-model="form.description" type="textarea" :rows="3" placeholder="项目描述(可选)" />
|
||||
</el-form-item>
|
||||
<el-form-item label="启用" v-if="isEditing">
|
||||
<el-switch v-model="form.is_active" />
|
||||
</el-form-item>
|
||||
</el-form>
|
||||
<template #footer>
|
||||
<el-button @click="dialogVisible = false">取消</el-button>
|
||||
<el-button type="primary" @click="handleSubmit" :loading="submitting">确定</el-button>
|
||||
</template>
|
||||
</el-dialog>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, onMounted } from 'vue'
|
||||
import { listProjects, createProject, updateProject, deleteProject, rotateApiKey } from '../api'
|
||||
import { Plus, CopyDocument } from '@element-plus/icons-vue'
|
||||
import { ElMessage, ElMessageBox } from 'element-plus'
|
||||
|
||||
const projects = ref([])
|
||||
const loading = ref(false)
|
||||
const dialogVisible = ref(false)
|
||||
const isEditing = ref(false)
|
||||
const submitting = ref(false)
|
||||
const editId = ref(null)
|
||||
const formRef = ref(null)
|
||||
const form = ref({ name: '', description: '', is_active: true })
|
||||
const rules = {
|
||||
name: [{ required: true, message: '请输入项目名称', trigger: 'blur' }],
|
||||
}
|
||||
|
||||
async function loadProjects() {
|
||||
loading.value = true
|
||||
try {
|
||||
const res = await listProjects()
|
||||
projects.value = res.data
|
||||
} catch (e) {
|
||||
console.error(e)
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
function showCreateDialog() {
|
||||
isEditing.value = false
|
||||
editId.value = null
|
||||
form.value = { name: '', description: '', is_active: true }
|
||||
dialogVisible.value = true
|
||||
}
|
||||
|
||||
function showEditDialog(row) {
|
||||
isEditing.value = true
|
||||
editId.value = row.id
|
||||
form.value = { name: row.name, description: row.description || '', is_active: row.is_active }
|
||||
dialogVisible.value = true
|
||||
}
|
||||
|
||||
async function handleSubmit() {
|
||||
const valid = await formRef.value.validate().catch(() => false)
|
||||
if (!valid) return
|
||||
|
||||
submitting.value = true
|
||||
try {
|
||||
if (isEditing.value) {
|
||||
await updateProject(editId.value, form.value)
|
||||
ElMessage.success('项目已更新')
|
||||
} else {
|
||||
const res = await createProject(form.value)
|
||||
ElMessage.success(`项目已创建!API Key: ${res.data.api_key}`)
|
||||
}
|
||||
dialogVisible.value = false
|
||||
await loadProjects()
|
||||
} catch (e) {
|
||||
ElMessage.error(e.response?.data?.detail || '操作失败')
|
||||
} finally {
|
||||
submitting.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function handleRotateKey(row) {
|
||||
try {
|
||||
await ElMessageBox.confirm('确定要重置该项目的 API Key 吗?旧密钥将立即失效。', '确认重置', { type: 'warning' })
|
||||
const res = await rotateApiKey(row.id)
|
||||
ElMessage.success(`新 API Key: ${res.data.api_key}`)
|
||||
await loadProjects()
|
||||
} catch (e) {
|
||||
if (e !== 'cancel') ElMessage.error('重置失败')
|
||||
}
|
||||
}
|
||||
|
||||
async function handleDelete(row) {
|
||||
try {
|
||||
await ElMessageBox.confirm(`确定要删除项目"${row.name}"吗?相关日志不会被删除。`, '确认删除', { type: 'warning' })
|
||||
await deleteProject(row.id)
|
||||
ElMessage.success('项目已删除')
|
||||
await loadProjects()
|
||||
} catch (e) {
|
||||
if (e !== 'cancel') ElMessage.error('删除失败')
|
||||
}
|
||||
}
|
||||
|
||||
function copyKey(row) {
|
||||
if (!row.api_key) return
|
||||
if (navigator.clipboard && window.isSecureContext) {
|
||||
navigator.clipboard.writeText(row.api_key).then(() => {
|
||||
ElMessage.success('API Key 已复制')
|
||||
}).catch(() => {
|
||||
ElMessage.error('复制失败')
|
||||
})
|
||||
} else {
|
||||
const ta = document.createElement('textarea')
|
||||
ta.value = row.api_key
|
||||
ta.style.position = 'fixed'
|
||||
ta.style.left = '-9999px'
|
||||
document.body.appendChild(ta)
|
||||
ta.select()
|
||||
try {
|
||||
document.execCommand('copy')
|
||||
ElMessage.success('API Key 已复制')
|
||||
} catch (e) {
|
||||
ElMessage.error('复制失败,请手动复制')
|
||||
} finally {
|
||||
document.body.removeChild(ta)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(loadProjects)
|
||||
</script>
|
||||
@@ -0,0 +1,15 @@
|
||||
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,
|
||||
},
|
||||
},
|
||||
},
|
||||
})
|
||||
@@ -0,0 +1,150 @@
|
||||
# 接入 LogHive 日志系统指南
|
||||
|
||||
## 前置准备
|
||||
|
||||
向管理员提供你的**项目名称**(如 `user-service`),管理员会返回一个 **API Key**。
|
||||
|
||||
拿到 API Key 后,在你的项目 `.env` 文件中添加:
|
||||
|
||||
```env
|
||||
# LogHive 日志系统
|
||||
LOGHIVE_ENDPOINT=http://10.10.10.14:8000
|
||||
LOGHIVE_PROJECT=你的项目名称
|
||||
LOGHIVE_API_KEY=管理员给你的API-Key
|
||||
```
|
||||
|
||||
> **不要**把 API 地址和 Key 硬编码在代码里,全部从环境变量读取。
|
||||
|
||||
---
|
||||
|
||||
## 方式一:零代码改动(推荐)
|
||||
|
||||
适用于已使用 Python 标准 `logging` 模块的项目。只需在入口文件添加 3 行:
|
||||
|
||||
```python
|
||||
import logging
|
||||
import os
|
||||
from loghive_client import LogHiveHandler
|
||||
|
||||
handler = LogHiveHandler(
|
||||
project=os.environ["LOGHIVE_PROJECT"],
|
||||
api_key=os.environ["LOGHIVE_API_KEY"],
|
||||
endpoint=os.environ["LOGHIVE_ENDPOINT"],
|
||||
level=logging.INFO, # 只发送 INFO 及以上级别
|
||||
)
|
||||
logging.getLogger().addHandler(handler)
|
||||
|
||||
# 现有代码无需任何修改,所有日志自动发送到 LogHive
|
||||
logging.info("服务启动成功")
|
||||
logging.error("数据库连接超时", exc_info=True)
|
||||
```
|
||||
|
||||
### 仅发送特定 logger 的日志
|
||||
|
||||
```python
|
||||
logger = logging.getLogger("myapp.api")
|
||||
logger.addHandler(handler)
|
||||
logger.setLevel(logging.WARNING)
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 方式二:使用 LogHive SDK
|
||||
|
||||
适合需要更精细控制的场景,或者不想影响全局 logging 配置。
|
||||
|
||||
### 安装
|
||||
|
||||
```bash
|
||||
pip install /path/to/loghive-client
|
||||
```
|
||||
|
||||
### 同步项目使用
|
||||
|
||||
```python
|
||||
import os
|
||||
from loghive_client import LogHiveLogger
|
||||
|
||||
logger = LogHiveLogger(
|
||||
project=os.environ["LOGHIVE_PROJECT"],
|
||||
api_key=os.environ["LOGHIVE_API_KEY"],
|
||||
endpoint=os.environ["LOGHIVE_ENDPOINT"],
|
||||
)
|
||||
|
||||
logger.info("用户登录成功", user_id=42, ip="1.2.3.4")
|
||||
logger.warning("API 限流触发", rate="90%")
|
||||
logger.error("支付回调验签失败", trace_id="req-abc-123", exc_info=True)
|
||||
logger.debug("缓存命中 key=user:42")
|
||||
logger.critical("磁盘空间不足,服务即将崩溃")
|
||||
```
|
||||
|
||||
SDK 在后台线程异步批量发送,**不会阻塞主线程**。程序退出时会自动 flush 剩余日志。
|
||||
|
||||
### 异步项目使用(FastAPI / aiohttp)
|
||||
|
||||
```python
|
||||
import os
|
||||
from loghive_client import AsyncLogHiveLogger
|
||||
|
||||
async def main():
|
||||
async with AsyncLogHiveLogger(
|
||||
project=os.environ["LOGHIVE_PROJECT"],
|
||||
api_key=os.environ["LOGHIVE_API_KEY"],
|
||||
endpoint=os.environ["LOGHIVE_ENDPOINT"],
|
||||
) as logger:
|
||||
await logger.info("请求处理完成", path="/api/users", status=200)
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 方式三:直接调用 REST API(非 Python 项目)
|
||||
|
||||
```bash
|
||||
curl -X POST $LOGHIVE_ENDPOINT/api/logs/ingest \
|
||||
-H "Authorization: Bearer $LOGHIVE_API_KEY" \
|
||||
-H "Content-Type: application/json" \
|
||||
-d '{
|
||||
"project": "'$LOGHIVE_PROJECT'",
|
||||
"entries": [
|
||||
{
|
||||
"level": "error",
|
||||
"message": "服务异常",
|
||||
"logger": "myapp.module",
|
||||
"trace_id": "abc-123",
|
||||
"extra": {"key": "value"}
|
||||
}
|
||||
]
|
||||
}'
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 日志字段说明
|
||||
|
||||
| 字段 | 类型 | 必填 | 说明 |
|
||||
|------|------|------|------|
|
||||
| `level` | string | 是 | `debug` / `info` / `warning` / `error` / `critical` |
|
||||
| `message` | string | 是 | 日志内容,最长 65536 字符 |
|
||||
| `logger` | string | 否 | Logger 名称,默认 `root` |
|
||||
| `trace_id` | string | 否 | 链路追踪 ID,用于关联跨模块日志 |
|
||||
| `exception` | string | 否 | 异常堆栈,SDK 通过 `exc_info=True` 自动捕获 |
|
||||
| `extra` | object | 否 | 任意键值对,支持嵌套结构 |
|
||||
|
||||
---
|
||||
|
||||
## 在 LogHive 前端查看
|
||||
|
||||
访问 `http://10.10.10.14:3000`,按项目、级别、关键词、时间范围搜索和统计。
|
||||
|
||||
---
|
||||
|
||||
## 常见问题
|
||||
|
||||
**Q: 发送失败会影响我的主业务吗?**
|
||||
A: 不会。SDK 在后台线程异步发送,网络失败会自动重试 3 次,最终丢弃并记录本地 warning。
|
||||
|
||||
**Q: 日志量很大怎么办?**
|
||||
A: SDK 默认每 2 秒或积攒 50 条批量发送。可通过 `batch_size` 和 `flush_interval` 参数调节。
|
||||
|
||||
**Q: 多个进程/worker 同时发送有问题吗?**
|
||||
A: 没问题。每个进程创建自己的 LogHiveLogger 实例即可。
|
||||
Reference in New Issue
Block a user