feat: initial log center with k8s deployment
All checks were successful
Build and Deploy Log Center / build-and-deploy (push) Successful in 1m30s
All checks were successful
Build and Deploy Log Center / build-and-deploy (push) Successful in 1m30s
This commit is contained in:
commit
637c479818
79
.gitea/workflows/deploy.yaml
Normal file
79
.gitea/workflows/deploy.yaml
Normal file
@ -0,0 +1,79 @@
|
||||
name: Build and Deploy Log Center
|
||||
|
||||
on:
|
||||
push:
|
||||
branches:
|
||||
- main
|
||||
- master
|
||||
|
||||
jobs:
|
||||
build-and-deploy:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v3
|
||||
|
||||
- name: Set up Docker Buildx
|
||||
uses: docker/setup-buildx-action@v2
|
||||
with:
|
||||
config-inline: |
|
||||
[registry."docker.io"]
|
||||
mirrors = ["https://docker.m.daocloud.io", "https://docker.1panel.live", "https://hub.rat.dev"]
|
||||
|
||||
- name: Login to Huawei Cloud SWR
|
||||
uses: docker/login-action@v2
|
||||
with:
|
||||
registry: ${{ secrets.SWR_SERVER }}
|
||||
username: ${{ secrets.SWR_USERNAME }}
|
||||
password: ${{ secrets.SWR_PASSWORD }}
|
||||
|
||||
# Build API Image
|
||||
- name: Build and Push API
|
||||
uses: docker/build-push-action@v4
|
||||
with:
|
||||
context: .
|
||||
file: ./Dockerfile
|
||||
push: true
|
||||
provenance: false
|
||||
tags: ${{ secrets.SWR_SERVER }}/${{ secrets.SWR_ORG }}/log-center-api:latest
|
||||
|
||||
# Build Web Image
|
||||
- name: Build and Push Web
|
||||
uses: docker/build-push-action@v4
|
||||
with:
|
||||
context: ./web
|
||||
file: ./web/Dockerfile
|
||||
push: true
|
||||
provenance: false
|
||||
tags: ${{ secrets.SWR_SERVER }}/${{ secrets.SWR_ORG }}/log-center-web:latest
|
||||
build-args: |
|
||||
VITE_API_BASE_URL=https://qiyuan-log-center-api.airlabs.art
|
||||
|
||||
- name: Setup Kubectl
|
||||
run: |
|
||||
curl -LO "https://files.m.daocloud.io/dl.k8s.io/release/v1.28.2/bin/linux/amd64/kubectl"
|
||||
chmod +x kubectl
|
||||
mv kubectl /usr/local/bin/
|
||||
|
||||
- name: Deploy to K3s
|
||||
uses: Azure/k8s-set-context@v3
|
||||
with:
|
||||
method: kubeconfig
|
||||
kubeconfig: ${{ secrets.KUBE_CONFIG }}
|
||||
|
||||
- name: Update K8s Manifests
|
||||
run: |
|
||||
echo "Environment: Production"
|
||||
|
||||
# Replace image placeholders
|
||||
sed -i "s|\${CI_REGISTRY_IMAGE}/log-center-api:latest|${{ secrets.SWR_SERVER }}/${{ secrets.SWR_ORG }}/log-center-api:latest|g" k8s/api-deployment-prod.yaml
|
||||
sed -i "s|\${CI_REGISTRY_IMAGE}/log-center-web:latest|${{ secrets.SWR_SERVER }}/${{ secrets.SWR_ORG }}/log-center-web:latest|g" k8s/web-deployment-prod.yaml
|
||||
|
||||
# Apply configurations
|
||||
kubectl apply -f k8s/api-deployment-prod.yaml
|
||||
kubectl apply -f k8s/web-deployment-prod.yaml
|
||||
kubectl apply -f k8s/ingress.yaml
|
||||
|
||||
# Restart deployments
|
||||
kubectl rollout restart deployment/log-center-api
|
||||
kubectl rollout restart deployment/log-center-web
|
||||
33
.gitignore
vendored
Normal file
33
.gitignore
vendored
Normal file
@ -0,0 +1,33 @@
|
||||
# Dependencies
|
||||
node_modules/
|
||||
venv/
|
||||
__pycache__/
|
||||
*.pyc
|
||||
*.pyo
|
||||
|
||||
# Environment
|
||||
.env
|
||||
.env.local
|
||||
.env.*.local
|
||||
|
||||
# Build outputs
|
||||
dist/
|
||||
build/
|
||||
*.egg-info/
|
||||
|
||||
# IDE
|
||||
.idea/
|
||||
.vscode/
|
||||
*.swp
|
||||
*.swo
|
||||
|
||||
# OS
|
||||
.DS_Store
|
||||
Thumbs.db
|
||||
|
||||
# Logs
|
||||
*.log
|
||||
logs/
|
||||
|
||||
# K8s secrets (never commit real secrets)
|
||||
# k8s/secrets.yaml # Uncomment if you want to ignore secrets
|
||||
24
Dockerfile
Normal file
24
Dockerfile
Normal file
@ -0,0 +1,24 @@
|
||||
# Build stage - Python FastAPI application
|
||||
FROM python:3.12-slim AS build-stage
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
# Install dependencies
|
||||
COPY requirements.txt ./
|
||||
RUN pip install --no-cache-dir -r requirements.txt
|
||||
|
||||
# Production stage
|
||||
FROM python:3.12-slim AS production-stage
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
# Copy installed packages from build stage
|
||||
COPY --from=build-stage /usr/local/lib/python3.12/site-packages /usr/local/lib/python3.12/site-packages
|
||||
COPY --from=build-stage /usr/local/bin /usr/local/bin
|
||||
|
||||
# Copy application code
|
||||
COPY app ./app
|
||||
|
||||
EXPOSE 8002
|
||||
|
||||
CMD ["uvicorn", "app.main:app", "--host", "0.0.0.0", "--port", "8002"]
|
||||
30
app/database.py
Normal file
30
app/database.py
Normal file
@ -0,0 +1,30 @@
|
||||
from sqlmodel import SQLModel, create_engine
|
||||
from sqlmodel.ext.asyncio.session import AsyncSession
|
||||
from sqlalchemy.ext.asyncio import create_async_engine
|
||||
from sqlalchemy.orm import sessionmaker
|
||||
import os
|
||||
from dotenv import load_dotenv
|
||||
|
||||
load_dotenv()
|
||||
|
||||
DB_USER = os.getenv("DB_USER")
|
||||
DB_PASSWORD = os.getenv("DB_PASSWORD")
|
||||
DB_HOST = os.getenv("DB_HOST")
|
||||
DB_PORT = os.getenv("DB_PORT", "5432")
|
||||
DB_NAME = os.getenv("DB_NAME")
|
||||
|
||||
DATABASE_URL = f"postgresql+asyncpg://{DB_USER}:{DB_PASSWORD}@{DB_HOST}:{DB_PORT}/{DB_NAME}"
|
||||
|
||||
engine = create_async_engine(DATABASE_URL, echo=True, future=True)
|
||||
|
||||
async def init_db():
|
||||
async with engine.begin() as conn:
|
||||
# await conn.run_sync(SQLModel.metadata.drop_all)
|
||||
await conn.run_sync(SQLModel.metadata.create_all)
|
||||
|
||||
async def get_session() -> AsyncSession:
|
||||
async_session = sessionmaker(
|
||||
engine, class_=AsyncSession, expire_on_commit=False
|
||||
)
|
||||
async with async_session() as session:
|
||||
yield session
|
||||
201
app/main.py
Normal file
201
app/main.py
Normal file
@ -0,0 +1,201 @@
|
||||
from fastapi import FastAPI, Depends, HTTPException, Query
|
||||
from fastapi.middleware.cors import CORSMiddleware
|
||||
from sqlmodel.ext.asyncio.session import AsyncSession
|
||||
from sqlmodel import select, func
|
||||
from .database import init_db, get_session
|
||||
from .models import ErrorLog, ErrorLogCreate, LogStatus
|
||||
from datetime import datetime, timedelta
|
||||
from typing import Optional, List
|
||||
import hashlib
|
||||
import json
|
||||
|
||||
app = FastAPI(title="Log Center & AIOps Control Plane")
|
||||
|
||||
# CORS for frontend
|
||||
app.add_middleware(
|
||||
CORSMiddleware,
|
||||
allow_origins=["*"], # In production, restrict to your domain
|
||||
allow_credentials=True,
|
||||
allow_methods=["*"],
|
||||
allow_headers=["*"],
|
||||
)
|
||||
|
||||
@app.on_event("startup")
|
||||
async def on_startup():
|
||||
await init_db()
|
||||
|
||||
def generate_fingerprint(log: ErrorLogCreate) -> str:
|
||||
# Minimal fingerprinting: project + error_type + file + line
|
||||
raw = f"{log.project_id}|{log.error.get('type')}|{log.error.get('file_path')}|{log.error.get('line_number')}"
|
||||
return hashlib.md5(raw.encode()).hexdigest()
|
||||
|
||||
# ==================== Log Reporting ====================
|
||||
@app.post("/api/v1/logs/report", tags=["Logs"])
|
||||
async def report_log(log_data: ErrorLogCreate, session: AsyncSession = Depends(get_session)):
|
||||
fingerprint = generate_fingerprint(log_data)
|
||||
|
||||
# Check deduplication
|
||||
statement = select(ErrorLog).where(ErrorLog.fingerprint == fingerprint)
|
||||
results = await session.exec(statement)
|
||||
existing_log = results.first()
|
||||
|
||||
if existing_log:
|
||||
# If exists and not resolved, just ignore or update count (implied)
|
||||
if existing_log.status not in [LogStatus.DEPLOYED, LogStatus.FIXED, LogStatus.VERIFIED]:
|
||||
return {"message": "Log deduplicated", "id": existing_log.id, "status": existing_log.status}
|
||||
# If it was resolved but happened again -> Regression! Reset to NEW?
|
||||
existing_log.status = LogStatus.NEW
|
||||
existing_log.timestamp = log_data.timestamp or datetime.utcnow()
|
||||
existing_log.retry_count = 0 # Reset retries for new occurrence
|
||||
session.add(existing_log)
|
||||
await session.commit()
|
||||
await session.refresh(existing_log)
|
||||
return {"message": "Regression detected, reopened", "id": existing_log.id}
|
||||
|
||||
# Create new
|
||||
new_log = ErrorLog(
|
||||
project_id=log_data.project_id,
|
||||
environment=log_data.environment,
|
||||
level=log_data.level,
|
||||
error_type=log_data.error.get("type"),
|
||||
error_message=log_data.error.get("message"),
|
||||
file_path=log_data.error.get("file_path"),
|
||||
line_number=log_data.error.get("line_number"),
|
||||
stack_trace=log_data.error.get("stack_trace"),
|
||||
context=log_data.context,
|
||||
version=log_data.version,
|
||||
commit_hash=log_data.commit_hash,
|
||||
fingerprint=fingerprint,
|
||||
timestamp=log_data.timestamp or datetime.utcnow()
|
||||
)
|
||||
|
||||
session.add(new_log)
|
||||
await session.commit()
|
||||
await session.refresh(new_log)
|
||||
|
||||
return {"message": "Log reported", "id": new_log.id}
|
||||
|
||||
# ==================== Agent Tasks ====================
|
||||
@app.get("/api/v1/tasks/pending", tags=["Tasks"])
|
||||
async def get_pending_tasks(project_id: str = None, session: AsyncSession = Depends(get_session)):
|
||||
query = select(ErrorLog).where(ErrorLog.status == LogStatus.NEW)
|
||||
if project_id:
|
||||
query = query.where(ErrorLog.project_id == project_id)
|
||||
|
||||
results = await session.exec(query)
|
||||
return results.all()
|
||||
|
||||
@app.patch("/api/v1/tasks/{task_id}/status", tags=["Tasks"])
|
||||
async def update_task_status(task_id: int, status: LogStatus, session: AsyncSession = Depends(get_session)):
|
||||
statement = select(ErrorLog).where(ErrorLog.id == task_id)
|
||||
results = await session.exec(statement)
|
||||
task = results.first()
|
||||
|
||||
if not task:
|
||||
raise HTTPException(status_code=404, detail="Task not found")
|
||||
|
||||
task.status = status
|
||||
session.add(task)
|
||||
await session.commit()
|
||||
await session.refresh(task)
|
||||
|
||||
return {"message": "Status updated", "id": task.id, "status": task.status}
|
||||
|
||||
# ==================== Dashboard APIs ====================
|
||||
@app.get("/api/v1/dashboard/stats", tags=["Dashboard"])
|
||||
async def get_dashboard_stats(session: AsyncSession = Depends(get_session)):
|
||||
"""Get overall statistics for dashboard"""
|
||||
today = datetime.utcnow().replace(hour=0, minute=0, second=0, microsecond=0)
|
||||
|
||||
# Total bugs
|
||||
total_query = select(func.count(ErrorLog.id))
|
||||
total_result = await session.exec(total_query)
|
||||
total_bugs = total_result.one()
|
||||
|
||||
# Today's new bugs
|
||||
today_query = select(func.count(ErrorLog.id)).where(ErrorLog.timestamp >= today)
|
||||
today_result = await session.exec(today_query)
|
||||
today_bugs = today_result.one()
|
||||
|
||||
# Count by status
|
||||
status_counts = {}
|
||||
for status in LogStatus:
|
||||
count_query = select(func.count(ErrorLog.id)).where(ErrorLog.status == status)
|
||||
count_result = await session.exec(count_query)
|
||||
status_counts[status.value] = count_result.one()
|
||||
|
||||
# Fixed rate = (FIXED + VERIFIED + DEPLOYED) / Total
|
||||
fixed_count = status_counts.get("FIXED", 0) + status_counts.get("VERIFIED", 0) + status_counts.get("DEPLOYED", 0)
|
||||
fix_rate = round((fixed_count / total_bugs * 100), 2) if total_bugs > 0 else 0
|
||||
|
||||
return {
|
||||
"total_bugs": total_bugs,
|
||||
"today_bugs": today_bugs,
|
||||
"fix_rate": fix_rate,
|
||||
"status_distribution": status_counts
|
||||
}
|
||||
|
||||
@app.get("/api/v1/bugs", tags=["Dashboard"])
|
||||
async def get_bugs_list(
|
||||
page: int = Query(1, ge=1),
|
||||
page_size: int = Query(20, ge=1, le=100),
|
||||
status: Optional[LogStatus] = None,
|
||||
project_id: Optional[str] = None,
|
||||
session: AsyncSession = Depends(get_session)
|
||||
):
|
||||
"""Get paginated list of bugs with optional filters"""
|
||||
query = select(ErrorLog).order_by(ErrorLog.timestamp.desc())
|
||||
|
||||
if status:
|
||||
query = query.where(ErrorLog.status == status)
|
||||
if project_id:
|
||||
query = query.where(ErrorLog.project_id == project_id)
|
||||
|
||||
# Pagination
|
||||
offset = (page - 1) * page_size
|
||||
query = query.offset(offset).limit(page_size)
|
||||
|
||||
results = await session.exec(query)
|
||||
bugs = results.all()
|
||||
|
||||
# Get total count for pagination info
|
||||
count_query = select(func.count(ErrorLog.id))
|
||||
if status:
|
||||
count_query = count_query.where(ErrorLog.status == status)
|
||||
if project_id:
|
||||
count_query = count_query.where(ErrorLog.project_id == project_id)
|
||||
count_result = await session.exec(count_query)
|
||||
total = count_result.one()
|
||||
|
||||
return {
|
||||
"items": bugs,
|
||||
"total": total,
|
||||
"page": page,
|
||||
"page_size": page_size,
|
||||
"total_pages": (total + page_size - 1) // page_size
|
||||
}
|
||||
|
||||
@app.get("/api/v1/bugs/{bug_id}", tags=["Dashboard"])
|
||||
async def get_bug_detail(bug_id: int, session: AsyncSession = Depends(get_session)):
|
||||
"""Get detailed information about a specific bug"""
|
||||
statement = select(ErrorLog).where(ErrorLog.id == bug_id)
|
||||
results = await session.exec(statement)
|
||||
bug = results.first()
|
||||
|
||||
if not bug:
|
||||
raise HTTPException(status_code=404, detail="Bug not found")
|
||||
|
||||
return bug
|
||||
|
||||
@app.get("/api/v1/projects", tags=["Dashboard"])
|
||||
async def get_projects(session: AsyncSession = Depends(get_session)):
|
||||
"""Get list of all unique project IDs"""
|
||||
query = select(ErrorLog.project_id).distinct()
|
||||
results = await session.exec(query)
|
||||
projects = results.all()
|
||||
return {"projects": projects}
|
||||
|
||||
@app.get("/", tags=["Health"])
|
||||
async def health_check():
|
||||
return {"status": "ok"}
|
||||
|
||||
58
app/models.py
Normal file
58
app/models.py
Normal file
@ -0,0 +1,58 @@
|
||||
from datetime import datetime
|
||||
from typing import Optional, Dict
|
||||
from sqlmodel import SQLModel, Field, Column, JSON
|
||||
from enum import Enum
|
||||
|
||||
class LogStatus(str, Enum):
|
||||
NEW = "NEW"
|
||||
VERIFYING = "VERIFYING"
|
||||
CANNOT_REPRODUCE = "CANNOT_REPRODUCE"
|
||||
PENDING_FIX = "PENDING_FIX"
|
||||
FIXING = "FIXING"
|
||||
FIXED = "FIXED"
|
||||
VERIFIED = "VERIFIED"
|
||||
DEPLOYED = "DEPLOYED"
|
||||
FIX_FAILED = "FIX_FAILED"
|
||||
|
||||
class ErrorLog(SQLModel, table=True):
|
||||
id: Optional[int] = Field(default=None, primary_key=True)
|
||||
project_id: str = Field(index=True)
|
||||
environment: str
|
||||
level: str
|
||||
|
||||
# Error Details
|
||||
error_type: str
|
||||
error_message: str
|
||||
file_path: str
|
||||
line_number: int
|
||||
stack_trace: str = Field(sa_column=Column(JSON)) # Store full stack trace
|
||||
|
||||
# Context
|
||||
context: Dict = Field(default={}, sa_column=Column(JSON))
|
||||
|
||||
# Versioning
|
||||
version: Optional[str] = None
|
||||
commit_hash: Optional[str] = None
|
||||
|
||||
# Meta
|
||||
timestamp: datetime = Field(default_factory=datetime.utcnow)
|
||||
fingerprint: str = Field(unique=True, index=True) # project_id + error_type + file_path + line_number
|
||||
|
||||
# Status Tracking
|
||||
status: LogStatus = Field(default=LogStatus.NEW)
|
||||
retry_count: int = Field(default=0)
|
||||
|
||||
# Relationships could be added here (e.g., to a RepairTask table if 1:N)
|
||||
# For simplicity, we track status on the Log itself for now.
|
||||
|
||||
# Pydantic Models for API
|
||||
class ErrorLogCreate(SQLModel):
|
||||
project_id: str
|
||||
environment: str
|
||||
level: str
|
||||
timestamp: Optional[datetime] = None
|
||||
version: Optional[str] = None
|
||||
commit_hash: Optional[str] = None
|
||||
|
||||
error: Dict # {type, message, file_path, line_number, stack_trace}
|
||||
context: Optional[Dict] = {}
|
||||
299
docs/integration_guide.md
Normal file
299
docs/integration_guide.md
Normal file
@ -0,0 +1,299 @@
|
||||
# Log Center 接入指南
|
||||
|
||||
## 概述
|
||||
|
||||
Log Center 是一个集中式错误日志收集平台,提供 REST API 供各项目接入,实现运行时错误的统一收集、去重、追踪和分析。
|
||||
|
||||
---
|
||||
|
||||
## 快速开始
|
||||
|
||||
### 服务地址
|
||||
|
||||
| 环境 | API 地址 | 仪表盘 |
|
||||
|------|----------|--------|
|
||||
| 本地开发 | `http://localhost:8002` | `http://localhost:8003` |
|
||||
| 生产环境 | `https://log.yourcompany.com` | `https://log.yourcompany.com` |
|
||||
|
||||
---
|
||||
|
||||
## API 接口
|
||||
|
||||
### 上报错误日志
|
||||
|
||||
**POST** `/api/v1/logs/report`
|
||||
|
||||
#### 请求体 (JSON)
|
||||
|
||||
```json
|
||||
{
|
||||
"project_id": "rtc_backend",
|
||||
"environment": "production",
|
||||
"level": "ERROR",
|
||||
"timestamp": "2026-01-30T10:30:00Z",
|
||||
"version": "1.2.3",
|
||||
"commit_hash": "abc1234",
|
||||
"error": {
|
||||
"type": "ValueError",
|
||||
"message": "invalid literal for int() with base 10: 'abc'",
|
||||
"file_path": "apps/users/views.py",
|
||||
"line_number": 42,
|
||||
"stack_trace": [
|
||||
"Traceback (most recent call last):",
|
||||
" File \"apps/users/views.py\", line 42, in get_user",
|
||||
" user_id = int(request.GET['id'])",
|
||||
"ValueError: invalid literal for int() with base 10: 'abc'"
|
||||
]
|
||||
},
|
||||
"context": {
|
||||
"url": "/api/users/123",
|
||||
"method": "GET",
|
||||
"user_id": "u_12345",
|
||||
"request_id": "req_abc123"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
#### 字段说明
|
||||
|
||||
| 字段 | 类型 | 必填 | 说明 |
|
||||
|------|------|------|------|
|
||||
| `project_id` | string | ✅ | 项目标识,如 `rtc_backend`, `rtc_web` |
|
||||
| `environment` | string | ✅ | 环境:`development`, `staging`, `production` |
|
||||
| `level` | string | ✅ | 日志级别:`ERROR`, `WARNING`, `CRITICAL` |
|
||||
| `timestamp` | string | ❌ | ISO 8601 格式,不传则使用服务器时间 |
|
||||
| `version` | string | ❌ | 应用版本号 |
|
||||
| `commit_hash` | string | ❌ | Git commit hash |
|
||||
| `error.type` | string | ✅ | 异常类型,如 `ValueError`, `TypeError` |
|
||||
| `error.message` | string | ✅ | 错误消息 |
|
||||
| `error.file_path` | string | ✅ | 出错文件路径 |
|
||||
| `error.line_number` | int | ✅ | 出错行号 |
|
||||
| `error.stack_trace` | array | ✅ | 堆栈信息(数组或字符串) |
|
||||
| `context` | object | ❌ | 额外上下文信息(URL、用户ID等) |
|
||||
|
||||
#### 响应
|
||||
|
||||
**成功 (200)**
|
||||
```json
|
||||
{
|
||||
"status": "ok",
|
||||
"id": 123,
|
||||
"fingerprint": "a1b2c3d4e5f6",
|
||||
"is_new": true
|
||||
}
|
||||
```
|
||||
|
||||
**已存在 (200)** - 重复错误自动去重
|
||||
```json
|
||||
{
|
||||
"status": "duplicate",
|
||||
"id": 123,
|
||||
"fingerprint": "a1b2c3d4e5f6",
|
||||
"is_new": false
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 接入示例
|
||||
|
||||
### Python (Django / FastAPI)
|
||||
|
||||
```python
|
||||
import requests
|
||||
import traceback
|
||||
import os
|
||||
|
||||
LOG_CENTER_URL = os.getenv("LOG_CENTER_URL", "http://localhost:8002")
|
||||
|
||||
def report_error(exc, context=None):
|
||||
"""上报错误到 Log Center"""
|
||||
tb = traceback.extract_tb(exc.__traceback__)
|
||||
last_frame = tb[-1] if tb else None
|
||||
|
||||
payload = {
|
||||
"project_id": "rtc_backend",
|
||||
"environment": os.getenv("ENVIRONMENT", "development"),
|
||||
"level": "ERROR",
|
||||
"error": {
|
||||
"type": type(exc).__name__,
|
||||
"message": str(exc),
|
||||
"file_path": last_frame.filename if last_frame else "unknown",
|
||||
"line_number": last_frame.lineno if last_frame else 0,
|
||||
"stack_trace": traceback.format_exception(exc)
|
||||
},
|
||||
"context": context or {}
|
||||
}
|
||||
|
||||
try:
|
||||
requests.post(
|
||||
f"{LOG_CENTER_URL}/api/v1/logs/report",
|
||||
json=payload,
|
||||
timeout=3 # 快速失败,不影响主业务
|
||||
)
|
||||
except Exception:
|
||||
pass # 静默失败,不影响主业务
|
||||
```
|
||||
|
||||
#### Django 集成位置
|
||||
|
||||
修改 `utils/exceptions.py` 的 `custom_exception_handler`:
|
||||
|
||||
```python
|
||||
def custom_exception_handler(exc, context):
|
||||
# 上报到 Log Center (异步,不阻塞响应)
|
||||
report_error(exc, {
|
||||
"view": str(context.get("view")),
|
||||
"request_path": context.get("request").path if context.get("request") else None,
|
||||
})
|
||||
|
||||
# ... 原有逻辑不变 ...
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### JavaScript / TypeScript (React / Vue)
|
||||
|
||||
```typescript
|
||||
const LOG_CENTER_URL = import.meta.env.VITE_LOG_CENTER_URL || 'http://localhost:8002';
|
||||
|
||||
interface ErrorPayload {
|
||||
project_id: string;
|
||||
environment: string;
|
||||
level: string;
|
||||
error: {
|
||||
type: string;
|
||||
message: string;
|
||||
file_path: string;
|
||||
line_number: number;
|
||||
stack_trace: string[];
|
||||
};
|
||||
context?: Record<string, unknown>;
|
||||
}
|
||||
|
||||
export function reportError(error: Error, context?: Record<string, unknown>) {
|
||||
// 解析堆栈信息
|
||||
const stackLines = error.stack?.split('\n') || [];
|
||||
const match = stackLines[1]?.match(/at\s+.*\s+\((.+):(\d+):\d+\)/);
|
||||
|
||||
const payload: ErrorPayload = {
|
||||
project_id: 'rtc_web',
|
||||
environment: import.meta.env.MODE,
|
||||
level: 'ERROR',
|
||||
error: {
|
||||
type: error.name,
|
||||
message: error.message,
|
||||
file_path: match?.[1] || 'unknown',
|
||||
line_number: parseInt(match?.[2] || '0'),
|
||||
stack_trace: stackLines,
|
||||
},
|
||||
context: {
|
||||
url: window.location.href,
|
||||
userAgent: navigator.userAgent,
|
||||
...context,
|
||||
},
|
||||
};
|
||||
|
||||
// 使用 sendBeacon 确保页面关闭时也能发送
|
||||
if (navigator.sendBeacon) {
|
||||
navigator.sendBeacon(
|
||||
`${LOG_CENTER_URL}/api/v1/logs/report`,
|
||||
JSON.stringify(payload)
|
||||
);
|
||||
} else {
|
||||
fetch(`${LOG_CENTER_URL}/api/v1/logs/report`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(payload),
|
||||
keepalive: true,
|
||||
}).catch(() => {});
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
#### Axios 拦截器集成
|
||||
|
||||
修改 `src/api/request.ts`:
|
||||
|
||||
```typescript
|
||||
request.interceptors.response.use(
|
||||
(response) => { /* ... */ },
|
||||
(error: AxiosError) => {
|
||||
// 上报到 Log Center
|
||||
reportError(error, {
|
||||
url: error.config?.url,
|
||||
method: error.config?.method,
|
||||
status: error.response?.status,
|
||||
});
|
||||
|
||||
// ... 原有逻辑不变 ...
|
||||
}
|
||||
);
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 错误去重机制
|
||||
|
||||
Log Center 使用 **指纹(fingerprint)** 对错误进行去重:
|
||||
|
||||
```
|
||||
fingerprint = MD5(project_id + error_type + file_path + line_number)
|
||||
```
|
||||
|
||||
相同指纹的错误只会记录一次,后续只更新计数和最后出现时间。
|
||||
|
||||
---
|
||||
|
||||
## 错误状态流转
|
||||
|
||||
```
|
||||
NEW → VERIFYING → PENDING_FIX → FIXING → FIXED → VERIFIED → DEPLOYED
|
||||
↓ ↓
|
||||
CANNOT_REPRODUCE FIX_FAILED
|
||||
```
|
||||
|
||||
| 状态 | 说明 |
|
||||
|------|------|
|
||||
| `NEW` | 新上报的错误 |
|
||||
| `VERIFYING` | 正在验证复现 |
|
||||
| `CANNOT_REPRODUCE` | 无法复现 |
|
||||
| `PENDING_FIX` | 等待修复 |
|
||||
| `FIXING` | 正在修复中 |
|
||||
| `FIXED` | 已修复,待验证 |
|
||||
| `VERIFIED` | 已验证修复 |
|
||||
| `DEPLOYED` | 已部署上线 |
|
||||
| `FIX_FAILED` | 修复失败 |
|
||||
|
||||
---
|
||||
|
||||
## 最佳实践
|
||||
|
||||
1. **设置超时**: 上报请求设置 3 秒超时,避免影响主业务
|
||||
2. **静默失败**: 上报失败不应影响用户体验
|
||||
3. **异步上报**: 使用异步方式上报,不阻塞主流程
|
||||
4. **添加上下文**: 尽量添加有用的上下文信息(用户ID、请求URL等)
|
||||
5. **环境区分**: 正确设置 `environment` 字段区分开发/生产
|
||||
|
||||
---
|
||||
|
||||
## 环境变量配置
|
||||
|
||||
### Python 项目
|
||||
```bash
|
||||
# .env
|
||||
LOG_CENTER_URL=http://localhost:8002
|
||||
ENVIRONMENT=development
|
||||
```
|
||||
|
||||
### JavaScript 项目
|
||||
```bash
|
||||
# .env
|
||||
VITE_LOG_CENTER_URL=http://localhost:8002
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## API 文档
|
||||
|
||||
完整 API 文档请访问: [http://localhost:8002/docs](http://localhost:8002/docs)
|
||||
61
k8s/api-deployment-prod.yaml
Normal file
61
k8s/api-deployment-prod.yaml
Normal file
@ -0,0 +1,61 @@
|
||||
apiVersion: apps/v1
|
||||
kind: Deployment
|
||||
metadata:
|
||||
name: log-center-api
|
||||
labels:
|
||||
app: log-center-api
|
||||
spec:
|
||||
replicas: 1
|
||||
selector:
|
||||
matchLabels:
|
||||
app: log-center-api
|
||||
template:
|
||||
metadata:
|
||||
labels:
|
||||
app: log-center-api
|
||||
spec:
|
||||
containers:
|
||||
- name: log-center-api
|
||||
image: ${CI_REGISTRY_IMAGE}/log-center-api:latest
|
||||
imagePullPolicy: Always
|
||||
ports:
|
||||
- containerPort: 8002
|
||||
env:
|
||||
- name: DB_HOST
|
||||
valueFrom:
|
||||
secretKeyRef:
|
||||
name: log-center-secrets
|
||||
key: db-host
|
||||
- name: DB_PORT
|
||||
value: "5432"
|
||||
- name: DB_NAME
|
||||
value: "log_center"
|
||||
- name: DB_USER
|
||||
valueFrom:
|
||||
secretKeyRef:
|
||||
name: log-center-secrets
|
||||
key: db-user
|
||||
- name: DB_PASSWORD
|
||||
valueFrom:
|
||||
secretKeyRef:
|
||||
name: log-center-secrets
|
||||
key: db-password
|
||||
resources:
|
||||
requests:
|
||||
memory: "128Mi"
|
||||
cpu: "100m"
|
||||
limits:
|
||||
memory: "512Mi"
|
||||
cpu: "500m"
|
||||
---
|
||||
apiVersion: v1
|
||||
kind: Service
|
||||
metadata:
|
||||
name: log-center-api
|
||||
spec:
|
||||
selector:
|
||||
app: log-center-api
|
||||
ports:
|
||||
- protocol: TCP
|
||||
port: 8002
|
||||
targetPort: 8002
|
||||
36
k8s/ingress.yaml
Normal file
36
k8s/ingress.yaml
Normal file
@ -0,0 +1,36 @@
|
||||
apiVersion: networking.k8s.io/v1
|
||||
kind: Ingress
|
||||
metadata:
|
||||
name: log-center-ingress
|
||||
annotations:
|
||||
kubernetes.io/ingress.class: "traefik"
|
||||
cert-manager.io/cluster-issuer: "letsencrypt-prod"
|
||||
spec:
|
||||
tls:
|
||||
- hosts:
|
||||
- qiyuan-log-center-api.airlabs.art
|
||||
- qiyuan-log-center-web.airlabs.art
|
||||
secretName: log-center-tls
|
||||
rules:
|
||||
# API Service
|
||||
- host: qiyuan-log-center-api.airlabs.art
|
||||
http:
|
||||
paths:
|
||||
- path: /
|
||||
pathType: Prefix
|
||||
backend:
|
||||
service:
|
||||
name: log-center-api
|
||||
port:
|
||||
number: 8002
|
||||
# Web Dashboard
|
||||
- host: qiyuan-log-center-web.airlabs.art
|
||||
http:
|
||||
paths:
|
||||
- path: /
|
||||
pathType: Prefix
|
||||
backend:
|
||||
service:
|
||||
name: log-center-web
|
||||
port:
|
||||
number: 80
|
||||
19
k8s/secrets.yaml
Normal file
19
k8s/secrets.yaml
Normal file
@ -0,0 +1,19 @@
|
||||
# K8s Secret for Log Center database credentials
|
||||
# Apply this ONCE before first deployment:
|
||||
# kubectl apply -f k8s/secrets.yaml
|
||||
#
|
||||
# You can also create it with kubectl directly:
|
||||
# kubectl create secret generic log-center-secrets \
|
||||
# --from-literal=db-host=pgm-7xv4811oj11j86htzo.pg.rds.aliyuncs.com \
|
||||
# --from-literal=db-user=log_center \
|
||||
# --from-literal=db-password=JogNQdtrd3WY8CBCAiYfYEGx
|
||||
|
||||
apiVersion: v1
|
||||
kind: Secret
|
||||
metadata:
|
||||
name: log-center-secrets
|
||||
type: Opaque
|
||||
stringData:
|
||||
db-host: pgm-7xv4811oj11j86htzo.pg.rds.aliyuncs.com
|
||||
db-user: log_center
|
||||
db-password: JogNQdtrd3WY8CBCAiYfYEGx
|
||||
41
k8s/web-deployment-prod.yaml
Normal file
41
k8s/web-deployment-prod.yaml
Normal file
@ -0,0 +1,41 @@
|
||||
apiVersion: apps/v1
|
||||
kind: Deployment
|
||||
metadata:
|
||||
name: log-center-web
|
||||
labels:
|
||||
app: log-center-web
|
||||
spec:
|
||||
replicas: 1
|
||||
selector:
|
||||
matchLabels:
|
||||
app: log-center-web
|
||||
template:
|
||||
metadata:
|
||||
labels:
|
||||
app: log-center-web
|
||||
spec:
|
||||
containers:
|
||||
- name: log-center-web
|
||||
image: ${CI_REGISTRY_IMAGE}/log-center-web:latest
|
||||
imagePullPolicy: Always
|
||||
ports:
|
||||
- containerPort: 80
|
||||
resources:
|
||||
requests:
|
||||
memory: "64Mi"
|
||||
cpu: "100m"
|
||||
limits:
|
||||
memory: "256Mi"
|
||||
cpu: "250m"
|
||||
---
|
||||
apiVersion: v1
|
||||
kind: Service
|
||||
metadata:
|
||||
name: log-center-web
|
||||
spec:
|
||||
selector:
|
||||
app: log-center-web
|
||||
ports:
|
||||
- protocol: TCP
|
||||
port: 80
|
||||
targetPort: 80
|
||||
6
requirements.txt
Normal file
6
requirements.txt
Normal file
@ -0,0 +1,6 @@
|
||||
fastapi
|
||||
uvicorn[standard]
|
||||
sqlmodel
|
||||
psycopg2-binary
|
||||
asyncpg
|
||||
python-dotenv
|
||||
4
run.sh
Executable file
4
run.sh
Executable file
@ -0,0 +1,4 @@
|
||||
#!/bin/bash
|
||||
export PYTHONPATH=$PYTHONPATH:$(pwd)
|
||||
echo "Starting Log Center on port 8002..."
|
||||
uvicorn app.main:app --host 0.0.0.0 --port 8002 --reload
|
||||
13
run_web.sh
Executable file
13
run_web.sh
Executable file
@ -0,0 +1,13 @@
|
||||
#!/bin/bash
|
||||
# Log Center Web Dashboard - Start Script
|
||||
|
||||
echo "============================================"
|
||||
echo " 📊 Log Center Dashboard"
|
||||
echo "============================================"
|
||||
echo ""
|
||||
echo "Starting frontend on http://localhost:8003"
|
||||
echo "Make sure backend is running on http://localhost:8002"
|
||||
echo ""
|
||||
|
||||
cd /Users/maidong/Desktop/zyc/qy_gitlab/log_center/web
|
||||
npm run dev
|
||||
52
test_db.py
Normal file
52
test_db.py
Normal file
@ -0,0 +1,52 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Quick script to test PostgreSQL connection"""
|
||||
import os
|
||||
from dotenv import load_dotenv
|
||||
|
||||
load_dotenv()
|
||||
|
||||
DB_USER = os.getenv("DB_USER")
|
||||
DB_PASSWORD = os.getenv("DB_PASSWORD")
|
||||
DB_HOST = os.getenv("DB_HOST")
|
||||
DB_PORT = os.getenv("DB_PORT", "5432")
|
||||
DB_NAME = os.getenv("DB_NAME")
|
||||
|
||||
print("=" * 50)
|
||||
print("Testing PostgreSQL Connection")
|
||||
print("=" * 50)
|
||||
print(f"Host: {DB_HOST}")
|
||||
print(f"Port: {DB_PORT}")
|
||||
print(f"Database: {DB_NAME}")
|
||||
print(f"User: {DB_USER}")
|
||||
print("=" * 50)
|
||||
|
||||
try:
|
||||
import psycopg2
|
||||
|
||||
conn = psycopg2.connect(
|
||||
host=DB_HOST,
|
||||
port=DB_PORT,
|
||||
database=DB_NAME,
|
||||
user=DB_USER,
|
||||
password=DB_PASSWORD,
|
||||
connect_timeout=10
|
||||
)
|
||||
|
||||
cursor = conn.cursor()
|
||||
cursor.execute("SELECT version();")
|
||||
version = cursor.fetchone()
|
||||
|
||||
print(f"✅ Connection Successful!")
|
||||
print(f"PostgreSQL Version: {version[0]}")
|
||||
|
||||
cursor.close()
|
||||
conn.close()
|
||||
|
||||
except Exception as e:
|
||||
print(f"❌ Connection Failed!")
|
||||
print(f"Error: {e}")
|
||||
print()
|
||||
print("Possible fixes:")
|
||||
print("1. Check if your IP is whitelisted in Aliyun RDS console")
|
||||
print("2. Verify DB_HOST is correct (use external/public endpoint)")
|
||||
print("3. Ensure the database 'log_center' exists")
|
||||
2
web/.env.example
Normal file
2
web/.env.example
Normal file
@ -0,0 +1,2 @@
|
||||
# API Base URL - 生产环境会在构建时注入
|
||||
VITE_API_BASE_URL=http://localhost:8002
|
||||
24
web/.gitignore
vendored
Normal file
24
web/.gitignore
vendored
Normal file
@ -0,0 +1,24 @@
|
||||
# Logs
|
||||
logs
|
||||
*.log
|
||||
npm-debug.log*
|
||||
yarn-debug.log*
|
||||
yarn-error.log*
|
||||
pnpm-debug.log*
|
||||
lerna-debug.log*
|
||||
|
||||
node_modules
|
||||
dist
|
||||
dist-ssr
|
||||
*.local
|
||||
|
||||
# Editor directories and files
|
||||
.vscode/*
|
||||
!.vscode/extensions.json
|
||||
.idea
|
||||
.DS_Store
|
||||
*.suo
|
||||
*.ntvs*
|
||||
*.njsproj
|
||||
*.sln
|
||||
*.sw?
|
||||
28
web/Dockerfile
Normal file
28
web/Dockerfile
Normal file
@ -0,0 +1,28 @@
|
||||
# Build stage
|
||||
FROM node:20-alpine AS build-stage
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
COPY package*.json ./
|
||||
|
||||
RUN npm install
|
||||
|
||||
COPY . .
|
||||
|
||||
# Build with production API URL
|
||||
ARG VITE_API_BASE_URL=https://qiyuan-log-center-api.airlabs.art
|
||||
ENV VITE_API_BASE_URL=$VITE_API_BASE_URL
|
||||
|
||||
RUN npm run build
|
||||
|
||||
# Production stage
|
||||
FROM nginx:stable-alpine AS production-stage
|
||||
|
||||
COPY --from=build-stage /app/dist /usr/share/nginx/html
|
||||
|
||||
# Copy custom nginx config
|
||||
COPY nginx.conf /etc/nginx/conf.d/default.conf
|
||||
|
||||
EXPOSE 80
|
||||
|
||||
CMD ["nginx", "-g", "daemon off;"]
|
||||
73
web/README.md
Normal file
73
web/README.md
Normal file
@ -0,0 +1,73 @@
|
||||
# React + TypeScript + Vite
|
||||
|
||||
This template provides a minimal setup to get React working in Vite with HMR and some ESLint rules.
|
||||
|
||||
Currently, two official plugins are available:
|
||||
|
||||
- [@vitejs/plugin-react](https://github.com/vitejs/vite-plugin-react/blob/main/packages/plugin-react) uses [Babel](https://babeljs.io/) (or [oxc](https://oxc.rs) when used in [rolldown-vite](https://vite.dev/guide/rolldown)) for Fast Refresh
|
||||
- [@vitejs/plugin-react-swc](https://github.com/vitejs/vite-plugin-react/blob/main/packages/plugin-react-swc) uses [SWC](https://swc.rs/) for Fast Refresh
|
||||
|
||||
## React Compiler
|
||||
|
||||
The React Compiler is not enabled on this template because of its impact on dev & build performances. To add it, see [this documentation](https://react.dev/learn/react-compiler/installation).
|
||||
|
||||
## Expanding the ESLint configuration
|
||||
|
||||
If you are developing a production application, we recommend updating the configuration to enable type-aware lint rules:
|
||||
|
||||
```js
|
||||
export default defineConfig([
|
||||
globalIgnores(['dist']),
|
||||
{
|
||||
files: ['**/*.{ts,tsx}'],
|
||||
extends: [
|
||||
// Other configs...
|
||||
|
||||
// Remove tseslint.configs.recommended and replace with this
|
||||
tseslint.configs.recommendedTypeChecked,
|
||||
// Alternatively, use this for stricter rules
|
||||
tseslint.configs.strictTypeChecked,
|
||||
// Optionally, add this for stylistic rules
|
||||
tseslint.configs.stylisticTypeChecked,
|
||||
|
||||
// Other configs...
|
||||
],
|
||||
languageOptions: {
|
||||
parserOptions: {
|
||||
project: ['./tsconfig.node.json', './tsconfig.app.json'],
|
||||
tsconfigRootDir: import.meta.dirname,
|
||||
},
|
||||
// other options...
|
||||
},
|
||||
},
|
||||
])
|
||||
```
|
||||
|
||||
You can also install [eslint-plugin-react-x](https://github.com/Rel1cx/eslint-react/tree/main/packages/plugins/eslint-plugin-react-x) and [eslint-plugin-react-dom](https://github.com/Rel1cx/eslint-react/tree/main/packages/plugins/eslint-plugin-react-dom) for React-specific lint rules:
|
||||
|
||||
```js
|
||||
// eslint.config.js
|
||||
import reactX from 'eslint-plugin-react-x'
|
||||
import reactDom from 'eslint-plugin-react-dom'
|
||||
|
||||
export default defineConfig([
|
||||
globalIgnores(['dist']),
|
||||
{
|
||||
files: ['**/*.{ts,tsx}'],
|
||||
extends: [
|
||||
// Other configs...
|
||||
// Enable lint rules for React
|
||||
reactX.configs['recommended-typescript'],
|
||||
// Enable lint rules for React DOM
|
||||
reactDom.configs.recommended,
|
||||
],
|
||||
languageOptions: {
|
||||
parserOptions: {
|
||||
project: ['./tsconfig.node.json', './tsconfig.app.json'],
|
||||
tsconfigRootDir: import.meta.dirname,
|
||||
},
|
||||
// other options...
|
||||
},
|
||||
},
|
||||
])
|
||||
```
|
||||
23
web/eslint.config.js
Normal file
23
web/eslint.config.js
Normal file
@ -0,0 +1,23 @@
|
||||
import js from '@eslint/js'
|
||||
import globals from 'globals'
|
||||
import reactHooks from 'eslint-plugin-react-hooks'
|
||||
import reactRefresh from 'eslint-plugin-react-refresh'
|
||||
import tseslint from 'typescript-eslint'
|
||||
import { defineConfig, globalIgnores } from 'eslint/config'
|
||||
|
||||
export default defineConfig([
|
||||
globalIgnores(['dist']),
|
||||
{
|
||||
files: ['**/*.{ts,tsx}'],
|
||||
extends: [
|
||||
js.configs.recommended,
|
||||
tseslint.configs.recommended,
|
||||
reactHooks.configs.flat.recommended,
|
||||
reactRefresh.configs.vite,
|
||||
],
|
||||
languageOptions: {
|
||||
ecmaVersion: 2020,
|
||||
globals: globals.browser,
|
||||
},
|
||||
},
|
||||
])
|
||||
13
web/index.html
Normal file
13
web/index.html
Normal file
@ -0,0 +1,13 @@
|
||||
<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<link rel="icon" type="image/svg+xml" href="/vite.svg" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>web</title>
|
||||
</head>
|
||||
<body>
|
||||
<div id="root"></div>
|
||||
<script type="module" src="/src/main.tsx"></script>
|
||||
</body>
|
||||
</html>
|
||||
23
web/nginx.conf
Normal file
23
web/nginx.conf
Normal file
@ -0,0 +1,23 @@
|
||||
server {
|
||||
listen 80;
|
||||
server_name localhost;
|
||||
|
||||
location / {
|
||||
root /usr/share/nginx/html;
|
||||
index index.html index.htm;
|
||||
# SPA 路由刷新 404 修复
|
||||
try_files $uri $uri/ /index.html;
|
||||
}
|
||||
|
||||
# 静态资源缓存
|
||||
location /assets/ {
|
||||
root /usr/share/nginx/html;
|
||||
expires 7d;
|
||||
add_header Cache-Control "public";
|
||||
}
|
||||
|
||||
error_page 500 502 503 504 /50x.html;
|
||||
location = /50x.html {
|
||||
root /usr/share/nginx/html;
|
||||
}
|
||||
}
|
||||
3595
web/package-lock.json
generated
Normal file
3595
web/package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
32
web/package.json
Normal file
32
web/package.json
Normal file
@ -0,0 +1,32 @@
|
||||
{
|
||||
"name": "web",
|
||||
"private": true,
|
||||
"version": "0.0.0",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
"build": "tsc -b && vite build",
|
||||
"lint": "eslint .",
|
||||
"preview": "vite preview"
|
||||
},
|
||||
"dependencies": {
|
||||
"axios": "^1.13.4",
|
||||
"react": "^19.2.0",
|
||||
"react-dom": "^19.2.0",
|
||||
"react-router-dom": "^7.13.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@eslint/js": "^9.39.1",
|
||||
"@types/node": "^24.10.1",
|
||||
"@types/react": "^19.2.5",
|
||||
"@types/react-dom": "^19.2.3",
|
||||
"@vitejs/plugin-react": "^5.1.1",
|
||||
"eslint": "^9.39.1",
|
||||
"eslint-plugin-react-hooks": "^7.0.1",
|
||||
"eslint-plugin-react-refresh": "^0.4.24",
|
||||
"globals": "^16.5.0",
|
||||
"typescript": "~5.9.3",
|
||||
"typescript-eslint": "^8.46.4",
|
||||
"vite": "^7.2.4"
|
||||
}
|
||||
}
|
||||
1
web/public/vite.svg
Normal file
1
web/public/vite.svg
Normal file
@ -0,0 +1 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" aria-hidden="true" role="img" class="iconify iconify--logos" width="31.88" height="32" preserveAspectRatio="xMidYMid meet" viewBox="0 0 256 257"><defs><linearGradient id="IconifyId1813088fe1fbc01fb466" x1="-.828%" x2="57.636%" y1="7.652%" y2="78.411%"><stop offset="0%" stop-color="#41D1FF"></stop><stop offset="100%" stop-color="#BD34FE"></stop></linearGradient><linearGradient id="IconifyId1813088fe1fbc01fb467" x1="43.376%" x2="50.316%" y1="2.242%" y2="89.03%"><stop offset="0%" stop-color="#FFEA83"></stop><stop offset="8.333%" stop-color="#FFDD35"></stop><stop offset="100%" stop-color="#FFA800"></stop></linearGradient></defs><path fill="url(#IconifyId1813088fe1fbc01fb466)" d="M255.153 37.938L134.897 252.976c-2.483 4.44-8.862 4.466-11.382.048L.875 37.958c-2.746-4.814 1.371-10.646 6.827-9.67l120.385 21.517a6.537 6.537 0 0 0 2.322-.004l117.867-21.483c5.438-.991 9.574 4.796 6.877 9.62Z"></path><path fill="url(#IconifyId1813088fe1fbc01fb467)" d="M185.432.063L96.44 17.501a3.268 3.268 0 0 0-2.634 3.014l-5.474 92.456a3.268 3.268 0 0 0 3.997 3.378l24.777-5.718c2.318-.535 4.413 1.507 3.936 3.838l-7.361 36.047c-.495 2.426 1.782 4.5 4.151 3.78l15.304-4.649c2.372-.72 4.652 1.36 4.15 3.788l-11.698 56.621c-.732 3.542 3.979 5.473 5.943 2.437l1.313-2.028l72.516-144.72c1.215-2.423-.88-5.186-3.54-4.672l-25.505 4.922c-2.396.462-4.435-1.77-3.759-4.114l16.646-57.705c.677-2.35-1.37-4.583-3.769-4.113Z"></path></svg>
|
||||
|
After Width: | Height: | Size: 1.5 KiB |
42
web/src/App.css
Normal file
42
web/src/App.css
Normal file
@ -0,0 +1,42 @@
|
||||
#root {
|
||||
max-width: 1280px;
|
||||
margin: 0 auto;
|
||||
padding: 2rem;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.logo {
|
||||
height: 6em;
|
||||
padding: 1.5em;
|
||||
will-change: filter;
|
||||
transition: filter 300ms;
|
||||
}
|
||||
.logo:hover {
|
||||
filter: drop-shadow(0 0 2em #646cffaa);
|
||||
}
|
||||
.logo.react:hover {
|
||||
filter: drop-shadow(0 0 2em #61dafbaa);
|
||||
}
|
||||
|
||||
@keyframes logo-spin {
|
||||
from {
|
||||
transform: rotate(0deg);
|
||||
}
|
||||
to {
|
||||
transform: rotate(360deg);
|
||||
}
|
||||
}
|
||||
|
||||
@media (prefers-reduced-motion: no-preference) {
|
||||
a:nth-of-type(2) .logo {
|
||||
animation: logo-spin infinite 20s linear;
|
||||
}
|
||||
}
|
||||
|
||||
.card {
|
||||
padding: 2em;
|
||||
}
|
||||
|
||||
.read-the-docs {
|
||||
color: #888;
|
||||
}
|
||||
50
web/src/App.tsx
Normal file
50
web/src/App.tsx
Normal file
@ -0,0 +1,50 @@
|
||||
import { BrowserRouter, Routes, Route, NavLink } from 'react-router-dom';
|
||||
import Dashboard from './pages/Dashboard';
|
||||
import BugList from './pages/BugList';
|
||||
import BugDetail from './pages/BugDetail';
|
||||
import './index.css';
|
||||
|
||||
function App() {
|
||||
return (
|
||||
<BrowserRouter>
|
||||
<div className="app">
|
||||
<aside className="sidebar">
|
||||
<div className="logo">
|
||||
🛡️ <span>Log Center</span>
|
||||
</div>
|
||||
<nav>
|
||||
<ul className="nav-menu">
|
||||
<li className="nav-item">
|
||||
<NavLink
|
||||
to="/"
|
||||
className={({ isActive }) => `nav-link ${isActive ? 'active' : ''}`}
|
||||
end
|
||||
>
|
||||
📊 Dashboard
|
||||
</NavLink>
|
||||
</li>
|
||||
<li className="nav-item">
|
||||
<NavLink
|
||||
to="/bugs"
|
||||
className={({ isActive }) => `nav-link ${isActive ? 'active' : ''}`}
|
||||
>
|
||||
🐛 Bug List
|
||||
</NavLink>
|
||||
</li>
|
||||
</ul>
|
||||
</nav>
|
||||
</aside>
|
||||
|
||||
<main className="main-content">
|
||||
<Routes>
|
||||
<Route path="/" element={<Dashboard />} />
|
||||
<Route path="/bugs" element={<BugList />} />
|
||||
<Route path="/bugs/:id" element={<BugDetail />} />
|
||||
</Routes>
|
||||
</main>
|
||||
</div>
|
||||
</BrowserRouter>
|
||||
);
|
||||
}
|
||||
|
||||
export default App;
|
||||
62
web/src/api.ts
Normal file
62
web/src/api.ts
Normal file
@ -0,0 +1,62 @@
|
||||
import axios from 'axios';
|
||||
|
||||
const API_BASE = import.meta.env.VITE_API_BASE_URL || 'http://localhost:8002';
|
||||
|
||||
const api = axios.create({
|
||||
baseURL: API_BASE,
|
||||
timeout: 10000,
|
||||
});
|
||||
|
||||
// Types
|
||||
export interface ErrorLog {
|
||||
id: number;
|
||||
project_id: string;
|
||||
environment: string;
|
||||
level: string;
|
||||
error_type: string;
|
||||
error_message: string;
|
||||
file_path: string;
|
||||
line_number: number;
|
||||
stack_trace: string;
|
||||
context: Record<string, any>;
|
||||
version?: string;
|
||||
commit_hash?: string;
|
||||
timestamp: string;
|
||||
fingerprint: string;
|
||||
status: string;
|
||||
retry_count: number;
|
||||
}
|
||||
|
||||
export interface DashboardStats {
|
||||
total_bugs: number;
|
||||
today_bugs: number;
|
||||
fix_rate: number;
|
||||
status_distribution: Record<string, number>;
|
||||
}
|
||||
|
||||
export interface PaginatedResponse<T> {
|
||||
items: T[];
|
||||
total: number;
|
||||
page: number;
|
||||
page_size: number;
|
||||
total_pages: number;
|
||||
}
|
||||
|
||||
// API Functions
|
||||
export const getStats = () => api.get<DashboardStats>('/api/v1/dashboard/stats');
|
||||
|
||||
export const getBugs = (params: {
|
||||
page?: number;
|
||||
page_size?: number;
|
||||
status?: string;
|
||||
project_id?: string;
|
||||
}) => api.get<PaginatedResponse<ErrorLog>>('/api/v1/bugs', { params });
|
||||
|
||||
export const getBugDetail = (id: number) => api.get<ErrorLog>(`/api/v1/bugs/${id}`);
|
||||
|
||||
export const getProjects = () => api.get<{ projects: string[] }>('/api/v1/projects');
|
||||
|
||||
export const updateTaskStatus = (taskId: number, status: string) =>
|
||||
api.patch(`/api/v1/tasks/${taskId}/status`, null, { params: { status } });
|
||||
|
||||
export default api;
|
||||
1
web/src/assets/react.svg
Normal file
1
web/src/assets/react.svg
Normal file
@ -0,0 +1 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" aria-hidden="true" role="img" class="iconify iconify--logos" width="35.93" height="32" preserveAspectRatio="xMidYMid meet" viewBox="0 0 256 228"><path fill="#00D8FF" d="M210.483 73.824a171.49 171.49 0 0 0-8.24-2.597c.465-1.9.893-3.777 1.273-5.621c6.238-30.281 2.16-54.676-11.769-62.708c-13.355-7.7-35.196.329-57.254 19.526a171.23 171.23 0 0 0-6.375 5.848a155.866 155.866 0 0 0-4.241-3.917C100.759 3.829 77.587-4.822 63.673 3.233C50.33 10.957 46.379 33.89 51.995 62.588a170.974 170.974 0 0 0 1.892 8.48c-3.28.932-6.445 1.924-9.474 2.98C17.309 83.498 0 98.307 0 113.668c0 15.865 18.582 31.778 46.812 41.427a145.52 145.52 0 0 0 6.921 2.165a167.467 167.467 0 0 0-2.01 9.138c-5.354 28.2-1.173 50.591 12.134 58.266c13.744 7.926 36.812-.22 59.273-19.855a145.567 145.567 0 0 0 5.342-4.923a168.064 168.064 0 0 0 6.92 6.314c21.758 18.722 43.246 26.282 56.54 18.586c13.731-7.949 18.194-32.003 12.4-61.268a145.016 145.016 0 0 0-1.535-6.842c1.62-.48 3.21-.974 4.76-1.488c29.348-9.723 48.443-25.443 48.443-41.52c0-15.417-17.868-30.326-45.517-39.844Zm-6.365 70.984c-1.4.463-2.836.91-4.3 1.345c-3.24-10.257-7.612-21.163-12.963-32.432c5.106-11 9.31-21.767 12.459-31.957c2.619.758 5.16 1.557 7.61 2.4c23.69 8.156 38.14 20.213 38.14 29.504c0 9.896-15.606 22.743-40.946 31.14Zm-10.514 20.834c2.562 12.94 2.927 24.64 1.23 33.787c-1.524 8.219-4.59 13.698-8.382 15.893c-8.067 4.67-25.32-1.4-43.927-17.412a156.726 156.726 0 0 1-6.437-5.87c7.214-7.889 14.423-17.06 21.459-27.246c12.376-1.098 24.068-2.894 34.671-5.345a134.17 134.17 0 0 1 1.386 6.193ZM87.276 214.515c-7.882 2.783-14.16 2.863-17.955.675c-8.075-4.657-11.432-22.636-6.853-46.752a156.923 156.923 0 0 1 1.869-8.499c10.486 2.32 22.093 3.988 34.498 4.994c7.084 9.967 14.501 19.128 21.976 27.15a134.668 134.668 0 0 1-4.877 4.492c-9.933 8.682-19.886 14.842-28.658 17.94ZM50.35 144.747c-12.483-4.267-22.792-9.812-29.858-15.863c-6.35-5.437-9.555-10.836-9.555-15.216c0-9.322 13.897-21.212 37.076-29.293c2.813-.98 5.757-1.905 8.812-2.773c3.204 10.42 7.406 21.315 12.477 32.332c-5.137 11.18-9.399 22.249-12.634 32.792a134.718 134.718 0 0 1-6.318-1.979Zm12.378-84.26c-4.811-24.587-1.616-43.134 6.425-47.789c8.564-4.958 27.502 2.111 47.463 19.835a144.318 144.318 0 0 1 3.841 3.545c-7.438 7.987-14.787 17.08-21.808 26.988c-12.04 1.116-23.565 2.908-34.161 5.309a160.342 160.342 0 0 1-1.76-7.887Zm110.427 27.268a347.8 347.8 0 0 0-7.785-12.803c8.168 1.033 15.994 2.404 23.343 4.08c-2.206 7.072-4.956 14.465-8.193 22.045a381.151 381.151 0 0 0-7.365-13.322Zm-45.032-43.861c5.044 5.465 10.096 11.566 15.065 18.186a322.04 322.04 0 0 0-30.257-.006c4.974-6.559 10.069-12.652 15.192-18.18ZM82.802 87.83a323.167 323.167 0 0 0-7.227 13.238c-3.184-7.553-5.909-14.98-8.134-22.152c7.304-1.634 15.093-2.97 23.209-3.984a321.524 321.524 0 0 0-7.848 12.897Zm8.081 65.352c-8.385-.936-16.291-2.203-23.593-3.793c2.26-7.3 5.045-14.885 8.298-22.6a321.187 321.187 0 0 0 7.257 13.246c2.594 4.48 5.28 8.868 8.038 13.147Zm37.542 31.03c-5.184-5.592-10.354-11.779-15.403-18.433c4.902.192 9.899.29 14.978.29c5.218 0 10.376-.117 15.453-.343c-4.985 6.774-10.018 12.97-15.028 18.486Zm52.198-57.817c3.422 7.8 6.306 15.345 8.596 22.52c-7.422 1.694-15.436 3.058-23.88 4.071a382.417 382.417 0 0 0 7.859-13.026a347.403 347.403 0 0 0 7.425-13.565Zm-16.898 8.101a358.557 358.557 0 0 1-12.281 19.815a329.4 329.4 0 0 1-23.444.823c-7.967 0-15.716-.248-23.178-.732a310.202 310.202 0 0 1-12.513-19.846h.001a307.41 307.41 0 0 1-10.923-20.627a310.278 310.278 0 0 1 10.89-20.637l-.001.001a307.318 307.318 0 0 1 12.413-19.761c7.613-.576 15.42-.876 23.31-.876H128c7.926 0 15.743.303 23.354.883a329.357 329.357 0 0 1 12.335 19.695a358.489 358.489 0 0 1 11.036 20.54a329.472 329.472 0 0 1-11 20.722Zm22.56-122.124c8.572 4.944 11.906 24.881 6.52 51.026c-.344 1.668-.73 3.367-1.15 5.09c-10.622-2.452-22.155-4.275-34.23-5.408c-7.034-10.017-14.323-19.124-21.64-27.008a160.789 160.789 0 0 1 5.888-5.4c18.9-16.447 36.564-22.941 44.612-18.3ZM128 90.808c12.625 0 22.86 10.235 22.86 22.86s-10.235 22.86-22.86 22.86s-22.86-10.235-22.86-22.86s10.235-22.86 22.86-22.86Z"></path></svg>
|
||||
|
After Width: | Height: | Size: 4.0 KiB |
379
web/src/index.css
Normal file
379
web/src/index.css
Normal file
@ -0,0 +1,379 @@
|
||||
:root {
|
||||
--bg-primary: #0f0f23;
|
||||
--bg-secondary: #1a1a2e;
|
||||
--bg-card: #16213e;
|
||||
--accent: #00d4ff;
|
||||
--accent-secondary: #7b2cbf;
|
||||
--text-primary: #ffffff;
|
||||
--text-secondary: #a0a0b0;
|
||||
--success: #00e676;
|
||||
--warning: #ffab00;
|
||||
--error: #ff5252;
|
||||
--border: #2a2a4a;
|
||||
}
|
||||
|
||||
* {
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
body {
|
||||
font-family: 'Inter', -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
|
||||
background: var(--bg-primary);
|
||||
color: var(--text-primary);
|
||||
line-height: 1.6;
|
||||
}
|
||||
|
||||
.app {
|
||||
display: flex;
|
||||
min-height: 100vh;
|
||||
}
|
||||
|
||||
/* Sidebar */
|
||||
.sidebar {
|
||||
width: 260px;
|
||||
background: var(--bg-secondary);
|
||||
border-right: 1px solid var(--border);
|
||||
padding: 24px 16px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.logo {
|
||||
font-size: 24px;
|
||||
font-weight: 700;
|
||||
color: var(--accent);
|
||||
margin-bottom: 40px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.logo span {
|
||||
background: linear-gradient(135deg, var(--accent), var(--accent-secondary));
|
||||
background-clip: text;
|
||||
-webkit-background-clip: text;
|
||||
-webkit-text-fill-color: transparent;
|
||||
}
|
||||
|
||||
.nav-menu {
|
||||
list-style: none;
|
||||
}
|
||||
|
||||
.nav-item {
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
|
||||
.nav-link {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
padding: 12px 16px;
|
||||
border-radius: 8px;
|
||||
color: var(--text-secondary);
|
||||
text-decoration: none;
|
||||
transition: all 0.2s ease;
|
||||
}
|
||||
|
||||
.nav-link:hover,
|
||||
.nav-link.active {
|
||||
background: rgba(0, 212, 255, 0.1);
|
||||
color: var(--accent);
|
||||
}
|
||||
|
||||
/* Main Content */
|
||||
.main-content {
|
||||
flex: 1;
|
||||
padding: 32px;
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
.page-header {
|
||||
margin-bottom: 32px;
|
||||
}
|
||||
|
||||
.page-title {
|
||||
font-size: 28px;
|
||||
font-weight: 600;
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
|
||||
.page-subtitle {
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
|
||||
/* Stats Cards */
|
||||
.stats-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fit, minmax(240px, 1fr));
|
||||
gap: 24px;
|
||||
margin-bottom: 32px;
|
||||
}
|
||||
|
||||
.stat-card {
|
||||
background: var(--bg-card);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 16px;
|
||||
padding: 24px;
|
||||
transition: transform 0.2s ease, box-shadow 0.2s ease;
|
||||
}
|
||||
|
||||
.stat-card:hover {
|
||||
transform: translateY(-4px);
|
||||
box-shadow: 0 8px 24px rgba(0, 212, 255, 0.1);
|
||||
}
|
||||
|
||||
.stat-label {
|
||||
font-size: 14px;
|
||||
color: var(--text-secondary);
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
|
||||
.stat-value {
|
||||
font-size: 36px;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.stat-value.accent {
|
||||
color: var(--accent);
|
||||
}
|
||||
|
||||
.stat-value.success {
|
||||
color: var(--success);
|
||||
}
|
||||
|
||||
.stat-value.warning {
|
||||
color: var(--warning);
|
||||
}
|
||||
|
||||
.stat-value.error {
|
||||
color: var(--error);
|
||||
}
|
||||
|
||||
/* Table */
|
||||
.table-container {
|
||||
background: var(--bg-card);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 16px;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.table-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
padding: 20px 24px;
|
||||
border-bottom: 1px solid var(--border);
|
||||
}
|
||||
|
||||
.table-title {
|
||||
font-size: 18px;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
table {
|
||||
width: 100%;
|
||||
border-collapse: collapse;
|
||||
}
|
||||
|
||||
th,
|
||||
td {
|
||||
padding: 16px 24px;
|
||||
text-align: left;
|
||||
}
|
||||
|
||||
th {
|
||||
background: rgba(0, 0, 0, 0.2);
|
||||
color: var(--text-secondary);
|
||||
font-weight: 500;
|
||||
font-size: 13px;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.5px;
|
||||
}
|
||||
|
||||
tr {
|
||||
border-bottom: 1px solid var(--border);
|
||||
transition: background 0.2s ease;
|
||||
}
|
||||
|
||||
tr:hover {
|
||||
background: rgba(0, 212, 255, 0.05);
|
||||
}
|
||||
|
||||
/* Status Badge */
|
||||
.status-badge {
|
||||
display: inline-block;
|
||||
padding: 4px 12px;
|
||||
border-radius: 20px;
|
||||
font-size: 12px;
|
||||
font-weight: 600;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
.status-NEW {
|
||||
background: rgba(0, 212, 255, 0.2);
|
||||
color: var(--accent);
|
||||
}
|
||||
|
||||
.status-PENDING_FIX {
|
||||
background: rgba(255, 171, 0, 0.2);
|
||||
color: var(--warning);
|
||||
}
|
||||
|
||||
.status-FIXING {
|
||||
background: rgba(123, 44, 191, 0.2);
|
||||
color: var(--accent-secondary);
|
||||
}
|
||||
|
||||
.status-FIXED {
|
||||
background: rgba(0, 230, 118, 0.2);
|
||||
color: var(--success);
|
||||
}
|
||||
|
||||
.status-VERIFIED {
|
||||
background: rgba(0, 230, 118, 0.3);
|
||||
color: var(--success);
|
||||
}
|
||||
|
||||
.status-DEPLOYED {
|
||||
background: rgba(0, 230, 118, 0.4);
|
||||
color: var(--success);
|
||||
}
|
||||
|
||||
.status-FIX_FAILED {
|
||||
background: rgba(255, 82, 82, 0.2);
|
||||
color: var(--error);
|
||||
}
|
||||
|
||||
.status-CANNOT_REPRODUCE {
|
||||
background: rgba(160, 160, 176, 0.2);
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
|
||||
/* Filters */
|
||||
.filters {
|
||||
display: flex;
|
||||
gap: 16px;
|
||||
margin-bottom: 24px;
|
||||
}
|
||||
|
||||
.filter-select {
|
||||
background: var(--bg-card);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 8px;
|
||||
padding: 10px 16px;
|
||||
color: var(--text-primary);
|
||||
font-size: 14px;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.filter-select:focus {
|
||||
outline: none;
|
||||
border-color: var(--accent);
|
||||
}
|
||||
|
||||
/* Bug Detail */
|
||||
.detail-card {
|
||||
background: var(--bg-card);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 16px;
|
||||
padding: 24px;
|
||||
margin-bottom: 24px;
|
||||
}
|
||||
|
||||
.detail-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: flex-start;
|
||||
margin-bottom: 24px;
|
||||
}
|
||||
|
||||
.detail-title {
|
||||
font-size: 20px;
|
||||
font-weight: 600;
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
|
||||
.detail-meta {
|
||||
display: flex;
|
||||
gap: 16px;
|
||||
color: var(--text-secondary);
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
.stack-trace {
|
||||
background: var(--bg-primary);
|
||||
border-radius: 8px;
|
||||
padding: 16px;
|
||||
font-family: 'Fira Code', monospace;
|
||||
font-size: 13px;
|
||||
overflow-x: auto;
|
||||
white-space: pre-wrap;
|
||||
color: var(--error);
|
||||
}
|
||||
|
||||
.back-link {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
color: var(--accent);
|
||||
text-decoration: none;
|
||||
margin-bottom: 24px;
|
||||
}
|
||||
|
||||
.back-link:hover {
|
||||
text-decoration: underline;
|
||||
}
|
||||
|
||||
/* Pagination */
|
||||
.pagination {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
gap: 8px;
|
||||
padding: 20px;
|
||||
}
|
||||
|
||||
.pagination button {
|
||||
background: var(--bg-secondary);
|
||||
border: 1px solid var(--border);
|
||||
color: var(--text-primary);
|
||||
padding: 8px 16px;
|
||||
border-radius: 6px;
|
||||
cursor: pointer;
|
||||
transition: all 0.2s ease;
|
||||
}
|
||||
|
||||
.pagination button:hover:not(:disabled) {
|
||||
background: var(--accent);
|
||||
border-color: var(--accent);
|
||||
}
|
||||
|
||||
.pagination button:disabled {
|
||||
opacity: 0.5;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
/* Loading */
|
||||
.loading {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
height: 200px;
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
|
||||
.spinner {
|
||||
width: 40px;
|
||||
height: 40px;
|
||||
border: 3px solid var(--border);
|
||||
border-top-color: var(--accent);
|
||||
border-radius: 50%;
|
||||
animation: spin 1s linear infinite;
|
||||
}
|
||||
|
||||
@keyframes spin {
|
||||
to {
|
||||
transform: rotate(360deg);
|
||||
}
|
||||
}
|
||||
10
web/src/main.tsx
Normal file
10
web/src/main.tsx
Normal file
@ -0,0 +1,10 @@
|
||||
import { StrictMode } from 'react'
|
||||
import { createRoot } from 'react-dom/client'
|
||||
import './index.css'
|
||||
import App from './App.tsx'
|
||||
|
||||
createRoot(document.getElementById('root')!).render(
|
||||
<StrictMode>
|
||||
<App />
|
||||
</StrictMode>,
|
||||
)
|
||||
119
web/src/pages/BugDetail.tsx
Normal file
119
web/src/pages/BugDetail.tsx
Normal file
@ -0,0 +1,119 @@
|
||||
import { useState, useEffect } from 'react';
|
||||
import { useParams, Link } from 'react-router-dom';
|
||||
import { getBugDetail, type ErrorLog } from '../api';
|
||||
|
||||
export default function BugDetail() {
|
||||
const { id } = useParams<{ id: string }>();
|
||||
const [bug, setBug] = useState<ErrorLog | null>(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
|
||||
useEffect(() => {
|
||||
const fetchBug = async () => {
|
||||
if (!id) return;
|
||||
try {
|
||||
const response = await getBugDetail(parseInt(id));
|
||||
setBug(response.data);
|
||||
} catch (error) {
|
||||
console.error('Failed to fetch bug:', error);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
fetchBug();
|
||||
}, [id]);
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<div className="loading">
|
||||
<div className="spinner"></div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (!bug) {
|
||||
return <div className="loading">Bug not found</div>;
|
||||
}
|
||||
|
||||
return (
|
||||
<div>
|
||||
<Link to="/bugs" className="back-link">
|
||||
← Back to Bug List
|
||||
</Link>
|
||||
|
||||
<div className="detail-card">
|
||||
<div className="detail-header">
|
||||
<div>
|
||||
<h2 className="detail-title" style={{ color: 'var(--error)' }}>
|
||||
{bug.error_type}: {bug.error_message}
|
||||
</h2>
|
||||
<div className="detail-meta">
|
||||
<span>Project: {bug.project_id}</span>
|
||||
<span>Environment: {bug.environment}</span>
|
||||
<span>Level: {bug.level}</span>
|
||||
</div>
|
||||
</div>
|
||||
<span className={`status-badge status-${bug.status}`}>{bug.status}</span>
|
||||
</div>
|
||||
|
||||
<div style={{ marginBottom: '24px' }}>
|
||||
<h4 style={{ marginBottom: '12px', color: 'var(--text-secondary)' }}>Location</h4>
|
||||
<p style={{ fontFamily: 'monospace', fontSize: '14px' }}>
|
||||
📁 {bug.file_path} : Line {bug.line_number}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{bug.commit_hash && (
|
||||
<div style={{ marginBottom: '24px' }}>
|
||||
<h4 style={{ marginBottom: '12px', color: 'var(--text-secondary)' }}>Git Info</h4>
|
||||
<p style={{ fontFamily: 'monospace', fontSize: '14px' }}>
|
||||
Commit: {bug.commit_hash}
|
||||
{bug.version && ` | Version: ${bug.version}`}
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div style={{ marginBottom: '24px' }}>
|
||||
<h4 style={{ marginBottom: '12px', color: 'var(--text-secondary)' }}>Stack Trace</h4>
|
||||
<pre className="stack-trace">
|
||||
{typeof bug.stack_trace === 'string'
|
||||
? bug.stack_trace
|
||||
: JSON.stringify(bug.stack_trace, null, 2)}
|
||||
</pre>
|
||||
</div>
|
||||
|
||||
{bug.context && Object.keys(bug.context).length > 0 && (
|
||||
<div>
|
||||
<h4 style={{ marginBottom: '12px', color: 'var(--text-secondary)' }}>Context</h4>
|
||||
<pre className="stack-trace" style={{ color: 'var(--accent)' }}>
|
||||
{JSON.stringify(bug.context, null, 2)}
|
||||
</pre>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="detail-card">
|
||||
<h4 style={{ marginBottom: '16px' }}>Metadata</h4>
|
||||
<table>
|
||||
<tbody>
|
||||
<tr>
|
||||
<td style={{ color: 'var(--text-secondary)' }}>Bug ID</td>
|
||||
<td>{bug.id}</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td style={{ color: 'var(--text-secondary)' }}>Fingerprint</td>
|
||||
<td style={{ fontFamily: 'monospace', fontSize: '13px' }}>{bug.fingerprint}</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td style={{ color: 'var(--text-secondary)' }}>Retry Count</td>
|
||||
<td>{bug.retry_count}</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td style={{ color: 'var(--text-secondary)' }}>Reported At</td>
|
||||
<td>{new Date(bug.timestamp).toLocaleString()}</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
148
web/src/pages/BugList.tsx
Normal file
148
web/src/pages/BugList.tsx
Normal file
@ -0,0 +1,148 @@
|
||||
import { useState, useEffect } from 'react';
|
||||
import { Link } from 'react-router-dom';
|
||||
import { getBugs, getProjects, type ErrorLog } from '../api';
|
||||
|
||||
export default function BugList() {
|
||||
const [bugs, setBugs] = useState<ErrorLog[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [page, setPage] = useState(1);
|
||||
const [totalPages, setTotalPages] = useState(1);
|
||||
const [statusFilter, setStatusFilter] = useState('');
|
||||
const [projectFilter, setProjectFilter] = useState('');
|
||||
const [projects, setProjects] = useState<string[]>([]);
|
||||
|
||||
useEffect(() => {
|
||||
const fetchProjects = async () => {
|
||||
try {
|
||||
const response = await getProjects();
|
||||
setProjects(response.data.projects);
|
||||
} catch (error) {
|
||||
console.error('Failed to fetch projects:', error);
|
||||
}
|
||||
};
|
||||
fetchProjects();
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
const fetchBugs = async () => {
|
||||
setLoading(true);
|
||||
try {
|
||||
const params: any = { page, page_size: 20 };
|
||||
if (statusFilter) params.status = statusFilter;
|
||||
if (projectFilter) params.project_id = projectFilter;
|
||||
|
||||
const response = await getBugs(params);
|
||||
setBugs(response.data.items);
|
||||
setTotalPages(response.data.total_pages);
|
||||
} catch (error) {
|
||||
console.error('Failed to fetch bugs:', error);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
fetchBugs();
|
||||
}, [page, statusFilter, projectFilter]);
|
||||
|
||||
const statuses = [
|
||||
'NEW', 'VERIFYING', 'CANNOT_REPRODUCE', 'PENDING_FIX',
|
||||
'FIXING', 'FIXED', 'VERIFIED', 'DEPLOYED', 'FIX_FAILED'
|
||||
];
|
||||
|
||||
return (
|
||||
<div>
|
||||
<div className="page-header">
|
||||
<h1 className="page-title">Bug List</h1>
|
||||
<p className="page-subtitle">All reported errors and their current status</p>
|
||||
</div>
|
||||
|
||||
<div className="filters">
|
||||
<select
|
||||
className="filter-select"
|
||||
value={statusFilter}
|
||||
onChange={(e) => { setStatusFilter(e.target.value); setPage(1); }}
|
||||
>
|
||||
<option value="">All Status</option>
|
||||
{statuses.map(s => (
|
||||
<option key={s} value={s}>{s}</option>
|
||||
))}
|
||||
</select>
|
||||
|
||||
<select
|
||||
className="filter-select"
|
||||
value={projectFilter}
|
||||
onChange={(e) => { setProjectFilter(e.target.value); setPage(1); }}
|
||||
>
|
||||
<option value="">All Projects</option>
|
||||
{projects.map(p => (
|
||||
<option key={p} value={p}>{p}</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div className="table-container">
|
||||
{loading ? (
|
||||
<div className="loading">
|
||||
<div className="spinner"></div>
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
<table>
|
||||
<thead>
|
||||
<tr>
|
||||
<th>ID</th>
|
||||
<th>Project</th>
|
||||
<th>Error Type</th>
|
||||
<th>File</th>
|
||||
<th>Status</th>
|
||||
<th>Time</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{bugs.map(bug => (
|
||||
<tr key={bug.id}>
|
||||
<td>
|
||||
<Link to={`/bugs/${bug.id}`} style={{ color: 'var(--accent)' }}>
|
||||
#{bug.id}
|
||||
</Link>
|
||||
</td>
|
||||
<td>{bug.project_id}</td>
|
||||
<td style={{ color: 'var(--error)' }}>{bug.error_type}</td>
|
||||
<td style={{ fontFamily: 'monospace', fontSize: '13px' }}>
|
||||
{bug.file_path}:{bug.line_number}
|
||||
</td>
|
||||
<td>
|
||||
<span className={`status-badge status-${bug.status}`}>
|
||||
{bug.status}
|
||||
</span>
|
||||
</td>
|
||||
<td style={{ color: 'var(--text-secondary)', fontSize: '14px' }}>
|
||||
{new Date(bug.timestamp).toLocaleString()}
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
|
||||
<div className="pagination">
|
||||
<button
|
||||
onClick={() => setPage(p => Math.max(1, p - 1))}
|
||||
disabled={page === 1}
|
||||
>
|
||||
Previous
|
||||
</button>
|
||||
<span style={{ padding: '8px 16px', color: 'var(--text-secondary)' }}>
|
||||
Page {page} of {totalPages}
|
||||
</span>
|
||||
<button
|
||||
onClick={() => setPage(p => Math.min(totalPages, p + 1))}
|
||||
disabled={page === totalPages}
|
||||
>
|
||||
Next
|
||||
</button>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
88
web/src/pages/Dashboard.tsx
Normal file
88
web/src/pages/Dashboard.tsx
Normal file
@ -0,0 +1,88 @@
|
||||
import { useState, useEffect } from 'react';
|
||||
import { getStats, type DashboardStats } from '../api';
|
||||
|
||||
export default function Dashboard() {
|
||||
const [stats, setStats] = useState<DashboardStats | null>(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
|
||||
useEffect(() => {
|
||||
const fetchStats = async () => {
|
||||
try {
|
||||
const response = await getStats();
|
||||
setStats(response.data);
|
||||
} catch (error) {
|
||||
console.error('Failed to fetch stats:', error);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
fetchStats();
|
||||
}, []);
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<div className="loading">
|
||||
<div className="spinner"></div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (!stats) {
|
||||
return <div className="loading">Failed to load statistics</div>;
|
||||
}
|
||||
|
||||
return (
|
||||
<div>
|
||||
<div className="page-header">
|
||||
<h1 className="page-title">Dashboard</h1>
|
||||
<p className="page-subtitle">Overview of your error tracking system</p>
|
||||
</div>
|
||||
|
||||
<div className="stats-grid">
|
||||
<div className="stat-card">
|
||||
<div className="stat-label">Total Bugs</div>
|
||||
<div className="stat-value accent">{stats.total_bugs}</div>
|
||||
</div>
|
||||
<div className="stat-card">
|
||||
<div className="stat-label">Today's New Bugs</div>
|
||||
<div className="stat-value warning">{stats.today_bugs}</div>
|
||||
</div>
|
||||
<div className="stat-card">
|
||||
<div className="stat-label">Fix Rate</div>
|
||||
<div className="stat-value success">{stats.fix_rate}%</div>
|
||||
</div>
|
||||
<div className="stat-card">
|
||||
<div className="stat-label">Pending Fix</div>
|
||||
<div className="stat-value error">
|
||||
{(stats.status_distribution['NEW'] || 0) +
|
||||
(stats.status_distribution['PENDING_FIX'] || 0)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="table-container">
|
||||
<div className="table-header">
|
||||
<h3 className="table-title">Status Distribution</h3>
|
||||
</div>
|
||||
<table>
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Status</th>
|
||||
<th>Count</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{Object.entries(stats.status_distribution).map(([status, count]) => (
|
||||
<tr key={status}>
|
||||
<td>
|
||||
<span className={`status-badge status-${status}`}>{status}</span>
|
||||
</td>
|
||||
<td>{count}</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
28
web/tsconfig.app.json
Normal file
28
web/tsconfig.app.json
Normal file
@ -0,0 +1,28 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"tsBuildInfoFile": "./node_modules/.tmp/tsconfig.app.tsbuildinfo",
|
||||
"target": "ES2022",
|
||||
"useDefineForClassFields": true,
|
||||
"lib": ["ES2022", "DOM", "DOM.Iterable"],
|
||||
"module": "ESNext",
|
||||
"types": ["vite/client"],
|
||||
"skipLibCheck": true,
|
||||
|
||||
/* Bundler mode */
|
||||
"moduleResolution": "bundler",
|
||||
"allowImportingTsExtensions": true,
|
||||
"verbatimModuleSyntax": true,
|
||||
"moduleDetection": "force",
|
||||
"noEmit": true,
|
||||
"jsx": "react-jsx",
|
||||
|
||||
/* Linting */
|
||||
"strict": true,
|
||||
"noUnusedLocals": true,
|
||||
"noUnusedParameters": true,
|
||||
"erasableSyntaxOnly": true,
|
||||
"noFallthroughCasesInSwitch": true,
|
||||
"noUncheckedSideEffectImports": true
|
||||
},
|
||||
"include": ["src"]
|
||||
}
|
||||
7
web/tsconfig.json
Normal file
7
web/tsconfig.json
Normal file
@ -0,0 +1,7 @@
|
||||
{
|
||||
"files": [],
|
||||
"references": [
|
||||
{ "path": "./tsconfig.app.json" },
|
||||
{ "path": "./tsconfig.node.json" }
|
||||
]
|
||||
}
|
||||
26
web/tsconfig.node.json
Normal file
26
web/tsconfig.node.json
Normal file
@ -0,0 +1,26 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"tsBuildInfoFile": "./node_modules/.tmp/tsconfig.node.tsbuildinfo",
|
||||
"target": "ES2023",
|
||||
"lib": ["ES2023"],
|
||||
"module": "ESNext",
|
||||
"types": ["node"],
|
||||
"skipLibCheck": true,
|
||||
|
||||
/* Bundler mode */
|
||||
"moduleResolution": "bundler",
|
||||
"allowImportingTsExtensions": true,
|
||||
"verbatimModuleSyntax": true,
|
||||
"moduleDetection": "force",
|
||||
"noEmit": true,
|
||||
|
||||
/* Linting */
|
||||
"strict": true,
|
||||
"noUnusedLocals": true,
|
||||
"noUnusedParameters": true,
|
||||
"erasableSyntaxOnly": true,
|
||||
"noFallthroughCasesInSwitch": true,
|
||||
"noUncheckedSideEffectImports": true
|
||||
},
|
||||
"include": ["vite.config.ts"]
|
||||
}
|
||||
10
web/vite.config.ts
Normal file
10
web/vite.config.ts
Normal file
@ -0,0 +1,10 @@
|
||||
import { defineConfig } from 'vite'
|
||||
import react from '@vitejs/plugin-react'
|
||||
|
||||
// https://vite.dev/config/
|
||||
export default defineConfig({
|
||||
plugins: [react()],
|
||||
server: {
|
||||
port: 8003,
|
||||
},
|
||||
})
|
||||
Loading…
x
Reference in New Issue
Block a user