feat(project-mgmt): 项目管理 + 失败原因追踪 + 前端展示
All checks were successful
Build and Deploy Log Center / build-and-deploy (push) Successful in 2m16s
All checks were successful
Build and Deploy Log Center / build-and-deploy (push) Successful in 2m16s
- 新增 Project 模型(repo_url, local_path, name, description) - 项目 CRUD API(GET/PUT /api/v1/projects) - 日志上报自动 upsert Project 记录 - ErrorLog 增加 failure_reason 字段 - update_task_status / create_repair_report 写入失败原因 - Repair Agent 优先从 API 获取项目配置,回退 .env - 新增 Web 端「项目管理」页面(表格 + 行内编辑) - BugList/BugDetail/RepairList 展示失败原因 - 更新接入指南文档 Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
parent
229d86e158
commit
625e53dc44
@ -32,6 +32,8 @@ async def init_db():
|
|||||||
"ALTER TABLE errorlog ALTER COLUMN file_path DROP NOT NULL",
|
"ALTER TABLE errorlog ALTER COLUMN file_path DROP NOT NULL",
|
||||||
"ALTER TABLE errorlog ALTER COLUMN line_number DROP NOT NULL",
|
"ALTER TABLE errorlog ALTER COLUMN line_number DROP NOT NULL",
|
||||||
"CREATE INDEX IF NOT EXISTS ix_errorlog_source ON errorlog (source)",
|
"CREATE INDEX IF NOT EXISTS ix_errorlog_source ON errorlog (source)",
|
||||||
|
# ErrorLog failure_reason
|
||||||
|
"ALTER TABLE errorlog ADD COLUMN IF NOT EXISTS failure_reason TEXT",
|
||||||
]
|
]
|
||||||
for sql in migrations:
|
for sql in migrations:
|
||||||
try:
|
try:
|
||||||
|
|||||||
62
app/main.py
62
app/main.py
@ -3,7 +3,7 @@ from fastapi.middleware.cors import CORSMiddleware
|
|||||||
from sqlmodel.ext.asyncio.session import AsyncSession
|
from sqlmodel.ext.asyncio.session import AsyncSession
|
||||||
from sqlmodel import select, func
|
from sqlmodel import select, func
|
||||||
from .database import init_db, get_session
|
from .database import init_db, get_session
|
||||||
from .models import ErrorLog, ErrorLogCreate, LogStatus, TaskStatusUpdate, RepairTask, RepairTaskCreate
|
from .models import ErrorLog, ErrorLogCreate, LogStatus, TaskStatusUpdate, RepairTask, RepairTaskCreate, Project, ProjectUpdate
|
||||||
from datetime import datetime, timedelta
|
from datetime import datetime, timedelta
|
||||||
from typing import Optional, List
|
from typing import Optional, List
|
||||||
import hashlib
|
import hashlib
|
||||||
@ -61,6 +61,17 @@ async def report_log(log_data: ErrorLogCreate, session: AsyncSession = Depends(g
|
|||||||
await session.refresh(existing_log)
|
await session.refresh(existing_log)
|
||||||
return {"message": "Regression detected, reopened", "id": existing_log.id}
|
return {"message": "Regression detected, reopened", "id": existing_log.id}
|
||||||
|
|
||||||
|
# Upsert Project record
|
||||||
|
proj_stmt = select(Project).where(Project.project_id == log_data.project_id)
|
||||||
|
proj_result = await session.exec(proj_stmt)
|
||||||
|
project = proj_result.first()
|
||||||
|
if not project:
|
||||||
|
project = Project(project_id=log_data.project_id)
|
||||||
|
if log_data.repo_url:
|
||||||
|
project.repo_url = log_data.repo_url
|
||||||
|
project.updated_at = datetime.utcnow()
|
||||||
|
session.add(project)
|
||||||
|
|
||||||
# Create new
|
# Create new
|
||||||
new_log = ErrorLog(
|
new_log = ErrorLog(
|
||||||
project_id=log_data.project_id,
|
project_id=log_data.project_id,
|
||||||
@ -78,11 +89,11 @@ async def report_log(log_data: ErrorLogCreate, session: AsyncSession = Depends(g
|
|||||||
fingerprint=fingerprint,
|
fingerprint=fingerprint,
|
||||||
timestamp=log_data.timestamp or datetime.utcnow()
|
timestamp=log_data.timestamp or datetime.utcnow()
|
||||||
)
|
)
|
||||||
|
|
||||||
session.add(new_log)
|
session.add(new_log)
|
||||||
await session.commit()
|
await session.commit()
|
||||||
await session.refresh(new_log)
|
await session.refresh(new_log)
|
||||||
|
|
||||||
return {"message": "Log reported", "id": new_log.id}
|
return {"message": "Log reported", "id": new_log.id}
|
||||||
|
|
||||||
# ==================== Agent Tasks ====================
|
# ==================== Agent Tasks ====================
|
||||||
@ -111,8 +122,9 @@ async def update_task_status(
|
|||||||
raise HTTPException(status_code=404, detail="Task not found")
|
raise HTTPException(status_code=404, detail="Task not found")
|
||||||
|
|
||||||
task.status = status_update.status
|
task.status = status_update.status
|
||||||
# We could log the message to a history table if needed
|
if status_update.message and status_update.status == LogStatus.FIX_FAILED:
|
||||||
|
task.failure_reason = status_update.message
|
||||||
|
|
||||||
session.add(task)
|
session.add(task)
|
||||||
await session.commit()
|
await session.commit()
|
||||||
await session.refresh(task)
|
await session.refresh(task)
|
||||||
@ -129,13 +141,15 @@ async def create_repair_report(report: RepairTaskCreate, session: AsyncSession =
|
|||||||
repair_task = RepairTask.from_orm(report)
|
repair_task = RepairTask.from_orm(report)
|
||||||
session.add(repair_task)
|
session.add(repair_task)
|
||||||
|
|
||||||
# 2. Update error log status (optional, but good for consistency)
|
# 2. Update error log status and failure_reason
|
||||||
if report.status in [LogStatus.FIXED, LogStatus.FIX_FAILED]:
|
if report.status in [LogStatus.FIXED, LogStatus.FIX_FAILED]:
|
||||||
log_stmt = select(ErrorLog).where(ErrorLog.id == report.error_log_id)
|
log_stmt = select(ErrorLog).where(ErrorLog.id == report.error_log_id)
|
||||||
results = await session.exec(log_stmt)
|
results = await session.exec(log_stmt)
|
||||||
error_log = results.first()
|
error_log = results.first()
|
||||||
if error_log:
|
if error_log:
|
||||||
error_log.status = report.status
|
error_log.status = report.status
|
||||||
|
if report.failure_reason and report.status == LogStatus.FIX_FAILED:
|
||||||
|
error_log.failure_reason = report.failure_reason
|
||||||
session.add(error_log)
|
session.add(error_log)
|
||||||
|
|
||||||
await session.commit()
|
await session.commit()
|
||||||
@ -298,14 +312,44 @@ async def get_bug_detail(bug_id: int, session: AsyncSession = Depends(get_sessio
|
|||||||
|
|
||||||
return bug
|
return bug
|
||||||
|
|
||||||
@app.get("/api/v1/projects", tags=["Dashboard"])
|
# ==================== Project Management ====================
|
||||||
|
@app.get("/api/v1/projects", tags=["Projects"])
|
||||||
async def get_projects(session: AsyncSession = Depends(get_session)):
|
async def get_projects(session: AsyncSession = Depends(get_session)):
|
||||||
"""Get list of all unique project IDs"""
|
"""Get list of all projects with full info"""
|
||||||
query = select(ErrorLog.project_id).distinct()
|
query = select(Project).order_by(Project.updated_at.desc())
|
||||||
results = await session.exec(query)
|
results = await session.exec(query)
|
||||||
projects = results.all()
|
projects = results.all()
|
||||||
return {"projects": projects}
|
return {"projects": projects}
|
||||||
|
|
||||||
|
@app.get("/api/v1/projects/{project_id}", tags=["Projects"])
|
||||||
|
async def get_project_detail(project_id: str, session: AsyncSession = Depends(get_session)):
|
||||||
|
"""Get single project detail"""
|
||||||
|
statement = select(Project).where(Project.project_id == project_id)
|
||||||
|
results = await session.exec(statement)
|
||||||
|
project = results.first()
|
||||||
|
if not project:
|
||||||
|
raise HTTPException(status_code=404, detail="Project not found")
|
||||||
|
return project
|
||||||
|
|
||||||
|
@app.put("/api/v1/projects/{project_id}", tags=["Projects"])
|
||||||
|
async def update_project(project_id: str, data: ProjectUpdate, session: AsyncSession = Depends(get_session)):
|
||||||
|
"""Update project config (repo_url, local_path, name, description)"""
|
||||||
|
statement = select(Project).where(Project.project_id == project_id)
|
||||||
|
results = await session.exec(statement)
|
||||||
|
project = results.first()
|
||||||
|
if not project:
|
||||||
|
raise HTTPException(status_code=404, detail="Project not found")
|
||||||
|
|
||||||
|
update_data = data.model_dump(exclude_unset=True)
|
||||||
|
for key, value in update_data.items():
|
||||||
|
setattr(project, key, value)
|
||||||
|
project.updated_at = datetime.utcnow()
|
||||||
|
|
||||||
|
session.add(project)
|
||||||
|
await session.commit()
|
||||||
|
await session.refresh(project)
|
||||||
|
return project
|
||||||
|
|
||||||
@app.get("/", tags=["Health"])
|
@app.get("/", tags=["Health"])
|
||||||
async def health_check():
|
async def health_check():
|
||||||
return {"status": "ok"}
|
return {"status": "ok"}
|
||||||
|
|||||||
@ -19,12 +19,30 @@ class LogStatus(str, Enum):
|
|||||||
DEPLOYED = "DEPLOYED"
|
DEPLOYED = "DEPLOYED"
|
||||||
FIX_FAILED = "FIX_FAILED"
|
FIX_FAILED = "FIX_FAILED"
|
||||||
|
|
||||||
|
class Project(SQLModel, table=True):
|
||||||
|
"""项目信息"""
|
||||||
|
id: Optional[int] = Field(default=None, primary_key=True)
|
||||||
|
project_id: str = Field(unique=True, index=True)
|
||||||
|
name: Optional[str] = Field(default=None)
|
||||||
|
repo_url: Optional[str] = Field(default=None)
|
||||||
|
local_path: Optional[str] = Field(default=None)
|
||||||
|
description: Optional[str] = Field(default=None)
|
||||||
|
created_at: datetime = Field(default_factory=datetime.utcnow)
|
||||||
|
updated_at: datetime = Field(default_factory=datetime.utcnow)
|
||||||
|
|
||||||
|
class ProjectUpdate(SQLModel):
|
||||||
|
"""项目编辑 schema"""
|
||||||
|
name: Optional[str] = None
|
||||||
|
repo_url: Optional[str] = None
|
||||||
|
local_path: Optional[str] = None
|
||||||
|
description: Optional[str] = None
|
||||||
|
|
||||||
class ErrorLog(SQLModel, table=True):
|
class ErrorLog(SQLModel, table=True):
|
||||||
id: Optional[int] = Field(default=None, primary_key=True)
|
id: Optional[int] = Field(default=None, primary_key=True)
|
||||||
project_id: str = Field(index=True)
|
project_id: str = Field(index=True)
|
||||||
environment: str
|
environment: str
|
||||||
level: str
|
level: str
|
||||||
|
|
||||||
# Source
|
# Source
|
||||||
source: str = Field(default="runtime", index=True)
|
source: str = Field(default="runtime", index=True)
|
||||||
|
|
||||||
@ -34,24 +52,24 @@ class ErrorLog(SQLModel, table=True):
|
|||||||
file_path: Optional[str] = None
|
file_path: Optional[str] = None
|
||||||
line_number: Optional[int] = None
|
line_number: Optional[int] = None
|
||||||
stack_trace: str = Field(sa_column=Column(JSON)) # Store full stack trace
|
stack_trace: str = Field(sa_column=Column(JSON)) # Store full stack trace
|
||||||
|
|
||||||
# Context
|
# Context
|
||||||
context: Dict = Field(default={}, sa_column=Column(JSON))
|
context: Dict = Field(default={}, sa_column=Column(JSON))
|
||||||
|
|
||||||
# Versioning
|
# Versioning
|
||||||
version: Optional[str] = None
|
version: Optional[str] = None
|
||||||
commit_hash: Optional[str] = None
|
commit_hash: Optional[str] = None
|
||||||
|
|
||||||
# Meta
|
# Meta
|
||||||
timestamp: datetime = Field(default_factory=datetime.utcnow)
|
timestamp: datetime = Field(default_factory=datetime.utcnow)
|
||||||
fingerprint: str = Field(unique=True, index=True) # project_id + error_type + file_path + line_number
|
fingerprint: str = Field(unique=True, index=True) # project_id + error_type + file_path + line_number
|
||||||
|
|
||||||
# Status Tracking
|
# Status Tracking
|
||||||
status: LogStatus = Field(default=LogStatus.NEW)
|
status: LogStatus = Field(default=LogStatus.NEW)
|
||||||
retry_count: int = Field(default=0)
|
retry_count: int = Field(default=0)
|
||||||
|
|
||||||
# Relationships could be added here (e.g., to a RepairTask table if 1:N)
|
# Repair Tracking
|
||||||
# For simplicity, we track status on the Log itself for now.
|
failure_reason: Optional[str] = Field(default=None, sa_column=Column(Text, nullable=True))
|
||||||
|
|
||||||
# Pydantic Models for API
|
# Pydantic Models for API
|
||||||
class ErrorLogCreate(SQLModel):
|
class ErrorLogCreate(SQLModel):
|
||||||
@ -61,7 +79,8 @@ class ErrorLogCreate(SQLModel):
|
|||||||
timestamp: Optional[datetime] = None
|
timestamp: Optional[datetime] = None
|
||||||
version: Optional[str] = None
|
version: Optional[str] = None
|
||||||
commit_hash: Optional[str] = None
|
commit_hash: Optional[str] = None
|
||||||
|
repo_url: Optional[str] = None
|
||||||
|
|
||||||
source: str = "runtime"
|
source: str = "runtime"
|
||||||
error: Dict # {type, message, file_path, line_number, stack_trace}
|
error: Dict # {type, message, file_path, line_number, stack_trace}
|
||||||
context: Optional[Dict] = {}
|
context: Optional[Dict] = {}
|
||||||
|
|||||||
@ -2,7 +2,13 @@
|
|||||||
|
|
||||||
## 概述
|
## 概述
|
||||||
|
|
||||||
Log Center 是一个集中式错误日志收集平台,提供 REST API 供各项目接入,实现运行时错误的统一收集、去重、追踪和分析。
|
Log Center 是一个集中式错误日志收集与 AI 自动修复平台,提供 REST API 供各项目接入,实现运行时错误的统一收集、去重、追踪、分析和自动修复。
|
||||||
|
|
||||||
|
接入流程:
|
||||||
|
|
||||||
|
1. 项目首次上报日志时自动注册到 Log Center
|
||||||
|
2. 在 Web 管理端配置项目的**仓库地址**和**本地路径**
|
||||||
|
3. Repair Agent 根据配置自动拉取代码并修复 Bug
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@ -13,7 +19,7 @@ Log Center 是一个集中式错误日志收集平台,提供 REST API 供各
|
|||||||
| 环境 | API 地址 | 仪表盘 |
|
| 环境 | API 地址 | 仪表盘 |
|
||||||
|------|----------|--------|
|
|------|----------|--------|
|
||||||
| 本地开发 | `http://localhost:8002` | `http://localhost:8003` |
|
| 本地开发 | `http://localhost:8002` | `http://localhost:8003` |
|
||||||
| 生产环境 | `https://log.yourcompany.com` | `https://log.yourcompany.com` |
|
| 生产环境 | `https://qiyuan-log-center-api.airlabs.art` | `https://qiyuan-log-center.airlabs.art` |
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@ -33,6 +39,7 @@ Log Center 是一个集中式错误日志收集平台,提供 REST API 供各
|
|||||||
"timestamp": "2026-01-30T10:30:00Z",
|
"timestamp": "2026-01-30T10:30:00Z",
|
||||||
"version": "1.2.3",
|
"version": "1.2.3",
|
||||||
"commit_hash": "abc1234",
|
"commit_hash": "abc1234",
|
||||||
|
"repo_url": "https://gitea.example.com/team/rtc_backend.git",
|
||||||
"error": {
|
"error": {
|
||||||
"type": "ValueError",
|
"type": "ValueError",
|
||||||
"message": "invalid literal for int() with base 10: 'abc'",
|
"message": "invalid literal for int() with base 10: 'abc'",
|
||||||
@ -58,43 +65,99 @@ Log Center 是一个集中式错误日志收集平台,提供 REST API 供各
|
|||||||
|
|
||||||
| 字段 | 类型 | 必填 | 说明 |
|
| 字段 | 类型 | 必填 | 说明 |
|
||||||
|------|------|------|------|
|
|------|------|------|------|
|
||||||
| `project_id` | string | ✅ | 项目标识,如 `rtc_backend`, `rtc_web` |
|
| `project_id` | string | ✅ | 项目标识,如 `rtc_backend`, `rtc_web`, `airhub_app` |
|
||||||
| `environment` | string | ✅ | 环境:`development`, `staging`, `production` |
|
| `environment` | string | ✅ | 环境:`development`, `staging`, `production` |
|
||||||
| `level` | string | ✅ | 日志级别:`ERROR`, `WARNING`, `CRITICAL` |
|
| `level` | string | ✅ | 日志级别:`ERROR`, `WARNING`, `CRITICAL` |
|
||||||
|
| `source` | string | ❌ | 来源:`runtime`(默认), `cicd`, `deployment` |
|
||||||
| `timestamp` | string | ❌ | ISO 8601 格式,不传则使用服务器时间 |
|
| `timestamp` | string | ❌ | ISO 8601 格式,不传则使用服务器时间 |
|
||||||
| `version` | string | ❌ | 应用版本号 |
|
| `version` | string | ❌ | 应用版本号 |
|
||||||
| `commit_hash` | string | ❌ | Git commit hash |
|
| `commit_hash` | string | ❌ | Git commit hash |
|
||||||
|
| `repo_url` | string | ❌ | 项目仓库地址,首次上报时传入可自动关联到项目 |
|
||||||
| `error.type` | string | ✅ | 异常类型,如 `ValueError`, `TypeError` |
|
| `error.type` | string | ✅ | 异常类型,如 `ValueError`, `TypeError` |
|
||||||
| `error.message` | string | ✅ | 错误消息 |
|
| `error.message` | string | ✅ | 错误消息 |
|
||||||
| `error.file_path` | string | ✅ | 出错文件路径 |
|
| `error.file_path` | string | ❌ | 出错文件路径(runtime 必填,cicd/deployment 可选) |
|
||||||
| `error.line_number` | int | ✅ | 出错行号 |
|
| `error.line_number` | int | ❌ | 出错行号(runtime 必填,cicd/deployment 可选) |
|
||||||
| `error.stack_trace` | array | ✅ | 堆栈信息(数组或字符串) |
|
| `error.stack_trace` | array | ✅ | 堆栈信息(数组或字符串) |
|
||||||
| `context` | object | ❌ | 额外上下文信息(URL、用户ID等) |
|
| `context` | object | ❌ | 额外上下文信息(URL、用户ID等) |
|
||||||
|
|
||||||
|
> **项目自动注册**: 首次上报日志时,系统会根据 `project_id` 自动创建项目记录。如果同时传入 `repo_url`,会自动关联仓库地址,供 Repair Agent 使用。
|
||||||
|
|
||||||
#### 响应
|
#### 响应
|
||||||
|
|
||||||
**成功 (200)**
|
**成功 (200)**
|
||||||
```json
|
```json
|
||||||
{
|
{
|
||||||
"status": "ok",
|
"message": "Log reported",
|
||||||
"id": 123,
|
"id": 123
|
||||||
"fingerprint": "a1b2c3d4e5f6",
|
|
||||||
"is_new": true
|
|
||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
**已存在 (200)** - 重复错误自动去重
|
**已存在 (200)** - 重复错误自动去重
|
||||||
```json
|
```json
|
||||||
{
|
{
|
||||||
"status": "duplicate",
|
"message": "Log deduplicated",
|
||||||
"id": 123,
|
"id": 123,
|
||||||
"fingerprint": "a1b2c3d4e5f6",
|
"status": "NEW"
|
||||||
"is_new": false
|
|
||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
### 项目管理 API
|
||||||
|
|
||||||
|
项目在首次日志上报时自动创建,之后可通过 API 或 Web 管理端编辑配置。
|
||||||
|
|
||||||
|
#### 获取项目列表
|
||||||
|
|
||||||
|
**GET** `/api/v1/projects`
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"projects": [
|
||||||
|
{
|
||||||
|
"id": 1,
|
||||||
|
"project_id": "rtc_backend",
|
||||||
|
"name": "RTC 后端",
|
||||||
|
"repo_url": "https://gitea.example.com/team/rtc_backend.git",
|
||||||
|
"local_path": "/home/dev/projects/rtc_backend",
|
||||||
|
"description": "Django 后端服务",
|
||||||
|
"created_at": "2026-01-15T08:00:00",
|
||||||
|
"updated_at": "2026-02-20T10:30:00"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
#### 获取项目详情
|
||||||
|
|
||||||
|
**GET** `/api/v1/projects/{project_id}`
|
||||||
|
|
||||||
|
返回单个项目的完整信息。
|
||||||
|
|
||||||
|
#### 编辑项目配置
|
||||||
|
|
||||||
|
**PUT** `/api/v1/projects/{project_id}`
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"name": "RTC 后端",
|
||||||
|
"repo_url": "https://gitea.example.com/team/rtc_backend.git",
|
||||||
|
"local_path": "/home/dev/projects/rtc_backend",
|
||||||
|
"description": "Django 后端服务"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
| 字段 | 类型 | 说明 |
|
||||||
|
|------|------|------|
|
||||||
|
| `name` | string | 项目显示名称 |
|
||||||
|
| `repo_url` | string | Git 仓库地址(Repair Agent 克隆/推送代码用) |
|
||||||
|
| `local_path` | string | 本地项目路径(Repair Agent 在此目录执行修复) |
|
||||||
|
| `description` | string | 项目描述 |
|
||||||
|
|
||||||
|
> **注意**: `repo_url` 和 `local_path` 是 Repair Agent 正常工作的关键配置。未配置时 Agent 将无法执行 Git 操作或定位项目代码。可在 Web 管理端的「项目管理」页面中配置。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
## 接入示例
|
## 接入示例
|
||||||
|
|
||||||
### Python (Django / FastAPI)
|
### Python (Django / FastAPI)
|
||||||
@ -110,11 +173,12 @@ def report_error(exc, context=None):
|
|||||||
"""上报错误到 Log Center"""
|
"""上报错误到 Log Center"""
|
||||||
tb = traceback.extract_tb(exc.__traceback__)
|
tb = traceback.extract_tb(exc.__traceback__)
|
||||||
last_frame = tb[-1] if tb else None
|
last_frame = tb[-1] if tb else None
|
||||||
|
|
||||||
payload = {
|
payload = {
|
||||||
"project_id": "rtc_backend",
|
"project_id": "rtc_backend",
|
||||||
"environment": os.getenv("ENVIRONMENT", "development"),
|
"environment": os.getenv("ENVIRONMENT", "development"),
|
||||||
"level": "ERROR",
|
"level": "ERROR",
|
||||||
|
"repo_url": os.getenv("REPO_URL", ""), # 可选:关联仓库地址
|
||||||
"error": {
|
"error": {
|
||||||
"type": type(exc).__name__,
|
"type": type(exc).__name__,
|
||||||
"message": str(exc),
|
"message": str(exc),
|
||||||
@ -124,7 +188,7 @@ def report_error(exc, context=None):
|
|||||||
},
|
},
|
||||||
"context": context or {}
|
"context": context or {}
|
||||||
}
|
}
|
||||||
|
|
||||||
try:
|
try:
|
||||||
requests.post(
|
requests.post(
|
||||||
f"{LOG_CENTER_URL}/api/v1/logs/report",
|
f"{LOG_CENTER_URL}/api/v1/logs/report",
|
||||||
@ -146,7 +210,7 @@ def custom_exception_handler(exc, context):
|
|||||||
"view": str(context.get("view")),
|
"view": str(context.get("view")),
|
||||||
"request_path": context.get("request").path if context.get("request") else None,
|
"request_path": context.get("request").path if context.get("request") else None,
|
||||||
})
|
})
|
||||||
|
|
||||||
# ... 原有逻辑不变 ...
|
# ... 原有逻辑不变 ...
|
||||||
```
|
```
|
||||||
|
|
||||||
@ -161,6 +225,7 @@ interface ErrorPayload {
|
|||||||
project_id: string;
|
project_id: string;
|
||||||
environment: string;
|
environment: string;
|
||||||
level: string;
|
level: string;
|
||||||
|
repo_url?: string;
|
||||||
error: {
|
error: {
|
||||||
type: string;
|
type: string;
|
||||||
message: string;
|
message: string;
|
||||||
@ -175,7 +240,7 @@ export function reportError(error: Error, context?: Record<string, unknown>) {
|
|||||||
// 解析堆栈信息
|
// 解析堆栈信息
|
||||||
const stackLines = error.stack?.split('\n') || [];
|
const stackLines = error.stack?.split('\n') || [];
|
||||||
const match = stackLines[1]?.match(/at\s+.*\s+\((.+):(\d+):\d+\)/);
|
const match = stackLines[1]?.match(/at\s+.*\s+\((.+):(\d+):\d+\)/);
|
||||||
|
|
||||||
const payload: ErrorPayload = {
|
const payload: ErrorPayload = {
|
||||||
project_id: 'rtc_web',
|
project_id: 'rtc_web',
|
||||||
environment: import.meta.env.MODE,
|
environment: import.meta.env.MODE,
|
||||||
@ -225,7 +290,7 @@ request.interceptors.response.use(
|
|||||||
method: error.config?.method,
|
method: error.config?.method,
|
||||||
status: error.response?.status,
|
status: error.response?.status,
|
||||||
});
|
});
|
||||||
|
|
||||||
// ... 原有逻辑不变 ...
|
// ... 原有逻辑不变 ...
|
||||||
}
|
}
|
||||||
);
|
);
|
||||||
@ -233,15 +298,78 @@ request.interceptors.response.use(
|
|||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
### Flutter (Dart)
|
||||||
|
|
||||||
|
```dart
|
||||||
|
import 'dart:convert';
|
||||||
|
import 'package:http/http.dart' as http;
|
||||||
|
|
||||||
|
const logCenterUrl = String.fromEnvironment(
|
||||||
|
'LOG_CENTER_URL',
|
||||||
|
defaultValue: 'http://localhost:8002',
|
||||||
|
);
|
||||||
|
|
||||||
|
Future<void> reportError(dynamic error, StackTrace stackTrace, {Map<String, dynamic>? context}) async {
|
||||||
|
final stackLines = stackTrace.toString().split('\n');
|
||||||
|
// 解析第一行获取文件和行号
|
||||||
|
final match = RegExp(r'#0\s+.*\((.+):(\d+):\d+\)').firstMatch(stackLines.first);
|
||||||
|
|
||||||
|
final payload = {
|
||||||
|
'project_id': 'airhub_app',
|
||||||
|
'environment': const String.fromEnvironment('ENVIRONMENT', defaultValue: 'development'),
|
||||||
|
'level': 'ERROR',
|
||||||
|
'repo_url': 'https://gitea.example.com/team/airhub_app.git',
|
||||||
|
'error': {
|
||||||
|
'type': error.runtimeType.toString(),
|
||||||
|
'message': error.toString(),
|
||||||
|
'file_path': match?.group(1) ?? 'unknown',
|
||||||
|
'line_number': int.tryParse(match?.group(2) ?? '0') ?? 0,
|
||||||
|
'stack_trace': stackLines.take(20).toList(),
|
||||||
|
},
|
||||||
|
'context': context ?? {},
|
||||||
|
};
|
||||||
|
|
||||||
|
try {
|
||||||
|
await http.post(
|
||||||
|
Uri.parse('$logCenterUrl/api/v1/logs/report'),
|
||||||
|
headers: {'Content-Type': 'application/json'},
|
||||||
|
body: jsonEncode(payload),
|
||||||
|
).timeout(const Duration(seconds: 3));
|
||||||
|
} catch (_) {
|
||||||
|
// 静默失败
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
在 `main.dart` 中全局捕获:
|
||||||
|
|
||||||
|
```dart
|
||||||
|
void main() {
|
||||||
|
FlutterError.onError = (details) {
|
||||||
|
reportError(details.exception, details.stack ?? StackTrace.current);
|
||||||
|
};
|
||||||
|
|
||||||
|
runZonedGuarded(() {
|
||||||
|
runApp(const MyApp());
|
||||||
|
}, (error, stack) {
|
||||||
|
reportError(error, stack);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
## 错误去重机制
|
## 错误去重机制
|
||||||
|
|
||||||
Log Center 使用 **指纹(fingerprint)** 对错误进行去重:
|
Log Center 使用 **指纹(fingerprint)** 对错误进行去重,按来源使用不同的指纹策略:
|
||||||
|
|
||||||
```
|
| 来源 | 指纹组成 |
|
||||||
fingerprint = MD5(project_id + error_type + file_path + line_number)
|
|------|----------|
|
||||||
```
|
| `runtime` | `MD5(project_id \| error_type \| file_path \| line_number)` |
|
||||||
|
| `cicd` | `MD5(project_id \| cicd \| error_type \| job_name \| step_name)` |
|
||||||
|
| `deployment` | `MD5(project_id \| deployment \| error_type \| namespace \| deployment_name)` |
|
||||||
|
|
||||||
相同指纹的错误只会记录一次,后续只更新计数和最后出现时间。
|
相同指纹的错误只会记录一次。如果已修复的错误再次出现,系统会自动重新打开(回归检测)。
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@ -259,21 +387,43 @@ NEW → VERIFYING → PENDING_FIX → FIXING → FIXED → VERIFIED → DEPLOYED
|
|||||||
| `VERIFYING` | 正在验证复现 |
|
| `VERIFYING` | 正在验证复现 |
|
||||||
| `CANNOT_REPRODUCE` | 无法复现 |
|
| `CANNOT_REPRODUCE` | 无法复现 |
|
||||||
| `PENDING_FIX` | 等待修复 |
|
| `PENDING_FIX` | 等待修复 |
|
||||||
| `FIXING` | 正在修复中 |
|
| `FIXING` | AI Agent 正在修复中 |
|
||||||
| `FIXED` | 已修复,待验证 |
|
| `FIXED` | 已修复,待验证 |
|
||||||
| `VERIFIED` | 已验证修复 |
|
| `VERIFIED` | 已验证修复 |
|
||||||
| `DEPLOYED` | 已部署上线 |
|
| `DEPLOYED` | 已部署上线 |
|
||||||
| `FIX_FAILED` | 修复失败 |
|
| `FIX_FAILED` | 修复失败(失败原因会记录到数据库并在 Web 端展示) |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Web 管理端
|
||||||
|
|
||||||
|
### 项目管理
|
||||||
|
|
||||||
|
访问 Web 管理端的「项目管理」页面,可以:
|
||||||
|
|
||||||
|
- 查看所有已注册项目及其配置状态
|
||||||
|
- 编辑项目的**仓库地址**(`repo_url`)和**本地路径**(`local_path`)
|
||||||
|
- 未配置的字段会标红提示
|
||||||
|
|
||||||
|
> Repair Agent 依赖这两个配置来定位项目代码和执行 Git 操作。请确保在接入后及时配置。
|
||||||
|
|
||||||
|
### 缺陷追踪
|
||||||
|
|
||||||
|
- **缺陷列表**: 按项目、来源、状态筛选,修复失败的缺陷会直接显示失败原因
|
||||||
|
- **缺陷详情**: 查看完整错误信息、堆栈、上下文,以及修复历史记录
|
||||||
|
- **修复报告**: 查看每轮 AI 修复的详细过程(分析、代码变更、测试结果、失败原因)
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## 最佳实践
|
## 最佳实践
|
||||||
|
|
||||||
1. **设置超时**: 上报请求设置 3 秒超时,避免影响主业务
|
1. **首次接入时传入 `repo_url`**: 在日志上报中包含仓库地址,省去手动配置步骤
|
||||||
2. **静默失败**: 上报失败不应影响用户体验
|
2. **设置超时**: 上报请求设置 3 秒超时,避免影响主业务
|
||||||
3. **异步上报**: 使用异步方式上报,不阻塞主流程
|
3. **静默失败**: 上报失败不应影响用户体验
|
||||||
4. **添加上下文**: 尽量添加有用的上下文信息(用户ID、请求URL等)
|
4. **异步上报**: 使用异步方式上报,不阻塞主流程
|
||||||
5. **环境区分**: 正确设置 `environment` 字段区分开发/生产
|
5. **添加上下文**: 尽量添加有用的上下文信息(用户ID、请求URL等)
|
||||||
|
6. **环境区分**: 正确设置 `environment` 字段区分开发/生产
|
||||||
|
7. **配置本地路径**: 接入后在 Web 端配置 `local_path`,使 Repair Agent 能正确定位代码
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@ -284,6 +434,7 @@ NEW → VERIFYING → PENDING_FIX → FIXING → FIXED → VERIFIED → DEPLOYED
|
|||||||
# .env
|
# .env
|
||||||
LOG_CENTER_URL=http://localhost:8002
|
LOG_CENTER_URL=http://localhost:8002
|
||||||
ENVIRONMENT=development
|
ENVIRONMENT=development
|
||||||
|
REPO_URL=https://gitea.example.com/team/rtc_backend.git # 可选
|
||||||
```
|
```
|
||||||
|
|
||||||
### JavaScript 项目
|
### JavaScript 项目
|
||||||
@ -292,6 +443,13 @@ ENVIRONMENT=development
|
|||||||
VITE_LOG_CENTER_URL=http://localhost:8002
|
VITE_LOG_CENTER_URL=http://localhost:8002
|
||||||
```
|
```
|
||||||
|
|
||||||
|
### Flutter 项目
|
||||||
|
```bash
|
||||||
|
# 编译时传入
|
||||||
|
flutter run --dart-define=LOG_CENTER_URL=http://localhost:8002
|
||||||
|
flutter run --dart-define=ENVIRONMENT=development
|
||||||
|
```
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## API 文档
|
## API 文档
|
||||||
|
|||||||
317
docs/project-management-repair-plan.md
Normal file
317
docs/project-management-repair-plan.md
Normal file
@ -0,0 +1,317 @@
|
|||||||
|
# Log Center 改进方案:项目管理 + 修复失败追踪
|
||||||
|
|
||||||
|
## 背景
|
||||||
|
|
||||||
|
Log Center 当前存在三个核心问题:
|
||||||
|
|
||||||
|
1. **Repair Agent 无法识别项目仓库和本地路径** — 仓库地址(`repo_url`)和本地项目路径(`local_path`)都硬编码在 `repair_agent/.env`,需手动配置,且当前全部为空。应在接入日志收集时自动注册项目信息,并可在 Web 端统一管理。
|
||||||
|
2. **修复失败原因未上报** — `ErrorLog` 表没有 `failure_reason` 字段;`update_task_status` API 接收了 `message` 参数但直接丢弃(`main.py:113`);`upload_report` 失败时原因静默丢失。
|
||||||
|
3. **前端未展示失败原因** — BugList / BugDetail 页面无法看到修复失败的具体原因,用户必须逐一查看 RepairTask 记录才能找到。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 第一部分:项目管理(仓库地址 + 本地路径 + Web 管理页)
|
||||||
|
|
||||||
|
### 1.1 新增 `Project` 数据模型
|
||||||
|
|
||||||
|
**文件**: `app/models.py`
|
||||||
|
|
||||||
|
```python
|
||||||
|
class Project(SQLModel, table=True):
|
||||||
|
id: Optional[int] = Field(default=None, primary_key=True)
|
||||||
|
project_id: str = Field(unique=True, index=True) # "rtc_backend"
|
||||||
|
name: Optional[str] = Field(default=None) # 显示名称
|
||||||
|
repo_url: Optional[str] = Field(default=None) # GitHub/Gitea 仓库地址
|
||||||
|
local_path: Optional[str] = Field(default=None) # 本地项目路径(Agent 修复时使用)
|
||||||
|
description: Optional[str] = Field(default=None) # 项目描述
|
||||||
|
created_at: datetime = Field(default_factory=datetime.utcnow)
|
||||||
|
updated_at: datetime = Field(default_factory=datetime.utcnow)
|
||||||
|
```
|
||||||
|
|
||||||
|
新增 `ProjectUpdate` schema 用于编辑:
|
||||||
|
|
||||||
|
```python
|
||||||
|
class ProjectUpdate(SQLModel):
|
||||||
|
name: Optional[str] = None
|
||||||
|
repo_url: Optional[str] = None
|
||||||
|
local_path: Optional[str] = None
|
||||||
|
description: Optional[str] = None
|
||||||
|
```
|
||||||
|
|
||||||
|
### 1.2 日志上报自动注册项目
|
||||||
|
|
||||||
|
**文件**: `app/models.py` — `ErrorLogCreate` 增加字段
|
||||||
|
|
||||||
|
```python
|
||||||
|
class ErrorLogCreate(SQLModel):
|
||||||
|
# ... 现有字段 ...
|
||||||
|
repo_url: Optional[str] = None # 可选:上报时附带仓库地址
|
||||||
|
```
|
||||||
|
|
||||||
|
**文件**: `app/main.py` — `report_log()` 增加 upsert 逻辑
|
||||||
|
|
||||||
|
收到日志上报时:
|
||||||
|
1. 根据 `project_id` 查找 Project 记录
|
||||||
|
2. 不存在 → 自动创建(`project_id` + `repo_url`)
|
||||||
|
3. 已存在且 `repo_url` 不为空 → 更新 `repo_url`
|
||||||
|
|
||||||
|
> 向后兼容:不传 `repo_url` 时仍正常创建 Project 记录(`repo_url` 为空),后续可在 Web 端手动补充。
|
||||||
|
|
||||||
|
### 1.3 项目管理 API
|
||||||
|
|
||||||
|
**文件**: `app/main.py`
|
||||||
|
|
||||||
|
| 端点 | 方法 | 用途 |
|
||||||
|
|------|------|------|
|
||||||
|
| `GET /api/v1/projects` | GET | 项目列表(改造现有端点,从 Project 表查询,返回完整信息) |
|
||||||
|
| `GET /api/v1/projects/{project_id}` | GET | 获取单个项目详情(含 repo_url、local_path) |
|
||||||
|
| `PUT /api/v1/projects/{project_id}` | PUT | 编辑项目信息(repo_url、local_path、name、description) |
|
||||||
|
|
||||||
|
#### GET /api/v1/projects 响应示例
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"projects": [
|
||||||
|
{
|
||||||
|
"id": 1,
|
||||||
|
"project_id": "rtc_backend",
|
||||||
|
"name": "RTC 后端",
|
||||||
|
"repo_url": "https://gitea.example.com/team/rtc_backend.git",
|
||||||
|
"local_path": "/Users/maidong/Desktop/zyc/qy_gitlab/rtc_backend",
|
||||||
|
"description": "Django 后端服务",
|
||||||
|
"created_at": "2026-01-15T08:00:00",
|
||||||
|
"updated_at": "2026-02-20T10:30:00"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
#### PUT /api/v1/projects/{project_id} 请求示例
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"name": "RTC 后端",
|
||||||
|
"repo_url": "https://gitea.example.com/team/rtc_backend.git",
|
||||||
|
"local_path": "/Users/maidong/Desktop/zyc/qy_gitlab/rtc_backend",
|
||||||
|
"description": "Django 后端服务"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### 1.4 Repair Agent 适配
|
||||||
|
|
||||||
|
#### task_manager.py — 新增 `get_project_info()`
|
||||||
|
|
||||||
|
```python
|
||||||
|
def get_project_info(self, project_id: str) -> Optional[dict]:
|
||||||
|
"""从 Log Center API 获取项目配置(repo_url + local_path)"""
|
||||||
|
response = self.client.get(f"{self.base_url}/api/v1/projects/{project_id}")
|
||||||
|
return response.json() # { repo_url, local_path, ... }
|
||||||
|
```
|
||||||
|
|
||||||
|
#### core.py — 修改 `fix_project()` 和 `fix_single_bug()`
|
||||||
|
|
||||||
|
优先级链:
|
||||||
|
- **local_path**:API 返回值 → `settings.get_project_path()` (.env) → 报错退出
|
||||||
|
- **repo_url**:API 返回值 → `settings.get_github_repo()` (.env) → 跳过 Git 操作
|
||||||
|
|
||||||
|
```python
|
||||||
|
# 获取项目配置(API 优先)
|
||||||
|
project_info = self.task_manager.get_project_info(project_id)
|
||||||
|
|
||||||
|
project_path = (project_info and project_info.get("local_path")) or settings.get_project_path(project_id)
|
||||||
|
github_repo = (project_info and project_info.get("repo_url")) or settings.get_github_repo(project_id)
|
||||||
|
```
|
||||||
|
|
||||||
|
### 1.5 Web 端项目管理页面
|
||||||
|
|
||||||
|
**新增文件**: `web/src/pages/ProjectList.tsx`
|
||||||
|
|
||||||
|
功能:
|
||||||
|
- 表格展示所有项目:project_id、名称、仓库地址、本地路径、描述、更新时间
|
||||||
|
- 行内编辑:点击「编辑」按钮进入编辑模式,可修改所有可编辑字段
|
||||||
|
- 状态指示:`repo_url` / `local_path` 为空时标红显示「未配置」
|
||||||
|
- 移动端适配:卡片式布局
|
||||||
|
|
||||||
|
**修改文件**: `web/src/App.tsx`
|
||||||
|
- 侧边栏新增「项目管理」入口(使用 `FolderGit2` 图标)
|
||||||
|
- 路由新增 `/projects` → `ProjectList`
|
||||||
|
|
||||||
|
**修改文件**: `web/src/api.ts`
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
export interface Project {
|
||||||
|
id: number;
|
||||||
|
project_id: string;
|
||||||
|
name: string | null;
|
||||||
|
repo_url: string | null;
|
||||||
|
local_path: string | null;
|
||||||
|
description: string | null;
|
||||||
|
created_at: string;
|
||||||
|
updated_at: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const getProjectList = () =>
|
||||||
|
api.get<{ projects: Project[] }>('/api/v1/projects');
|
||||||
|
|
||||||
|
export const getProjectDetail = (projectId: string) =>
|
||||||
|
api.get<Project>(`/api/v1/projects/${projectId}`);
|
||||||
|
|
||||||
|
export const updateProject = (projectId: string, data: Partial<Project>) =>
|
||||||
|
api.put(`/api/v1/projects/${projectId}`, data);
|
||||||
|
```
|
||||||
|
|
||||||
|
### 1.6 数据库变更
|
||||||
|
|
||||||
|
```sql
|
||||||
|
-- 新建 Project 表
|
||||||
|
CREATE TABLE project (
|
||||||
|
id SERIAL PRIMARY KEY,
|
||||||
|
project_id VARCHAR UNIQUE NOT NULL,
|
||||||
|
name VARCHAR,
|
||||||
|
repo_url VARCHAR,
|
||||||
|
local_path VARCHAR,
|
||||||
|
description VARCHAR,
|
||||||
|
created_at TIMESTAMP DEFAULT NOW(),
|
||||||
|
updated_at TIMESTAMP DEFAULT NOW()
|
||||||
|
);
|
||||||
|
CREATE UNIQUE INDEX ix_project_project_id ON project (project_id);
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 第二部分:修复失败原因录入数据库
|
||||||
|
|
||||||
|
### 2.1 ErrorLog 增加 `failure_reason` 字段
|
||||||
|
|
||||||
|
**文件**: `app/models.py`
|
||||||
|
|
||||||
|
```python
|
||||||
|
class ErrorLog(SQLModel, table=True):
|
||||||
|
# ... 现有字段 ...
|
||||||
|
failure_reason: Optional[str] = Field(default=None, sa_column=Column(Text, nullable=True))
|
||||||
|
```
|
||||||
|
|
||||||
|
### 2.2 `update_task_status` API 存储 message
|
||||||
|
|
||||||
|
**文件**: `app/main.py` — `update_task_status()`
|
||||||
|
|
||||||
|
当前代码(第 113 行):
|
||||||
|
```python
|
||||||
|
task.status = status_update.status
|
||||||
|
# We could log the message to a history table if needed ← message 被丢弃了
|
||||||
|
```
|
||||||
|
|
||||||
|
修改为:
|
||||||
|
```python
|
||||||
|
task.status = status_update.status
|
||||||
|
if status_update.message and status_update.status == LogStatus.FIX_FAILED:
|
||||||
|
task.failure_reason = status_update.message
|
||||||
|
```
|
||||||
|
|
||||||
|
### 2.3 `create_repair_report` API 同步更新 ErrorLog
|
||||||
|
|
||||||
|
**文件**: `app/main.py` — `create_repair_report()`
|
||||||
|
|
||||||
|
当前仅在 `FIXED` / `FIX_FAILED` 时更新 ErrorLog 的 status。增加逻辑:
|
||||||
|
|
||||||
|
```python
|
||||||
|
if error_log:
|
||||||
|
error_log.status = report.status
|
||||||
|
if report.failure_reason and report.status == LogStatus.FIX_FAILED:
|
||||||
|
error_log.failure_reason = report.failure_reason
|
||||||
|
session.add(error_log)
|
||||||
|
```
|
||||||
|
|
||||||
|
### 2.4 数据库变更
|
||||||
|
|
||||||
|
```sql
|
||||||
|
ALTER TABLE errorlog ADD COLUMN failure_reason TEXT;
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 第三部分:前端展示失败原因
|
||||||
|
|
||||||
|
### 3.1 TypeScript 类型更新
|
||||||
|
|
||||||
|
**文件**: `web/src/api.ts`
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
export interface ErrorLog {
|
||||||
|
// ... 现有字段 ...
|
||||||
|
failure_reason: string | null;
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### 3.2 BugList 页面
|
||||||
|
|
||||||
|
**文件**: `web/src/pages/BugList.tsx`
|
||||||
|
|
||||||
|
当 bug 状态为 `FIX_FAILED` 时,在状态列旁或下方显示 `failure_reason` 摘要(超长截断)。
|
||||||
|
|
||||||
|
### 3.3 BugDetail 页面
|
||||||
|
|
||||||
|
**文件**: `web/src/pages/BugDetail.tsx`
|
||||||
|
|
||||||
|
当状态为 `FIX_FAILED` 且 `failure_reason` 不为空时,增加醒目的失败原因卡片:
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
{bug.failure_reason && (
|
||||||
|
<div className="card" style={{ borderLeft: '3px solid var(--error)' }}>
|
||||||
|
<h2><AlertTriangle size={16} /> 修复失败原因</h2>
|
||||||
|
<pre className="code-block error">{bug.failure_reason}</pre>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
```
|
||||||
|
|
||||||
|
### 3.4 RepairList 页面
|
||||||
|
|
||||||
|
**文件**: `web/src/pages/RepairList.tsx`
|
||||||
|
|
||||||
|
表格新增「失败原因」列,显示截断文本(`text-overflow: ellipsis`)。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 文件变更清单
|
||||||
|
|
||||||
|
### 后端
|
||||||
|
|
||||||
|
| 文件 | 操作 | 说明 |
|
||||||
|
|------|------|------|
|
||||||
|
| `app/models.py` | 修改 | 新增 Project + ProjectUpdate;ErrorLog 增加 failure_reason |
|
||||||
|
| `app/main.py` | 修改 | 项目 CRUD API;修改 report_log / update_task_status / create_repair_report |
|
||||||
|
| `app/database.py` | 修改 | 追加迁移 SQL(Project 表 + failure_reason 列) |
|
||||||
|
| `repair_agent/agent/task_manager.py` | 修改 | 新增 get_project_info() |
|
||||||
|
| `repair_agent/agent/core.py` | 修改 | fix_project / fix_single_bug 优先使用 API 配置 |
|
||||||
|
|
||||||
|
### 前端
|
||||||
|
|
||||||
|
| 文件 | 操作 | 说明 |
|
||||||
|
|------|------|------|
|
||||||
|
| `web/src/api.ts` | 修改 | Project 类型 + API 函数;ErrorLog 增加 failure_reason |
|
||||||
|
| `web/src/App.tsx` | 修改 | 路由 + 侧边栏增加「项目管理」 |
|
||||||
|
| `web/src/pages/ProjectList.tsx` | **新增** | 项目列表 + 行内编辑 |
|
||||||
|
| `web/src/pages/BugList.tsx` | 修改 | 展示 failure_reason |
|
||||||
|
| `web/src/pages/BugDetail.tsx` | 修改 | 突出展示 failure_reason |
|
||||||
|
| `web/src/pages/RepairList.tsx` | 修改 | 增加失败原因列 |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 实施顺序
|
||||||
|
|
||||||
|
1. **后端模型** — 新增 Project 模型 + ErrorLog 增加 failure_reason + 数据库迁移
|
||||||
|
2. **后端 API** — 项目 CRUD + 修改 report_log / update_task_status / create_repair_report
|
||||||
|
3. **Repair Agent** — task_manager 获取项目配置 + core.py 优先使用 API 配置
|
||||||
|
4. **前端 API 层** — api.ts 类型和函数更新
|
||||||
|
5. **前端页面** — ProjectList 新页面 + BugList / BugDetail / RepairList 展示 failure_reason
|
||||||
|
6. **路由 / 侧边栏** — App.tsx 接入项目管理入口
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 验证方式
|
||||||
|
|
||||||
|
1. 调用 `POST /api/v1/logs/report` 带 `repo_url` → 检查 Project 表自动创建
|
||||||
|
2. Web 端「项目管理」页 → 编辑 `repo_url` / `local_path` → 验证保存成功
|
||||||
|
3. Repair Agent `status` 命令 → 验证读取到 API 配置的 `repo_url` + `local_path`
|
||||||
|
4. 触发一次修复失败 → 检查 `ErrorLog.failure_reason` 已写入
|
||||||
|
5. Web 端 BugList / BugDetail / RepairList → 确认失败原因正确展示
|
||||||
@ -38,9 +38,12 @@ class RepairEngine:
|
|||||||
BatchFixResult
|
BatchFixResult
|
||||||
"""
|
"""
|
||||||
logger.info(f"开始修复项目: {project_id}")
|
logger.info(f"开始修复项目: {project_id}")
|
||||||
|
|
||||||
# 获取项目路径
|
# 从 API 获取项目配置(优先),回退到 .env
|
||||||
project_path = settings.get_project_path(project_id)
|
project_info = self.task_manager.get_project_info(project_id)
|
||||||
|
project_path = (project_info and project_info.get("local_path")) or settings.get_project_path(project_id)
|
||||||
|
api_repo_url = (project_info and project_info.get("repo_url")) or ""
|
||||||
|
|
||||||
if not project_path:
|
if not project_path:
|
||||||
logger.error(f"未找到项目路径配置: {project_id}")
|
logger.error(f"未找到项目路径配置: {project_id}")
|
||||||
return BatchFixResult(
|
return BatchFixResult(
|
||||||
@ -50,7 +53,7 @@ class RepairEngine:
|
|||||||
failed_count=0,
|
failed_count=0,
|
||||||
results=[],
|
results=[],
|
||||||
)
|
)
|
||||||
|
|
||||||
# 获取待修复的 Bug
|
# 获取待修复的 Bug
|
||||||
bugs = self.task_manager.fetch_pending_bugs(project_id)
|
bugs = self.task_manager.fetch_pending_bugs(project_id)
|
||||||
if not bugs:
|
if not bugs:
|
||||||
@ -65,12 +68,12 @@ class RepairEngine:
|
|||||||
|
|
||||||
logger.info(f"获取到 {len(bugs)} 个待修复 Bug")
|
logger.info(f"获取到 {len(bugs)} 个待修复 Bug")
|
||||||
|
|
||||||
# 检查是否启用 Git
|
# 检查是否启用 Git(API 优先,回退 .env)
|
||||||
git_enabled = settings.is_git_enabled(project_id)
|
github_repo = api_repo_url or settings.get_github_repo(project_id)
|
||||||
|
git_enabled = bool(github_repo)
|
||||||
git_manager = None
|
git_manager = None
|
||||||
|
|
||||||
if git_enabled:
|
if git_enabled:
|
||||||
github_repo = settings.get_github_repo(project_id)
|
|
||||||
git_manager = GitManager(project_path, github_repo=github_repo)
|
git_manager = GitManager(project_path, github_repo=github_repo)
|
||||||
git_manager.pull()
|
git_manager.pull()
|
||||||
|
|
||||||
@ -78,7 +81,7 @@ class RepairEngine:
|
|||||||
if auto_commit:
|
if auto_commit:
|
||||||
git_manager.create_branch(branch_name)
|
git_manager.create_branch(branch_name)
|
||||||
else:
|
else:
|
||||||
logger.info(f"项目 {project_id} 未配置 GitHub 仓库,跳过 Git 操作")
|
logger.info(f"项目 {project_id} 未配置仓库地址,跳过 Git 操作")
|
||||||
|
|
||||||
# 更新所有 Bug 状态为 FIXING
|
# 更新所有 Bug 状态为 FIXING
|
||||||
for bug in bugs:
|
for bug in bugs:
|
||||||
@ -302,7 +305,11 @@ class RepairEngine:
|
|||||||
message="Bug 不存在",
|
message="Bug 不存在",
|
||||||
)
|
)
|
||||||
|
|
||||||
project_path = settings.get_project_path(bug.project_id)
|
# 从 API 获取项目配置(优先),回退到 .env
|
||||||
|
project_info = self.task_manager.get_project_info(bug.project_id)
|
||||||
|
project_path = (project_info and project_info.get("local_path")) or settings.get_project_path(bug.project_id)
|
||||||
|
github_repo = (project_info and project_info.get("repo_url")) or settings.get_github_repo(bug.project_id)
|
||||||
|
|
||||||
if not project_path:
|
if not project_path:
|
||||||
return FixResult(
|
return FixResult(
|
||||||
bug_id=bug_id,
|
bug_id=bug_id,
|
||||||
@ -313,7 +320,7 @@ class RepairEngine:
|
|||||||
self.task_manager.update_status(bug_id, BugStatus.FIXING)
|
self.task_manager.update_status(bug_id, BugStatus.FIXING)
|
||||||
|
|
||||||
max_rounds = settings.max_retry_count
|
max_rounds = settings.max_retry_count
|
||||||
git_manager = GitManager(project_path)
|
git_manager = GitManager(project_path, github_repo=github_repo) if github_repo else GitManager(project_path)
|
||||||
last_test_output = ""
|
last_test_output = ""
|
||||||
last_diff = ""
|
last_diff = ""
|
||||||
|
|
||||||
|
|||||||
@ -149,6 +149,16 @@ class TaskManager:
|
|||||||
logger.error(f"上传修复报告失败: {e}")
|
logger.error(f"上传修复报告失败: {e}")
|
||||||
return False
|
return False
|
||||||
|
|
||||||
|
def get_project_info(self, project_id: str) -> Optional[dict]:
|
||||||
|
"""从 Log Center API 获取项目配置(repo_url + local_path)"""
|
||||||
|
try:
|
||||||
|
response = self.client.get(f"{self.base_url}/api/v1/projects/{project_id}")
|
||||||
|
response.raise_for_status()
|
||||||
|
return response.json()
|
||||||
|
except httpx.HTTPError as e:
|
||||||
|
logger.warning(f"获取项目 {project_id} 配置失败: {e}")
|
||||||
|
return None
|
||||||
|
|
||||||
def close(self):
|
def close(self):
|
||||||
"""关闭连接"""
|
"""关闭连接"""
|
||||||
self.client.close()
|
self.client.close()
|
||||||
|
|||||||
@ -1,11 +1,12 @@
|
|||||||
import { useState, useEffect } from 'react';
|
import { useState, useEffect } from 'react';
|
||||||
import { BrowserRouter, Routes, Route, NavLink, useLocation } from 'react-router-dom';
|
import { BrowserRouter, Routes, Route, NavLink, useLocation } from 'react-router-dom';
|
||||||
import { LayoutDashboard, Bug, Wrench, Shield, Menu, X } from 'lucide-react';
|
import { LayoutDashboard, Bug, Wrench, FolderGit2, Shield, Menu, X } from 'lucide-react';
|
||||||
import Dashboard from './pages/Dashboard';
|
import Dashboard from './pages/Dashboard';
|
||||||
import BugList from './pages/BugList';
|
import BugList from './pages/BugList';
|
||||||
import BugDetail from './pages/BugDetail';
|
import BugDetail from './pages/BugDetail';
|
||||||
import RepairList from './pages/RepairList';
|
import RepairList from './pages/RepairList';
|
||||||
import RepairDetail from './pages/RepairDetail';
|
import RepairDetail from './pages/RepairDetail';
|
||||||
|
import ProjectList from './pages/ProjectList';
|
||||||
import './index.css';
|
import './index.css';
|
||||||
|
|
||||||
function AppLayout() {
|
function AppLayout() {
|
||||||
@ -76,6 +77,15 @@ function AppLayout() {
|
|||||||
修复报告
|
修复报告
|
||||||
</NavLink>
|
</NavLink>
|
||||||
</li>
|
</li>
|
||||||
|
<li className="nav-item">
|
||||||
|
<NavLink
|
||||||
|
to="/projects"
|
||||||
|
className={({ isActive }) => `nav-link ${isActive ? 'active' : ''}`}
|
||||||
|
>
|
||||||
|
<FolderGit2 size={16} />
|
||||||
|
项目管理
|
||||||
|
</NavLink>
|
||||||
|
</li>
|
||||||
</ul>
|
</ul>
|
||||||
</nav>
|
</nav>
|
||||||
</aside>
|
</aside>
|
||||||
@ -87,6 +97,7 @@ function AppLayout() {
|
|||||||
<Route path="/bugs/:id" element={<BugDetail />} />
|
<Route path="/bugs/:id" element={<BugDetail />} />
|
||||||
<Route path="/repairs" element={<RepairList />} />
|
<Route path="/repairs" element={<RepairList />} />
|
||||||
<Route path="/repairs/:id" element={<RepairDetail />} />
|
<Route path="/repairs/:id" element={<RepairDetail />} />
|
||||||
|
<Route path="/projects" element={<ProjectList />} />
|
||||||
</Routes>
|
</Routes>
|
||||||
</main>
|
</main>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@ -26,6 +26,18 @@ export interface ErrorLog {
|
|||||||
fingerprint: string;
|
fingerprint: string;
|
||||||
status: string;
|
status: string;
|
||||||
retry_count: number;
|
retry_count: number;
|
||||||
|
failure_reason: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface Project {
|
||||||
|
id: number;
|
||||||
|
project_id: string;
|
||||||
|
name: string | null;
|
||||||
|
repo_url: string | null;
|
||||||
|
local_path: string | null;
|
||||||
|
description: string | null;
|
||||||
|
created_at: string;
|
||||||
|
updated_at: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface DashboardStats {
|
export interface DashboardStats {
|
||||||
@ -73,7 +85,13 @@ export const getBugs = (params: {
|
|||||||
|
|
||||||
export const getBugDetail = (id: number) => api.get<ErrorLog>(`/api/v1/bugs/${id}`);
|
export const getBugDetail = (id: number) => api.get<ErrorLog>(`/api/v1/bugs/${id}`);
|
||||||
|
|
||||||
export const getProjects = () => api.get<{ projects: string[] }>('/api/v1/projects');
|
export const getProjects = () => api.get<{ projects: Project[] }>('/api/v1/projects');
|
||||||
|
|
||||||
|
export const getProjectDetail = (projectId: string) =>
|
||||||
|
api.get<Project>(`/api/v1/projects/${projectId}`);
|
||||||
|
|
||||||
|
export const updateProject = (projectId: string, data: Partial<Project>) =>
|
||||||
|
api.put<Project>(`/api/v1/projects/${projectId}`, data);
|
||||||
|
|
||||||
export const updateTaskStatus = (taskId: number, status: string, message?: string) =>
|
export const updateTaskStatus = (taskId: number, status: string, message?: string) =>
|
||||||
api.put(`/api/v1/tasks/${taskId}/status`, { status, message });
|
api.put(`/api/v1/tasks/${taskId}/status`, { status, message });
|
||||||
|
|||||||
@ -792,6 +792,24 @@ td a:hover {
|
|||||||
text-decoration: underline;
|
text-decoration: underline;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* ============ Edit Input ============ */
|
||||||
|
|
||||||
|
.edit-input {
|
||||||
|
width: 100%;
|
||||||
|
padding: 6px 10px;
|
||||||
|
background: var(--bg-primary);
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
border-radius: var(--radius-sm);
|
||||||
|
color: var(--text-primary);
|
||||||
|
font-size: 13px;
|
||||||
|
outline: none;
|
||||||
|
transition: border-color 0.15s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.edit-input:focus {
|
||||||
|
border-color: var(--accent);
|
||||||
|
}
|
||||||
|
|
||||||
/* ============ Trigger Repair Button ============ */
|
/* ============ Trigger Repair Button ============ */
|
||||||
|
|
||||||
.trigger-repair-btn {
|
.trigger-repair-btn {
|
||||||
|
|||||||
@ -1,6 +1,6 @@
|
|||||||
import { useState, useEffect } from 'react';
|
import { useState, useEffect } from 'react';
|
||||||
import { useParams, Link, useLocation } from 'react-router-dom';
|
import { useParams, Link, useLocation } from 'react-router-dom';
|
||||||
import { ArrowLeft, Play, Loader2, FileCode, GitCommit, History } from 'lucide-react';
|
import { ArrowLeft, Play, Loader2, FileCode, GitCommit, History, AlertTriangle } from 'lucide-react';
|
||||||
import { getBugDetail, triggerRepair, getRepairReportsByBug, type ErrorLog, type RepairReport } from '../api';
|
import { getBugDetail, triggerRepair, getRepairReportsByBug, type ErrorLog, type RepairReport } from '../api';
|
||||||
|
|
||||||
const SOURCE_LABELS: Record<string, string> = {
|
const SOURCE_LABELS: Record<string, string> = {
|
||||||
@ -206,6 +206,16 @@ export default function BugDetail() {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{bug.failure_reason && (
|
||||||
|
<div className="detail-card" style={{ borderLeft: '3px solid var(--error)' }}>
|
||||||
|
<div className="detail-section-title" style={{ marginBottom: '8px', display: 'flex', alignItems: 'center', gap: '6px', color: 'var(--error)' }}>
|
||||||
|
<AlertTriangle size={14} />
|
||||||
|
修复失败原因
|
||||||
|
</div>
|
||||||
|
<pre className="code-block error">{bug.failure_reason}</pre>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
<div className="detail-card">
|
<div className="detail-card">
|
||||||
<div className="detail-section-title" style={{ marginBottom: '12px' }}>元数据</div>
|
<div className="detail-section-title" style={{ marginBottom: '12px' }}>元数据</div>
|
||||||
<table className="meta-table">
|
<table className="meta-table">
|
||||||
|
|||||||
@ -1,6 +1,6 @@
|
|||||||
import { useState, useEffect, useCallback } from 'react';
|
import { useState, useEffect, useCallback } from 'react';
|
||||||
import { Link, useSearchParams } from 'react-router-dom';
|
import { Link, useSearchParams } from 'react-router-dom';
|
||||||
import { getBugs, getProjects, type ErrorLog } from '../api';
|
import { getBugs, getProjects, type ErrorLog, type Project } from '../api';
|
||||||
|
|
||||||
const STATUSES = [
|
const STATUSES = [
|
||||||
'NEW', 'VERIFYING', 'CANNOT_REPRODUCE', 'PENDING_FIX',
|
'NEW', 'VERIFYING', 'CANNOT_REPRODUCE', 'PENDING_FIX',
|
||||||
@ -31,7 +31,7 @@ export default function BugList() {
|
|||||||
const [bugs, setBugs] = useState<ErrorLog[]>([]);
|
const [bugs, setBugs] = useState<ErrorLog[]>([]);
|
||||||
const [loading, setLoading] = useState(true);
|
const [loading, setLoading] = useState(true);
|
||||||
const [totalPages, setTotalPages] = useState(1);
|
const [totalPages, setTotalPages] = useState(1);
|
||||||
const [projects, setProjects] = useState<string[]>([]);
|
const [projects, setProjects] = useState<Project[]>([]);
|
||||||
|
|
||||||
const currentProject = searchParams.get('project') || '';
|
const currentProject = searchParams.get('project') || '';
|
||||||
const currentStatus = searchParams.get('status') ?? 'NEW';
|
const currentStatus = searchParams.get('status') ?? 'NEW';
|
||||||
@ -106,11 +106,11 @@ export default function BugList() {
|
|||||||
</button>
|
</button>
|
||||||
{projects.map(p => (
|
{projects.map(p => (
|
||||||
<button
|
<button
|
||||||
key={p}
|
key={p.project_id}
|
||||||
className={`project-tab ${currentProject === p ? 'active' : ''}`}
|
className={`project-tab ${currentProject === p.project_id ? 'active' : ''}`}
|
||||||
onClick={() => updateParams({ project: p })}
|
onClick={() => updateParams({ project: p.project_id })}
|
||||||
>
|
>
|
||||||
{p}
|
{p.name || p.project_id}
|
||||||
</button>
|
</button>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
@ -210,6 +210,11 @@ export default function BugList() {
|
|||||||
<span className={`status-badge status-${bug.status}`}>
|
<span className={`status-badge status-${bug.status}`}>
|
||||||
{STATUS_LABELS[bug.status] || bug.status}
|
{STATUS_LABELS[bug.status] || bug.status}
|
||||||
</span>
|
</span>
|
||||||
|
{bug.status === 'FIX_FAILED' && bug.failure_reason && (
|
||||||
|
<div style={{ fontSize: '11px', color: 'var(--error)', marginTop: '4px', maxWidth: '200px', overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>
|
||||||
|
{bug.failure_reason}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
</td>
|
</td>
|
||||||
<td className="cell-secondary">
|
<td className="cell-secondary">
|
||||||
{new Date(bug.timestamp).toLocaleString()}
|
{new Date(bug.timestamp).toLocaleString()}
|
||||||
|
|||||||
258
web/src/pages/ProjectList.tsx
Normal file
258
web/src/pages/ProjectList.tsx
Normal file
@ -0,0 +1,258 @@
|
|||||||
|
import { useState, useEffect } from 'react';
|
||||||
|
import { getProjects, updateProject, type Project } from '../api';
|
||||||
|
import { Save, X, Pencil } from 'lucide-react';
|
||||||
|
|
||||||
|
export default function ProjectList() {
|
||||||
|
const [projects, setProjects] = useState<Project[]>([]);
|
||||||
|
const [loading, setLoading] = useState(true);
|
||||||
|
const [editingId, setEditingId] = useState<number | null>(null);
|
||||||
|
const [editForm, setEditForm] = useState({ name: '', repo_url: '', local_path: '', description: '' });
|
||||||
|
const [saving, setSaving] = useState(false);
|
||||||
|
|
||||||
|
const fetchProjects = async () => {
|
||||||
|
setLoading(true);
|
||||||
|
try {
|
||||||
|
const res = await getProjects();
|
||||||
|
setProjects(res.data.projects);
|
||||||
|
} catch (err) {
|
||||||
|
console.error(err);
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
useEffect(() => { fetchProjects(); }, []);
|
||||||
|
|
||||||
|
const startEdit = (p: Project) => {
|
||||||
|
setEditingId(p.id);
|
||||||
|
setEditForm({
|
||||||
|
name: p.name || '',
|
||||||
|
repo_url: p.repo_url || '',
|
||||||
|
local_path: p.local_path || '',
|
||||||
|
description: p.description || '',
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
const cancelEdit = () => {
|
||||||
|
setEditingId(null);
|
||||||
|
};
|
||||||
|
|
||||||
|
const saveEdit = async (projectId: string) => {
|
||||||
|
setSaving(true);
|
||||||
|
try {
|
||||||
|
await updateProject(projectId, editForm);
|
||||||
|
setEditingId(null);
|
||||||
|
await fetchProjects();
|
||||||
|
} catch (err) {
|
||||||
|
console.error('保存失败:', err);
|
||||||
|
} finally {
|
||||||
|
setSaving(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const ConfigBadge = ({ value }: { value: string | null }) => (
|
||||||
|
value ? (
|
||||||
|
<span style={{ fontSize: '12px', color: 'var(--text-secondary)', wordBreak: 'break-all' }}>{value}</span>
|
||||||
|
) : (
|
||||||
|
<span style={{ fontSize: '12px', color: 'var(--error)', fontWeight: 500 }}>未配置</span>
|
||||||
|
)
|
||||||
|
);
|
||||||
|
|
||||||
|
if (loading) {
|
||||||
|
return <div className="loading"><div className="spinner"></div></div>;
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
<div className="page-header">
|
||||||
|
<h1 className="page-title">项目管理</h1>
|
||||||
|
<p className="page-subtitle">管理项目的仓库地址和本地路径,供 Repair Agent 使用</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{projects.length === 0 ? (
|
||||||
|
<div className="empty-state">暂无项目,首次上报日志后会自动创建</div>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
{/* Desktop table */}
|
||||||
|
<div className="table-container">
|
||||||
|
<div className="table-desktop">
|
||||||
|
<table>
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th>项目 ID</th>
|
||||||
|
<th>名称</th>
|
||||||
|
<th>仓库地址</th>
|
||||||
|
<th>本地路径</th>
|
||||||
|
<th>描述</th>
|
||||||
|
<th>更新时间</th>
|
||||||
|
<th>操作</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{projects.map((p) => (
|
||||||
|
<tr key={p.id}>
|
||||||
|
{editingId === p.id ? (
|
||||||
|
<>
|
||||||
|
<td><strong>{p.project_id}</strong></td>
|
||||||
|
<td>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
value={editForm.name}
|
||||||
|
onChange={(e) => setEditForm({ ...editForm, name: e.target.value })}
|
||||||
|
placeholder="项目名称"
|
||||||
|
className="edit-input"
|
||||||
|
/>
|
||||||
|
</td>
|
||||||
|
<td>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
value={editForm.repo_url}
|
||||||
|
onChange={(e) => setEditForm({ ...editForm, repo_url: e.target.value })}
|
||||||
|
placeholder="https://gitea.example.com/..."
|
||||||
|
className="edit-input"
|
||||||
|
/>
|
||||||
|
</td>
|
||||||
|
<td>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
value={editForm.local_path}
|
||||||
|
onChange={(e) => setEditForm({ ...editForm, local_path: e.target.value })}
|
||||||
|
placeholder="/home/user/projects/..."
|
||||||
|
className="edit-input"
|
||||||
|
/>
|
||||||
|
</td>
|
||||||
|
<td>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
value={editForm.description}
|
||||||
|
onChange={(e) => setEditForm({ ...editForm, description: e.target.value })}
|
||||||
|
placeholder="项目描述"
|
||||||
|
className="edit-input"
|
||||||
|
/>
|
||||||
|
</td>
|
||||||
|
<td className="cell-secondary">
|
||||||
|
{new Date(p.updated_at).toLocaleString()}
|
||||||
|
</td>
|
||||||
|
<td>
|
||||||
|
<div style={{ display: 'flex', gap: '6px' }}>
|
||||||
|
<button
|
||||||
|
className="btn-link"
|
||||||
|
onClick={() => saveEdit(p.project_id)}
|
||||||
|
disabled={saving}
|
||||||
|
style={{ color: 'var(--success)' }}
|
||||||
|
>
|
||||||
|
<Save size={14} />
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
className="btn-link"
|
||||||
|
onClick={cancelEdit}
|
||||||
|
style={{ color: 'var(--text-tertiary)' }}
|
||||||
|
>
|
||||||
|
<X size={14} />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<td><strong>{p.project_id}</strong></td>
|
||||||
|
<td>{p.name || <span style={{ color: 'var(--text-tertiary)' }}>-</span>}</td>
|
||||||
|
<td><ConfigBadge value={p.repo_url} /></td>
|
||||||
|
<td><ConfigBadge value={p.local_path} /></td>
|
||||||
|
<td className="cell-secondary">{p.description || '-'}</td>
|
||||||
|
<td className="cell-secondary">
|
||||||
|
{new Date(p.updated_at).toLocaleString()}
|
||||||
|
</td>
|
||||||
|
<td>
|
||||||
|
<button className="btn-link" onClick={() => startEdit(p)}>
|
||||||
|
<Pencil size={14} /> 编辑
|
||||||
|
</button>
|
||||||
|
</td>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</tr>
|
||||||
|
))}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Mobile card list */}
|
||||||
|
<div className="mobile-card-list">
|
||||||
|
{projects.map((p) => (
|
||||||
|
<div key={p.id} className="mobile-card-item">
|
||||||
|
{editingId === p.id ? (
|
||||||
|
<div style={{ display: 'flex', flexDirection: 'column', gap: '8px' }}>
|
||||||
|
<strong>{p.project_id}</strong>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
value={editForm.name}
|
||||||
|
onChange={(e) => setEditForm({ ...editForm, name: e.target.value })}
|
||||||
|
placeholder="项目名称"
|
||||||
|
className="edit-input"
|
||||||
|
/>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
value={editForm.repo_url}
|
||||||
|
onChange={(e) => setEditForm({ ...editForm, repo_url: e.target.value })}
|
||||||
|
placeholder="仓库地址"
|
||||||
|
className="edit-input"
|
||||||
|
/>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
value={editForm.local_path}
|
||||||
|
onChange={(e) => setEditForm({ ...editForm, local_path: e.target.value })}
|
||||||
|
placeholder="本地路径"
|
||||||
|
className="edit-input"
|
||||||
|
/>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
value={editForm.description}
|
||||||
|
onChange={(e) => setEditForm({ ...editForm, description: e.target.value })}
|
||||||
|
placeholder="描述"
|
||||||
|
className="edit-input"
|
||||||
|
/>
|
||||||
|
<div style={{ display: 'flex', gap: '8px' }}>
|
||||||
|
<button
|
||||||
|
className="btn-link"
|
||||||
|
onClick={() => saveEdit(p.project_id)}
|
||||||
|
disabled={saving}
|
||||||
|
style={{ color: 'var(--success)' }}
|
||||||
|
>
|
||||||
|
<Save size={14} /> 保存
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
className="btn-link"
|
||||||
|
onClick={cancelEdit}
|
||||||
|
style={{ color: 'var(--text-tertiary)' }}
|
||||||
|
>
|
||||||
|
<X size={14} /> 取消
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<div className="mobile-card-top">
|
||||||
|
<strong>{p.project_id}</strong>
|
||||||
|
<button className="btn-link" onClick={() => startEdit(p)}>
|
||||||
|
<Pencil size={14} />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
{p.name && <div style={{ fontSize: '13px' }}>{p.name}</div>}
|
||||||
|
<div className="mobile-card-meta" style={{ flexDirection: 'column', alignItems: 'flex-start', gap: '4px', marginTop: '6px' }}>
|
||||||
|
<div>仓库: <ConfigBadge value={p.repo_url} /></div>
|
||||||
|
<div>路径: <ConfigBadge value={p.local_path} /></div>
|
||||||
|
</div>
|
||||||
|
{p.description && (
|
||||||
|
<div style={{ fontSize: '12px', color: 'var(--text-tertiary)', marginTop: '4px' }}>{p.description}</div>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@ -1,6 +1,6 @@
|
|||||||
import { useState, useEffect } from 'react';
|
import { useState, useEffect } from 'react';
|
||||||
import { Link } from 'react-router-dom';
|
import { Link } from 'react-router-dom';
|
||||||
import { getRepairReports, type RepairReport, getProjects } from '../api';
|
import { getRepairReports, type RepairReport, getProjects, type Project } from '../api';
|
||||||
|
|
||||||
const STATUS_LABELS: Record<string, string> = {
|
const STATUS_LABELS: Record<string, string> = {
|
||||||
NEW: '新发现',
|
NEW: '新发现',
|
||||||
@ -17,7 +17,7 @@ const STATUS_LABELS: Record<string, string> = {
|
|||||||
export default function RepairList() {
|
export default function RepairList() {
|
||||||
const [reports, setReports] = useState<RepairReport[]>([]);
|
const [reports, setReports] = useState<RepairReport[]>([]);
|
||||||
const [loading, setLoading] = useState(true);
|
const [loading, setLoading] = useState(true);
|
||||||
const [projects, setProjects] = useState<string[]>([]);
|
const [projects, setProjects] = useState<Project[]>([]);
|
||||||
const [filters, setFilters] = useState({
|
const [filters, setFilters] = useState({
|
||||||
project_id: '',
|
project_id: '',
|
||||||
page: 1,
|
page: 1,
|
||||||
@ -75,11 +75,11 @@ export default function RepairList() {
|
|||||||
</button>
|
</button>
|
||||||
{projects.map((p) => (
|
{projects.map((p) => (
|
||||||
<button
|
<button
|
||||||
key={p}
|
key={p.project_id}
|
||||||
className={`project-tab ${filters.project_id === p ? 'active' : ''}`}
|
className={`project-tab ${filters.project_id === p.project_id ? 'active' : ''}`}
|
||||||
onClick={() => handleFilterChange('project_id', p)}
|
onClick={() => handleFilterChange('project_id', p.project_id)}
|
||||||
>
|
>
|
||||||
{p}
|
{p.name || p.project_id}
|
||||||
</button>
|
</button>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
@ -105,6 +105,7 @@ export default function RepairList() {
|
|||||||
<th>修改文件数</th>
|
<th>修改文件数</th>
|
||||||
<th>测试结果</th>
|
<th>测试结果</th>
|
||||||
<th>状态</th>
|
<th>状态</th>
|
||||||
|
<th>失败原因</th>
|
||||||
<th>日期</th>
|
<th>日期</th>
|
||||||
<th>操作</th>
|
<th>操作</th>
|
||||||
</tr>
|
</tr>
|
||||||
@ -131,6 +132,9 @@ export default function RepairList() {
|
|||||||
{STATUS_LABELS[report.status] || report.status}
|
{STATUS_LABELS[report.status] || report.status}
|
||||||
</span>
|
</span>
|
||||||
</td>
|
</td>
|
||||||
|
<td style={{ maxWidth: '200px', overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap', fontSize: '12px', color: 'var(--error)' }}>
|
||||||
|
{report.failure_reason || '-'}
|
||||||
|
</td>
|
||||||
<td className="cell-secondary">
|
<td className="cell-secondary">
|
||||||
{new Date(report.created_at).toLocaleString()}
|
{new Date(report.created_at).toLocaleString()}
|
||||||
</td>
|
</td>
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user