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 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:
|
||||
|
||||
56
app/main.py
56
app/main.py
@ -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"}
|
||||
|
||||
@ -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}
|
||||
|
||||
@ -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 文档
|
||||
|
||||
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 → 确认失败原因正确展示
|
||||
@ -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)
|
||||
# 检查是否启用 Git(API 优先,回退 .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 = ""
|
||||
|
||||
|
||||
@ -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()
|
||||
|
||||
@ -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>
|
||||
|
||||
@ -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 });
|
||||
|
||||
@ -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 {
|
||||
|
||||
@ -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">
|
||||
|
||||
@ -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()}
|
||||
|
||||
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 { 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>
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user