feat: initial log center with k8s deployment
All checks were successful
Build and Deploy Log Center / build-and-deploy (push) Successful in 1m30s

This commit is contained in:
zyc 2026-01-30 11:49:47 +08:00
commit 637c479818
38 changed files with 5740 additions and 0 deletions

View 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
View 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
View 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
View 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
View 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
View 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
View 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)

View 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
View 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
View 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

View 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
View File

@ -0,0 +1,6 @@
fastapi
uvicorn[standard]
sqlmodel
psycopg2-binary
asyncpg
python-dotenv

4
run.sh Executable file
View 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
View 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
View 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
View File

@ -0,0 +1,2 @@
# API Base URL - 生产环境会在构建时注入
VITE_API_BASE_URL=http://localhost:8002

24
web/.gitignore vendored Normal file
View 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
View 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
View 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
View 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
View 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
View 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

File diff suppressed because it is too large Load Diff

32
web/package.json Normal file
View 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
View 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
View 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
View 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
View 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
View 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
View 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
View 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
View 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
View 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>
);
}

View 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
View 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
View File

@ -0,0 +1,7 @@
{
"files": [],
"references": [
{ "path": "./tsconfig.app.json" },
{ "path": "./tsconfig.node.json" }
]
}

26
web/tsconfig.node.json Normal file
View 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
View 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,
},
})