Initial commit: LogHive centralized log management system

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
v6ole
2026-05-09 14:55:14 +08:00
commit abfd07331e
54 changed files with 3816 additions and 0 deletions
+22
View File
@@ -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 *)"
]
}
}
+27
View File
@@ -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
View File
@@ -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
+219
View File
@@ -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
+21
View File
@@ -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"]
View File
View File
+180
View File
@@ -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,
)
+34
View File
@@ -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)
+253
View File
@@ -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}
+123
View File
@@ -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
+79
View File
@@ -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()
+1
View File
@@ -0,0 +1 @@
from .auth import verify_api_key, resolve_project_by_api_key
+50
View File
@@ -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
+1
View File
@@ -0,0 +1 @@
"""Core auth logic."""
+37
View File
@@ -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()
+65
View File
@@ -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"}
+5
View File
@@ -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"]
+71
View File
@@ -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)
)
+37
View File
@@ -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()),
)
+34
View File
@@ -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}>"
+35
View File
@@ -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",
]
+63
View File
@@ -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}
+64
View File
@@ -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
+34
View File
@@ -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}
View File
+142
View File
@@ -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)
+125
View File
@@ -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())
View File
+33
View File
@@ -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
},
},
)
+82
View File
@@ -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()
+25
View File
@@ -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
+84
View File
@@ -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")
```
+20
View File
@@ -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"]
+178
View File
@@ -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)
+183
View File
@@ -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)
+72
View File
@@ -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()
+23
View File
@@ -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",
],
)
+42
View File
@@ -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
+20
View File
@@ -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;"]
+13
View File
@@ -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>
+21
View File
@@ -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;
}
}
+25
View File
@@ -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"
}
}
+4
View File
@@ -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

+69
View File
@@ -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>
+92
View File
@@ -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')
}
+20
View File
@@ -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')
+19
View File
@@ -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
+272
View File
@@ -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>
+204
View File
@@ -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>
+222
View File
@@ -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>
+179
View File
@@ -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>
+15
View File
@@ -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,
},
},
},
})
+150
View File
@@ -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 实例即可。