feat(project-mgmt): 项目管理 + 失败原因追踪 + 前端展示
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:
zyc 2026-02-24 11:18:27 +08:00
parent 229d86e158
commit 625e53dc44
14 changed files with 953 additions and 72 deletions

View File

@ -32,6 +32,8 @@ async def init_db():
"ALTER TABLE errorlog ALTER COLUMN file_path DROP NOT NULL",
"ALTER TABLE errorlog ALTER COLUMN line_number DROP NOT NULL",
"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:
try:

View File

@ -3,7 +3,7 @@ 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, TaskStatusUpdate, RepairTask, RepairTaskCreate
from .models import ErrorLog, ErrorLogCreate, LogStatus, TaskStatusUpdate, RepairTask, RepairTaskCreate, Project, ProjectUpdate
from datetime import datetime, timedelta
from typing import Optional, List
import hashlib
@ -61,6 +61,17 @@ async def report_log(log_data: ErrorLogCreate, session: AsyncSession = Depends(g
await session.refresh(existing_log)
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
new_log = ErrorLog(
project_id=log_data.project_id,
@ -111,7 +122,8 @@ async def update_task_status(
raise HTTPException(status_code=404, detail="Task not found")
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)
await session.commit()
@ -129,13 +141,15 @@ async def create_repair_report(report: RepairTaskCreate, session: AsyncSession =
repair_task = RepairTask.from_orm(report)
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]:
log_stmt = select(ErrorLog).where(ErrorLog.id == report.error_log_id)
results = await session.exec(log_stmt)
error_log = results.first()
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)
await session.commit()
@ -298,14 +312,44 @@ async def get_bug_detail(bug_id: int, session: AsyncSession = Depends(get_sessio
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)):
"""Get list of all unique project IDs"""
query = select(ErrorLog.project_id).distinct()
"""Get list of all projects with full info"""
query = select(Project).order_by(Project.updated_at.desc())
results = await session.exec(query)
projects = results.all()
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"])
async def health_check():
return {"status": "ok"}

View File

@ -19,6 +19,24 @@ class LogStatus(str, Enum):
DEPLOYED = "DEPLOYED"
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):
id: Optional[int] = Field(default=None, primary_key=True)
project_id: str = Field(index=True)
@ -50,8 +68,8 @@ class ErrorLog(SQLModel, table=True):
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.
# Repair Tracking
failure_reason: Optional[str] = Field(default=None, sa_column=Column(Text, nullable=True))
# Pydantic Models for API
class ErrorLogCreate(SQLModel):
@ -61,6 +79,7 @@ class ErrorLogCreate(SQLModel):
timestamp: Optional[datetime] = None
version: Optional[str] = None
commit_hash: Optional[str] = None
repo_url: Optional[str] = None
source: str = "runtime"
error: Dict # {type, message, file_path, line_number, stack_trace}

View File

@ -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 地址 | 仪表盘 |
|------|----------|--------|
| 本地开发 | `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",
"version": "1.2.3",
"commit_hash": "abc1234",
"repo_url": "https://gitea.example.com/team/rtc_backend.git",
"error": {
"type": "ValueError",
"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` |
| `level` | string | ✅ | 日志级别:`ERROR`, `WARNING`, `CRITICAL` |
| `source` | string | ❌ | 来源:`runtime`(默认), `cicd`, `deployment` |
| `timestamp` | string | ❌ | ISO 8601 格式,不传则使用服务器时间 |
| `version` | string | ❌ | 应用版本号 |
| `commit_hash` | string | ❌ | Git commit hash |
| `repo_url` | string | ❌ | 项目仓库地址,首次上报时传入可自动关联到项目 |
| `error.type` | string | ✅ | 异常类型,如 `ValueError`, `TypeError` |
| `error.message` | string | ✅ | 错误消息 |
| `error.file_path` | string | ✅ | 出错文件路径 |
| `error.line_number` | int | ✅ | 出错行号 |
| `error.file_path` | string | ❌ | 出错文件路径runtime 必填cicd/deployment 可选) |
| `error.line_number` | int | ❌ | 出错行号runtime 必填cicd/deployment 可选) |
| `error.stack_trace` | array | ✅ | 堆栈信息(数组或字符串) |
| `context` | object | ❌ | 额外上下文信息URL、用户ID等 |
> **项目自动注册**: 首次上报日志时,系统会根据 `project_id` 自动创建项目记录。如果同时传入 `repo_url`,会自动关联仓库地址,供 Repair Agent 使用。
#### 响应
**成功 (200)**
```json
{
"status": "ok",
"id": 123,
"fingerprint": "a1b2c3d4e5f6",
"is_new": true
"message": "Log reported",
"id": 123
}
```
**已存在 (200)** - 重复错误自动去重
```json
{
"status": "duplicate",
"message": "Log deduplicated",
"id": 123,
"fingerprint": "a1b2c3d4e5f6",
"is_new": false
"status": "NEW"
}
```
---
### 项目管理 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)
@ -115,6 +178,7 @@ def report_error(exc, context=None):
"project_id": "rtc_backend",
"environment": os.getenv("ENVIRONMENT", "development"),
"level": "ERROR",
"repo_url": os.getenv("REPO_URL", ""), # 可选:关联仓库地址
"error": {
"type": type(exc).__name__,
"message": str(exc),
@ -161,6 +225,7 @@ interface ErrorPayload {
project_id: string;
environment: string;
level: string;
repo_url?: string;
error: {
type: string;
message: string;
@ -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` | 正在验证复现 |
| `CANNOT_REPRODUCE` | 无法复现 |
| `PENDING_FIX` | 等待修复 |
| `FIXING` | 正在修复中 |
| `FIXING` | AI Agent 正在修复中 |
| `FIXED` | 已修复,待验证 |
| `VERIFIED` | 已验证修复 |
| `DEPLOYED` | 已部署上线 |
| `FIX_FAILED` | 修复失败 |
| `FIX_FAILED` | 修复失败(失败原因会记录到数据库并在 Web 端展示) |
---
## Web 管理端
### 项目管理
访问 Web 管理端的「项目管理」页面,可以:
- 查看所有已注册项目及其配置状态
- 编辑项目的**仓库地址**`repo_url`)和**本地路径**`local_path`
- 未配置的字段会标红提示
> Repair Agent 依赖这两个配置来定位项目代码和执行 Git 操作。请确保在接入后及时配置。
### 缺陷追踪
- **缺陷列表**: 按项目、来源、状态筛选,修复失败的缺陷会直接显示失败原因
- **缺陷详情**: 查看完整错误信息、堆栈、上下文,以及修复历史记录
- **修复报告**: 查看每轮 AI 修复的详细过程(分析、代码变更、测试结果、失败原因)
---
## 最佳实践
1. **设置超时**: 上报请求设置 3 秒超时,避免影响主业务
2. **静默失败**: 上报失败不应影响用户体验
3. **异步上报**: 使用异步方式上报,不阻塞主流程
4. **添加上下文**: 尽量添加有用的上下文信息(用户ID、请求URL等)
5. **环境区分**: 正确设置 `environment` 字段区分开发/生产
1. **首次接入时传入 `repo_url`**: 在日志上报中包含仓库地址,省去手动配置步骤
2. **设置超时**: 上报请求设置 3 秒超时,避免影响主业务
3. **静默失败**: 上报失败不应影响用户体验
4. **异步上报**: 使用异步方式上报,不阻塞主流程
5. **添加上下文**: 尽量添加有用的上下文信息用户ID、请求URL等
6. **环境区分**: 正确设置 `environment` 字段区分开发/生产
7. **配置本地路径**: 接入后在 Web 端配置 `local_path`,使 Repair Agent 能正确定位代码
---
@ -284,6 +434,7 @@ NEW → VERIFYING → PENDING_FIX → FIXING → FIXED → VERIFIED → DEPLOYED
# .env
LOG_CENTER_URL=http://localhost:8002
ENVIRONMENT=development
REPO_URL=https://gitea.example.com/team/rtc_backend.git # 可选
```
### JavaScript 项目
@ -292,6 +443,13 @@ ENVIRONMENT=development
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 文档

View 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 + ProjectUpdateErrorLog 增加 failure_reason |
| `app/main.py` | 修改 | 项目 CRUD API修改 report_log / update_task_status / create_repair_report |
| `app/database.py` | 修改 | 追加迁移 SQLProject 表 + 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 → 确认失败原因正确展示

View File

@ -39,8 +39,11 @@ class RepairEngine:
"""
logger.info(f"开始修复项目: {project_id}")
# 获取项目路径
project_path = settings.get_project_path(project_id)
# 从 API 获取项目配置(优先),回退到 .env
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:
logger.error(f"未找到项目路径配置: {project_id}")
return BatchFixResult(
@ -65,12 +68,12 @@ class RepairEngine:
logger.info(f"获取到 {len(bugs)} 个待修复 Bug")
# 检查是否启用 Git
git_enabled = settings.is_git_enabled(project_id)
# 检查是否启用 GitAPI 优先,回退 .env
github_repo = api_repo_url or settings.get_github_repo(project_id)
git_enabled = bool(github_repo)
git_manager = None
if git_enabled:
github_repo = settings.get_github_repo(project_id)
git_manager = GitManager(project_path, github_repo=github_repo)
git_manager.pull()
@ -78,7 +81,7 @@ class RepairEngine:
if auto_commit:
git_manager.create_branch(branch_name)
else:
logger.info(f"项目 {project_id} 未配置 GitHub 仓库,跳过 Git 操作")
logger.info(f"项目 {project_id} 未配置仓库地址,跳过 Git 操作")
# 更新所有 Bug 状态为 FIXING
for bug in bugs:
@ -302,7 +305,11 @@ class RepairEngine:
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:
return FixResult(
bug_id=bug_id,
@ -313,7 +320,7 @@ class RepairEngine:
self.task_manager.update_status(bug_id, BugStatus.FIXING)
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_diff = ""

View File

@ -149,6 +149,16 @@ class TaskManager:
logger.error(f"上传修复报告失败: {e}")
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):
"""关闭连接"""
self.client.close()

View File

@ -1,11 +1,12 @@
import { useState, useEffect } from 'react';
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 BugList from './pages/BugList';
import BugDetail from './pages/BugDetail';
import RepairList from './pages/RepairList';
import RepairDetail from './pages/RepairDetail';
import ProjectList from './pages/ProjectList';
import './index.css';
function AppLayout() {
@ -76,6 +77,15 @@ function AppLayout() {
</NavLink>
</li>
<li className="nav-item">
<NavLink
to="/projects"
className={({ isActive }) => `nav-link ${isActive ? 'active' : ''}`}
>
<FolderGit2 size={16} />
</NavLink>
</li>
</ul>
</nav>
</aside>
@ -87,6 +97,7 @@ function AppLayout() {
<Route path="/bugs/:id" element={<BugDetail />} />
<Route path="/repairs" element={<RepairList />} />
<Route path="/repairs/:id" element={<RepairDetail />} />
<Route path="/projects" element={<ProjectList />} />
</Routes>
</main>
</div>

View File

@ -26,6 +26,18 @@ export interface ErrorLog {
fingerprint: string;
status: string;
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 {
@ -73,7 +85,13 @@ export const getBugs = (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 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) =>
api.put(`/api/v1/tasks/${taskId}/status`, { status, message });

View File

@ -792,6 +792,24 @@ td a:hover {
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-btn {

View File

@ -1,6 +1,6 @@
import { useState, useEffect } from 'react';
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';
const SOURCE_LABELS: Record<string, string> = {
@ -206,6 +206,16 @@ export default function BugDetail() {
</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-section-title" style={{ marginBottom: '12px' }}></div>
<table className="meta-table">

View File

@ -1,6 +1,6 @@
import { useState, useEffect, useCallback } from 'react';
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 = [
'NEW', 'VERIFYING', 'CANNOT_REPRODUCE', 'PENDING_FIX',
@ -31,7 +31,7 @@ export default function BugList() {
const [bugs, setBugs] = useState<ErrorLog[]>([]);
const [loading, setLoading] = useState(true);
const [totalPages, setTotalPages] = useState(1);
const [projects, setProjects] = useState<string[]>([]);
const [projects, setProjects] = useState<Project[]>([]);
const currentProject = searchParams.get('project') || '';
const currentStatus = searchParams.get('status') ?? 'NEW';
@ -106,11 +106,11 @@ export default function BugList() {
</button>
{projects.map(p => (
<button
key={p}
className={`project-tab ${currentProject === p ? 'active' : ''}`}
onClick={() => updateParams({ project: p })}
key={p.project_id}
className={`project-tab ${currentProject === p.project_id ? 'active' : ''}`}
onClick={() => updateParams({ project: p.project_id })}
>
{p}
{p.name || p.project_id}
</button>
))}
</div>
@ -210,6 +210,11 @@ export default function BugList() {
<span className={`status-badge status-${bug.status}`}>
{STATUS_LABELS[bug.status] || bug.status}
</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 className="cell-secondary">
{new Date(bug.timestamp).toLocaleString()}

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

View File

@ -1,6 +1,6 @@
import { useState, useEffect } from 'react';
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> = {
NEW: '新发现',
@ -17,7 +17,7 @@ const STATUS_LABELS: Record<string, string> = {
export default function RepairList() {
const [reports, setReports] = useState<RepairReport[]>([]);
const [loading, setLoading] = useState(true);
const [projects, setProjects] = useState<string[]>([]);
const [projects, setProjects] = useState<Project[]>([]);
const [filters, setFilters] = useState({
project_id: '',
page: 1,
@ -75,11 +75,11 @@ export default function RepairList() {
</button>
{projects.map((p) => (
<button
key={p}
className={`project-tab ${filters.project_id === p ? 'active' : ''}`}
onClick={() => handleFilterChange('project_id', p)}
key={p.project_id}
className={`project-tab ${filters.project_id === p.project_id ? 'active' : ''}`}
onClick={() => handleFilterChange('project_id', p.project_id)}
>
{p}
{p.name || p.project_id}
</button>
))}
</div>
@ -105,6 +105,7 @@ export default function RepairList() {
<th></th>
<th></th>
<th></th>
<th></th>
<th></th>
<th></th>
</tr>
@ -131,6 +132,9 @@ export default function RepairList() {
{STATUS_LABELS[report.status] || report.status}
</span>
</td>
<td style={{ maxWidth: '200px', overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap', fontSize: '12px', color: 'var(--error)' }}>
{report.failure_reason || '-'}
</td>
<td className="cell-secondary">
{new Date(report.created_at).toLocaleString()}
</td>