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