feat: 扩展日志收集,支持 CI/CD 构建错误和 K8s 部署错误
All checks were successful
Build and Deploy Log Center / build-and-deploy (push) Successful in 2m24s

新增两种日志来源(cicd / deployment),使日志中台覆盖"构建→部署→运行"全链路:

后端变更:
- models.py: 新增 LogSource 枚举和 source 字段,file_path/line_number 改为可选
- main.py: 按来源生成不同指纹策略,所有查询端点支持 source 筛选,仪表盘新增来源分布统计
- database.py: 新增 4 条迁移 SQL(source 字段、索引、字段可空)
- task_manager.py: 修复 Agent 仅拉取 runtime 来源的缺陷

新增组件:
- k8s-monitor/: K8s Pod 健康监控脚本(Python),每 5 分钟检测异常 Pod 并上报
- k8s/monitor-cronjob.yaml: CronJob + RBAC 部署清单
- scripts/report-cicd-error.sh: CI/CD 错误上报 Bash 脚本
- scripts/gitea-actions-example.yaml: Gitea Actions 集成示例

前端变更:
- api.ts: 类型定义更新,支持 source 字段
- BugList.tsx: 新增来源筛选标签页和来源列
- BugDetail.tsx: 按来源条件渲染(CI/CD 信息、部署信息),非 runtime 禁用修复按钮
- Dashboard.tsx: 新增来源分布表格
- index.css: 来源标签样式(source-badge)

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
zyc 2026-02-24 10:20:16 +08:00
parent e9ba36db92
commit 0d4b2d634c
18 changed files with 1645 additions and 221 deletions

View File

@ -27,6 +27,11 @@ async def init_db():
migrations = [
"ALTER TABLE repairtask ADD COLUMN repair_round INTEGER DEFAULT 1",
"ALTER TABLE repairtask ADD COLUMN failure_reason TEXT",
# Log source support
"ALTER TABLE errorlog ADD COLUMN source VARCHAR(20) DEFAULT 'runtime'",
"ALTER TABLE errorlog ALTER COLUMN file_path DROP NOT NULL",
"ALTER TABLE errorlog ALTER COLUMN line_number DROP NOT NULL",
"CREATE INDEX ix_errorlog_source ON errorlog (source)",
]
for sql in migrations:
try:

View File

@ -25,8 +25,17 @@ async def on_startup():
await init_db()
def generate_fingerprint(log: ErrorLogCreate) -> str:
# Minimal fingerprinting: project + error_type + file + line
raw = f"{log.project_id}|{log.error.get('type')}|{log.error.get('file_path')}|{log.error.get('line_number')}"
source = log.source
if source == "cicd":
ctx = log.context or {}
raw = f"{log.project_id}|cicd|{log.error.get('type')}|{ctx.get('job_name', 'unknown')}|{ctx.get('step_name', 'unknown')}"
elif source == "deployment":
ctx = log.context or {}
raw = f"{log.project_id}|deployment|{log.error.get('type')}|{ctx.get('namespace', 'default')}|{ctx.get('deployment_name', 'unknown')}"
else:
raw = f"{log.project_id}|{log.error.get('type')}|{log.error.get('file_path')}|{log.error.get('line_number')}"
return hashlib.md5(raw.encode()).hexdigest()
# ==================== Log Reporting ====================
@ -57,6 +66,7 @@ async def report_log(log_data: ErrorLogCreate, session: AsyncSession = Depends(g
project_id=log_data.project_id,
environment=log_data.environment,
level=log_data.level,
source=log_data.source,
error_type=log_data.error.get("type"),
error_message=log_data.error.get("message"),
file_path=log_data.error.get("file_path"),
@ -77,10 +87,12 @@ async def report_log(log_data: ErrorLogCreate, session: AsyncSession = Depends(g
# ==================== Agent Tasks ====================
@app.get("/api/v1/tasks/pending", tags=["Tasks"])
async def get_pending_tasks(project_id: str = None, session: AsyncSession = Depends(get_session)):
async def get_pending_tasks(project_id: str = None, source: Optional[str] = None, session: AsyncSession = Depends(get_session)):
query = select(ErrorLog).where(ErrorLog.status == LogStatus.NEW)
if project_id:
query = query.where(ErrorLog.project_id == project_id)
if source:
query = query.where(ErrorLog.source == source)
results = await session.exec(query)
return results.all()
@ -185,24 +197,27 @@ async def get_repair_report_detail(report_id: int, session: AsyncSession = Depen
# ==================== Dashboard APIs ====================
@app.get("/api/v1/dashboard/stats", tags=["Dashboard"])
async def get_dashboard_stats(session: AsyncSession = Depends(get_session)):
async def get_dashboard_stats(source: Optional[str] = None, session: AsyncSession = Depends(get_session)):
"""Get overall statistics for dashboard"""
today = datetime.utcnow().replace(hour=0, minute=0, second=0, microsecond=0)
def _apply_source(q):
return q.where(ErrorLog.source == source) if source else q
# Total bugs
total_query = select(func.count(ErrorLog.id))
total_query = _apply_source(select(func.count(ErrorLog.id)))
total_result = await session.exec(total_query)
total_bugs = total_result.one()
# Today's new bugs
today_query = select(func.count(ErrorLog.id)).where(ErrorLog.timestamp >= today)
today_query = _apply_source(select(func.count(ErrorLog.id)).where(ErrorLog.timestamp >= today))
today_result = await session.exec(today_query)
today_bugs = today_result.one()
# Count by status
status_counts = {}
for status in LogStatus:
count_query = select(func.count(ErrorLog.id)).where(ErrorLog.status == status)
count_query = _apply_source(select(func.count(ErrorLog.id)).where(ErrorLog.status == status))
count_result = await session.exec(count_query)
status_counts[status.value] = count_result.one()
@ -210,11 +225,20 @@ async def get_dashboard_stats(session: AsyncSession = Depends(get_session)):
fixed_count = status_counts.get("FIXED", 0) + status_counts.get("VERIFIED", 0) + status_counts.get("DEPLOYED", 0)
fix_rate = round((fixed_count / total_bugs * 100), 2) if total_bugs > 0 else 0
# Source distribution
from .models import LogSource
source_counts = {}
for src in LogSource:
sq = select(func.count(ErrorLog.id)).where(ErrorLog.source == src.value)
sr = await session.exec(sq)
source_counts[src.value] = sr.one()
return {
"total_bugs": total_bugs,
"today_bugs": today_bugs,
"fix_rate": fix_rate,
"status_distribution": status_counts
"status_distribution": status_counts,
"source_distribution": source_counts,
}
@app.get("/api/v1/bugs", tags=["Dashboard"])
@ -223,6 +247,7 @@ async def get_bugs_list(
page_size: int = Query(20, ge=1, le=100),
status: Optional[LogStatus] = None,
project_id: Optional[str] = None,
source: Optional[str] = None,
session: AsyncSession = Depends(get_session)
):
"""Get paginated list of bugs with optional filters"""
@ -232,6 +257,8 @@ async def get_bugs_list(
query = query.where(ErrorLog.status == status)
if project_id:
query = query.where(ErrorLog.project_id == project_id)
if source:
query = query.where(ErrorLog.source == source)
# Pagination
offset = (page - 1) * page_size
@ -246,6 +273,8 @@ async def get_bugs_list(
count_query = count_query.where(ErrorLog.status == status)
if project_id:
count_query = count_query.where(ErrorLog.project_id == project_id)
if source:
count_query = count_query.where(ErrorLog.source == source)
count_result = await session.exec(count_query)
total = count_result.one()

View File

@ -3,6 +3,11 @@ from typing import Optional, Dict, List
from sqlmodel import SQLModel, Field, Column, JSON, Text
from enum import Enum
class LogSource(str, Enum):
RUNTIME = "runtime"
CICD = "cicd"
DEPLOYMENT = "deployment"
class LogStatus(str, Enum):
NEW = "NEW"
VERIFYING = "VERIFYING"
@ -20,11 +25,14 @@ class ErrorLog(SQLModel, table=True):
environment: str
level: str
# Source
source: str = Field(default="runtime", index=True)
# Error Details
error_type: str
error_message: str
file_path: str
line_number: int
file_path: Optional[str] = None
line_number: Optional[int] = None
stack_trace: str = Field(sa_column=Column(JSON)) # Store full stack trace
# Context
@ -54,6 +62,7 @@ class ErrorLogCreate(SQLModel):
version: Optional[str] = None
commit_hash: Optional[str] = None
source: str = "runtime"
error: Dict # {type, message, file_path, line_number, stack_trace}
context: Optional[Dict] = {}

View File

@ -0,0 +1,197 @@
# Log Center 日志收集扩展方案
## 背景
当前 Log Center 仅通过 `POST /api/v1/logs/report` 收集**运行时错误**(应用代码中捕获的异常)。以下两类重要错误场景未被覆盖:
1. **CI/CD 构建错误** — Gitea Actions 构建失败Docker build、npm build、测试失败等
2. **部署错误** — K3s 集群中 Pod 异常状态CrashLoopBackOff、OOMKilled、ImagePullBackOff 等)
目标:扩展 Log Center使其成为覆盖"构建→部署→运行"全链路的错误收集平台。
---
## 实施方案
### 第一部分:后端 API 扩展
#### 1.1 模型变更 — `app/models.py`
- 新增 `LogSource` 枚举:`runtime`(默认) / `cicd` / `deployment`
- `ErrorLog` 表新增 `source` 字段(带索引,默认 `runtime`
- `file_path` 改为 `Optional[str] = None`CI/CD 和部署错误无文件路径)
- `line_number` 改为 `Optional[int] = None`
- `ErrorLogCreate` 新增 `source: str = "runtime"` 字段
> 向后兼容:现有调用方不传 `source` 时自动为 `runtime`,无需任何修改。
#### 1.2 指纹去重逻辑 — `app/main.py:27` `generate_fingerprint()`
`source` 分别计算指纹:
| Source | 指纹组成 | 去重逻辑 |
|--------|----------|----------|
| `runtime` | `project_id \| error_type \| file_path \| line_number` | 不变 |
| `cicd` | `project_id \| cicd \| error_type \| job_name \| step_name` | 同一 Job 的同一 Step 同类错误去重 |
| `deployment` | `project_id \| deployment \| error_type \| namespace \| deployment_name` | 同一 Deployment 同类异常去重 |
#### 1.3 API 端点变更 — `app/main.py`
| 端点 | 变更 |
|------|------|
| `POST /api/v1/logs/report` (L33) | 构建 `ErrorLog` 时写入 `source` 字段 |
| `GET /api/v1/bugs` (L220) | 新增 `source` 可选查询参数,加筛选条件 |
| `GET /api/v1/tasks/pending` (L79) | 新增 `source` 可选查询参数 |
| `GET /api/v1/dashboard/stats` (L187) | 新增 `source` 过滤 + 返回 `source_distribution` |
#### 1.4 数据库迁移 — `app/database.py:27` `init_db()` 的 migrations 列表追加
```sql
ALTER TABLE errorlog ADD COLUMN source VARCHAR(20) DEFAULT 'runtime';
ALTER TABLE errorlog ALTER COLUMN file_path DROP NOT NULL;
ALTER TABLE errorlog ALTER COLUMN line_number DROP NOT NULL;
CREATE INDEX ix_errorlog_source ON errorlog (source);
```
沿用现有 try/except 幂等模式,零停机。
---
### 第二部分CI/CD 构建错误上报
#### 2.1 上报脚本 — 新建 `scripts/report-cicd-error.sh`
Bash 脚本CI 环境天然有 `curl` + `jq`,无需额外依赖。
- 输入:`project_id``step_name`、错误消息或日志文件路径
- 自动读取 Gitea Actions 环境变量(`GITHUB_WORKFLOW``GITHUB_JOB``GITHUB_SHA` 等)
- 根据 step 名称推断 `error_type`DockerBuildError / NpmBuildError / TestFailure 等)
- 发送到 `/api/v1/logs/report``source: "cicd"`
- 超时 10s失败静默不影响流水线
#### 2.2 Gitea Actions 集成示例 — 新建 `scripts/gitea-actions-example.yaml`
在构建步骤后加 `if: failure()` 条件步骤:
```yaml
- name: Report Build Failure
if: failure()
run: |
curl -s -X POST "${LOG_CENTER_URL}/api/v1/logs/report" \
-H "Content-Type: application/json" \
-d '{ "project_id": "xxx", "source": "cicd", ... }' \
--max-time 10 || true
```
---
### 第三部分K8s 部署错误监控CronJob
#### 3.1 目录结构
```
log_center/k8s-monitor/
├── monitor.py # 监控脚本
├── Dockerfile # 镜像构建
└── requirements.txt # kubernetes + requests
```
#### 3.2 监控脚本 — `k8s-monitor/monitor.py`
- 使用 `kubernetes` Python 客户端(支持 in-cluster config
- 扫描指定命名空间的所有 Pod
- 检测异常状态:`CrashLoopBackOff``ImagePullBackOff``OOMKilled``Error``CreateContainerConfigError`
- 通过 Pod label `app` 映射到 `project_id`(如 `rtc-backend``rtc_backend`
- 获取容器日志(优先 `previous=True` 拿崩溃前日志)
- 上报到 `/api/v1/logs/report``source: "deployment"``level: "CRITICAL"`
- context 携带namespace、pod_name、container_name、deployment_name、restart_count
#### 3.3 K8s 部署 — 新建 `k8s/monitor-cronjob.yaml`
- CronJob每 5 分钟执行(`*/5 * * * *`
- RBACServiceAccount + ClusterRole`pods``pods/log` 的 get/list 权限)
- 资源限制64Mi/50m → 128Mi/100m
- 参考 `rtc_backend/k8s/backend-deployment-prod.yaml` 的 label 和格式风格
---
### 第四部分:前端 Dashboard 更新
#### 4.1 类型定义 — `web/src/api.ts`
- `ErrorLog` 接口新增 `source: string``file_path``line_number` 改为可选
- `getBugs` 参数新增 `source?: string`
- `getStats` 支持 `source` 参数
- `DashboardStats` 新增 `source_distribution`
#### 4.2 缺陷列表 — `web/src/pages/BugList.tsx`
- 在项目筛选和状态筛选之间新增**来源筛选**行(全部来源 / 运行时 / CI/CD / 部署)
- 表格新增「来源」列,显示彩色标签
- 「文件」列处理 null`file_path ? \`${file_path}:${line_number}\` : '-'`
#### 4.3 缺陷详情 — `web/src/pages/BugDetail.tsx`
- 元信息新增来源显示
- 文件位置区域条件渲染(仅 runtime 有 file_path 时显示)
- CI/CD 错误显示工作流名称、Job、Step、CI 日志链接
- 部署错误显示命名空间、Pod 名称、容器、重启次数
- 非 runtime 错误禁用「触发修复」按钮,显示提示
#### 4.4 仪表盘 — `web/src/pages/Dashboard.tsx`
- 新增「来源分布」表格(运行时/CI/CD/部署各多少条)
#### 4.5 样式 — `web/src/index.css`
- 新增 `.source-badge``.source-runtime` / `.source-cicd` / `.source-deployment` 颜色
- 新增 `.source-tabs` 筛选行样式(复用 `.project-tab` 风格)
---
### 第五部分Repair Agent 适配
#### 5.1 仅修改一处 — `repair_agent/agent/task_manager.py:33`
`fetch_pending_bugs()` 的请求参数增加 `"source": "runtime"`,确保修复 Agent 只拉取运行时错误,忽略 CI/CD 和部署错误。
---
## 文件变更清单
| 文件 | 操作 | 说明 |
|------|------|------|
| `app/models.py` | 修改 | LogSource 枚举、source 字段、可选字段 |
| `app/main.py` | 修改 | 指纹逻辑、source 过滤、stats 扩展 |
| `app/database.py` | 修改 | 追加 4 条迁移 SQL |
| `web/src/api.ts` | 修改 | 类型 + API 函数 |
| `web/src/pages/BugList.tsx` | 修改 | 来源筛选 + 来源列 |
| `web/src/pages/BugDetail.tsx` | 修改 | 来源展示 + 条件渲染 |
| `web/src/pages/Dashboard.tsx` | 修改 | 来源分布表格 |
| `web/src/index.css` | 修改 | 来源标签样式 |
| `repair_agent/agent/task_manager.py` | 修改 | 加 source=runtime 过滤 |
| `scripts/report-cicd-error.sh` | **新建** | CI/CD 错误上报脚本 |
| `scripts/gitea-actions-example.yaml` | **新建** | Gitea Actions 集成示例 |
| `k8s-monitor/monitor.py` | **新建** | K8s Pod 监控脚本 |
| `k8s-monitor/Dockerfile` | **新建** | 监控镜像 |
| `k8s-monitor/requirements.txt` | **新建** | Python 依赖 |
| `k8s/monitor-cronjob.yaml` | **新建** | CronJob + RBAC |
---
## 验证方案
1. **后端**:启动 API分别用 `curl` 发送 runtime/cicd/deployment 三种 source 的错误,验证入库和去重
2. **前端**:访问 Dashboard验证来源筛选和来源标签显示正常
3. **CI/CD**:在某项目的 Gitea Actions 中故意制造构建失败,确认 Log Center 收到 cicd 错误
4. **K8s Monitor**:本地用 `kubectl` 模拟 CrashLoopBackOff Pod运行 monitor.py确认上报
5. **Repair Agent**:运行 `python -m repair_agent list`,确认只返回 runtime 来源的 Bug
---
## 实施顺序
1. 后端变更(模型 + API + 迁移)→ 部署
2. 前端更新 → 部署
3. CI/CD 上报脚本 → 提交到仓库,集成到各项目流水线
4. K8s Monitor → 构建镜像,部署 CronJob

13
k8s-monitor/Dockerfile Normal file
View File

@ -0,0 +1,13 @@
FROM python:3.12-slim
WORKDIR /app
RUN pip config set global.index-url https://mirrors.aliyun.com/pypi/simple/ \
&& pip config set install.trusted-host mirrors.aliyun.com
COPY requirements.txt .
RUN pip install --no-cache-dir -r requirements.txt
COPY monitor.py .
CMD ["python", "monitor.py"]

155
k8s-monitor/monitor.py Normal file
View File

@ -0,0 +1,155 @@
"""
K8s Pod 健康监控 - 扫描异常 Pod 并上报到 Log Center
作为 K8s CronJob 5 分钟运行一次
"""
import os
import logging
import requests
from kubernetes import client, config
LOG_CENTER_URL = os.getenv("LOG_CENTER_URL", "https://qiyuan-log-center-api.airlabs.art")
NAMESPACE = os.getenv("MONITOR_NAMESPACE", "default")
# 需要监控的异常状态
ABNORMAL_STATES = {
"CrashLoopBackOff",
"ImagePullBackOff",
"ErrImagePull",
"OOMKilled",
"Error",
"CreateContainerConfigError",
"InvalidImageName",
"RunContainerError",
}
# Pod label app -> project_id 映射
APP_TO_PROJECT = {
"rtc-backend": "rtc_backend",
"rtc-backend-dev": "rtc_backend",
"rtc-web": "rtc_web",
"rtc-web-dev": "rtc_web",
"log-center-api": "log_center",
"log-center-web": "log_center",
}
logging.basicConfig(level=logging.INFO, format="%(asctime)s [%(levelname)s] %(message)s")
logger = logging.getLogger(__name__)
def get_project_id(pod_labels: dict) -> str:
app_name = pod_labels.get("app", "unknown")
return APP_TO_PROJECT.get(app_name, app_name)
def check_pod_health(pod) -> list[dict]:
"""检查单个 Pod 的异常状态,返回错误列表"""
errors = []
pod_name = pod.metadata.name
namespace = pod.metadata.namespace
labels = pod.metadata.labels or {}
if not pod.status or not pod.status.container_statuses:
return errors
for cs in pod.status.container_statuses:
state = cs.state
reason = None
message = None
if state.waiting:
reason = state.waiting.reason
message = state.waiting.message or ""
elif state.terminated and state.terminated.reason in ABNORMAL_STATES:
reason = state.terminated.reason
message = state.terminated.message or ""
if reason and reason in ABNORMAL_STATES:
errors.append({
"project_id": get_project_id(labels),
"reason": reason,
"message": message,
"pod_name": pod_name,
"container_name": cs.name,
"namespace": namespace,
"deployment_name": labels.get("app", pod_name),
"restart_count": cs.restart_count,
"node_name": pod.spec.node_name,
})
return errors
def get_container_logs(v1, pod_name: str, namespace: str, container: str, lines: int = 50) -> str:
"""获取容器日志,优先拿崩溃前的日志"""
try:
return v1.read_namespaced_pod_log(
name=pod_name, namespace=namespace, container=container,
tail_lines=lines, previous=True,
)
except Exception:
try:
return v1.read_namespaced_pod_log(
name=pod_name, namespace=namespace, container=container,
tail_lines=lines,
)
except Exception as e:
return f"获取日志失败: {e}"
def report_error(error: dict, logs: str):
"""上报错误到 Log Center"""
payload = {
"project_id": error["project_id"],
"environment": "production",
"level": "CRITICAL",
"source": "deployment",
"error": {
"type": error["reason"],
"message": f"{error['reason']}: {error['message']} (pod: {error['pod_name']}, container: {error['container_name']})",
"file_path": None,
"line_number": None,
"stack_trace": logs.split("\n")[-50:] if logs else [],
},
"context": {
"namespace": error["namespace"],
"pod_name": error["pod_name"],
"container_name": error["container_name"],
"deployment_name": error["deployment_name"],
"restart_count": error["restart_count"],
"node_name": error["node_name"],
},
}
try:
resp = requests.post(
f"{LOG_CENTER_URL}/api/v1/logs/report",
json=payload,
timeout=10,
)
logger.info(f"上报 {error['reason']} ({error['pod_name']}): {resp.json()}")
except Exception as e:
logger.error(f"上报失败: {e}")
def main():
try:
config.load_incluster_config()
except config.ConfigException:
config.load_kube_config()
v1 = client.CoreV1Api()
pods = v1.list_namespaced_pod(namespace=NAMESPACE)
total_errors = 0
for pod in pods.items:
errors = check_pod_health(pod)
for error in errors:
logs = get_container_logs(v1, error["pod_name"], error["namespace"], error["container_name"])
report_error(error, logs)
total_errors += 1
logger.info(f"扫描完成,命名空间 '{NAMESPACE}' 发现 {total_errors} 个异常 Pod")
if __name__ == "__main__":
main()

View File

@ -0,0 +1,2 @@
kubernetes>=28.1.0
requests>=2.31.0

60
k8s/monitor-cronjob.yaml Normal file
View File

@ -0,0 +1,60 @@
apiVersion: batch/v1
kind: CronJob
metadata:
name: k8s-pod-monitor
labels:
app: k8s-pod-monitor
spec:
schedule: "*/5 * * * *"
concurrencyPolicy: Forbid
successfulJobsHistoryLimit: 3
failedJobsHistoryLimit: 3
jobTemplate:
spec:
template:
spec:
serviceAccountName: pod-monitor
restartPolicy: Never
containers:
- name: monitor
image: ${CI_REGISTRY_IMAGE}/k8s-pod-monitor:latest
imagePullPolicy: Always
env:
- name: LOG_CENTER_URL
value: "https://qiyuan-log-center-api.airlabs.art"
- name: MONITOR_NAMESPACE
value: "default"
resources:
requests:
memory: "64Mi"
cpu: "50m"
limits:
memory: "128Mi"
cpu: "100m"
---
apiVersion: v1
kind: ServiceAccount
metadata:
name: pod-monitor
---
apiVersion: rbac.authorization.k8s.io/v1
kind: ClusterRole
metadata:
name: pod-monitor
rules:
- apiGroups: [""]
resources: ["pods", "pods/log"]
verbs: ["get", "list"]
---
apiVersion: rbac.authorization.k8s.io/v1
kind: ClusterRoleBinding
metadata:
name: pod-monitor
subjects:
- kind: ServiceAccount
name: pod-monitor
namespace: default
roleRef:
kind: ClusterRole
name: pod-monitor
apiGroup: rbac.authorization.k8s.io

View File

@ -30,7 +30,7 @@ class TaskManager:
for status in ("NEW", "PENDING_FIX"):
try:
params: dict[str, str] = {"status": status}
params: dict[str, str] = {"status": status, "source": "runtime"}
if project_id:
params["project_id"] = project_id

View File

@ -0,0 +1,87 @@
# Gitea Actions 集成示例 - 构建失败时自动上报到 Log Center
#
# 将以下步骤添加到你的 .gitea/workflows/deploy.yaml 中
# 放在可能失败的构建步骤之后
name: Build and Deploy
on:
push:
branches: [main]
env:
LOG_CENTER_URL: https://qiyuan-log-center-api.airlabs.art
jobs:
build:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
# ===== 你的构建步骤 =====
- name: Build Docker Image
id: build
run: |
docker build -t myapp:latest .
- name: Run Tests
id: test
run: |
docker run myapp:latest python -m pytest
# ===== 失败上报步骤 =====
- name: Report Build Failure
if: failure() && steps.build.outcome == 'failure'
run: |
curl -s -X POST "${LOG_CENTER_URL}/api/v1/logs/report" \
-H "Content-Type: application/json" \
-d '{
"project_id": "'"${GITHUB_REPOSITORY##*/}"'",
"environment": "cicd",
"level": "ERROR",
"source": "cicd",
"commit_hash": "'"$GITHUB_SHA"'",
"error": {
"type": "DockerBuildError",
"message": "Docker build failed",
"file_path": null,
"line_number": null,
"stack_trace": ["Build step failed. Check CI logs for details."]
},
"context": {
"workflow_name": "'"$GITHUB_WORKFLOW"'",
"job_name": "'"$GITHUB_JOB"'",
"step_name": "Build Docker Image",
"run_id": "'"$GITHUB_RUN_ID"'",
"branch": "'"$GITHUB_REF_NAME"'",
"repository": "'"$GITHUB_REPOSITORY"'"
}
}' --connect-timeout 5 --max-time 10 || true
- name: Report Test Failure
if: failure() && steps.test.outcome == 'failure'
run: |
curl -s -X POST "${LOG_CENTER_URL}/api/v1/logs/report" \
-H "Content-Type: application/json" \
-d '{
"project_id": "'"${GITHUB_REPOSITORY##*/}"'",
"environment": "cicd",
"level": "ERROR",
"source": "cicd",
"commit_hash": "'"$GITHUB_SHA"'",
"error": {
"type": "TestFailure",
"message": "Tests failed in CI pipeline",
"file_path": null,
"line_number": null,
"stack_trace": ["Test step failed. Check CI logs for details."]
},
"context": {
"workflow_name": "'"$GITHUB_WORKFLOW"'",
"job_name": "'"$GITHUB_JOB"'",
"step_name": "Run Tests",
"run_id": "'"$GITHUB_RUN_ID"'",
"branch": "'"$GITHUB_REF_NAME"'",
"repository": "'"$GITHUB_REPOSITORY"'"
}
}' --connect-timeout 5 --max-time 10 || true

92
scripts/report-cicd-error.sh Executable file
View File

@ -0,0 +1,92 @@
#!/bin/bash
# report-cicd-error.sh - CI/CD 构建错误上报到 Log Center
#
# 用法: ./report-cicd-error.sh <project_id> <step_name> <error_message_or_file>
#
# 环境变量 (Gitea Actions 自动设置):
# LOG_CENTER_URL - Log Center API 地址
# GITHUB_WORKFLOW - 工作流名称
# GITHUB_JOB - Job 名称
# GITHUB_RUN_ID - 运行 ID
# GITHUB_REF_NAME - 分支名
# GITHUB_SHA - Commit SHA
# GITHUB_SERVER_URL - Gitea 地址
# GITHUB_REPOSITORY - 仓库名
set -euo pipefail
PROJECT_ID="${1:?用法: $0 <project_id> <step_name> <error_message_or_file>}"
STEP_NAME="${2:?缺少 step_name}"
ERROR_INPUT="${3:?缺少错误消息或日志文件路径}"
LOG_CENTER_URL="${LOG_CENTER_URL:-https://qiyuan-log-center-api.airlabs.art}"
# 读取错误信息:文件或字符串
if [ -f "$ERROR_INPUT" ]; then
ERROR_MSG=$(tail -100 "$ERROR_INPUT")
else
ERROR_MSG="$ERROR_INPUT"
fi
# 首行作为错误消息,其余作为堆栈
FIRST_LINE=$(echo "$ERROR_MSG" | head -1)
STACK_TRACE=$(echo "$ERROR_MSG" | jq -Rs 'split("\n") | map(select(length > 0))')
# 根据 step 名称推断错误类型
case "$STEP_NAME" in
*docker*|*Docker*|*Build*Image*) ERROR_TYPE="DockerBuildError" ;;
*npm*|*yarn*|*pnpm*) ERROR_TYPE="NpmBuildError" ;;
*test*|*Test*) ERROR_TYPE="TestFailure" ;;
*lint*|*Lint*) ERROR_TYPE="LintError" ;;
*deploy*|*Deploy*) ERROR_TYPE="DeployError" ;;
*) ERROR_TYPE="CIBuildError" ;;
esac
# 构建 JSON
PAYLOAD=$(jq -n \
--arg project_id "$PROJECT_ID" \
--arg env "cicd" \
--arg level "ERROR" \
--arg source "cicd" \
--arg error_type "$ERROR_TYPE" \
--arg error_message "$FIRST_LINE" \
--argjson stack_trace "$STACK_TRACE" \
--arg commit_hash "${GITHUB_SHA:-unknown}" \
--arg workflow "${GITHUB_WORKFLOW:-unknown}" \
--arg job "${GITHUB_JOB:-unknown}" \
--arg step "$STEP_NAME" \
--arg run_id "${GITHUB_RUN_ID:-0}" \
--arg branch "${GITHUB_REF_NAME:-unknown}" \
--arg repo "${GITHUB_REPOSITORY:-unknown}" \
--arg server_url "${GITHUB_SERVER_URL:-}" \
'{
project_id: $project_id,
environment: $env,
level: $level,
source: $source,
commit_hash: $commit_hash,
error: {
type: $error_type,
message: $error_message,
file_path: null,
line_number: null,
stack_trace: $stack_trace
},
context: {
workflow_name: $workflow,
job_name: $job,
step_name: $step,
run_id: $run_id,
branch: $branch,
repository: $repo,
run_url: "\($server_url)/\($repo)/actions/runs/\($run_id)"
}
}')
# 发送到 Log Center失败静默不影响流水线
curl -s -X POST "${LOG_CENTER_URL}/api/v1/logs/report" \
-H "Content-Type: application/json" \
-d "$PAYLOAD" \
--connect-timeout 5 \
--max-time 10 \
|| echo "Warning: 上报 Log Center 失败"

View File

@ -1,5 +1,6 @@
import { BrowserRouter, Routes, Route, NavLink } from 'react-router-dom';
import { LayoutDashboard, Bug, Wrench, Shield } from 'lucide-react';
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 Dashboard from './pages/Dashboard';
import BugList from './pages/BugList';
import BugDetail from './pages/BugDetail';
@ -7,61 +8,95 @@ import RepairList from './pages/RepairList';
import RepairDetail from './pages/RepairDetail';
import './index.css';
function AppLayout() {
const [sidebarOpen, setSidebarOpen] = useState(false);
const location = useLocation();
// Close sidebar on route change (mobile)
useEffect(() => {
setSidebarOpen(false);
}, [location.pathname]);
return (
<div className="app">
{/* Mobile header */}
<header className="mobile-header">
<button className="mobile-menu-btn" onClick={() => setSidebarOpen(true)}>
<Menu size={20} />
</button>
<div className="mobile-logo">
<div className="logo-icon"><Shield size={14} /></div>
</div>
<div style={{ width: 36 }} />
</header>
{/* Overlay */}
{sidebarOpen && (
<div className="sidebar-overlay" onClick={() => setSidebarOpen(false)} />
)}
<aside className={`sidebar ${sidebarOpen ? 'sidebar-open' : ''}`}>
<div className="sidebar-top">
<div className="logo">
<div className="logo-icon"><Shield size={16} /></div>
</div>
<button className="sidebar-close-btn" onClick={() => setSidebarOpen(false)}>
<X size={18} />
</button>
</div>
<nav>
<ul className="nav-menu">
<li className="nav-item">
<NavLink
to="/"
className={({ isActive }) => `nav-link ${isActive ? 'active' : ''}`}
end
>
<LayoutDashboard size={16} />
</NavLink>
</li>
<li className="nav-item">
<NavLink
to="/bugs"
className={({ isActive }) => `nav-link ${isActive ? 'active' : ''}`}
>
<Bug size={16} />
</NavLink>
</li>
<li className="nav-item">
<NavLink
to="/repairs"
className={({ isActive }) => `nav-link ${isActive ? 'active' : ''}`}
>
<Wrench size={16} />
</NavLink>
</li>
</ul>
</nav>
</aside>
<main className="main-content">
<Routes>
<Route path="/" element={<Dashboard />} />
<Route path="/bugs" element={<BugList />} />
<Route path="/bugs/:id" element={<BugDetail />} />
<Route path="/repairs" element={<RepairList />} />
<Route path="/repairs/:id" element={<RepairDetail />} />
</Routes>
</main>
</div>
);
}
function App() {
return (
<BrowserRouter>
<div className="app">
<aside className="sidebar">
<div className="logo">
<div className="logo-icon">
<Shield size={16} />
</div>
</div>
<nav>
<ul className="nav-menu">
<li className="nav-item">
<NavLink
to="/"
className={({ isActive }) => `nav-link ${isActive ? 'active' : ''}`}
end
>
<LayoutDashboard size={16} />
</NavLink>
</li>
<li className="nav-item">
<NavLink
to="/bugs"
className={({ isActive }) => `nav-link ${isActive ? 'active' : ''}`}
>
<Bug size={16} />
</NavLink>
</li>
<li className="nav-item">
<NavLink
to="/repairs"
className={({ isActive }) => `nav-link ${isActive ? 'active' : ''}`}
>
<Wrench size={16} />
</NavLink>
</li>
</ul>
</nav>
</aside>
<main className="main-content">
<Routes>
<Route path="/" element={<Dashboard />} />
<Route path="/bugs" element={<BugList />} />
<Route path="/bugs/:id" element={<BugDetail />} />
<Route path="/repairs" element={<RepairList />} />
<Route path="/repairs/:id" element={<RepairDetail />} />
</Routes>
</main>
</div>
<AppLayout />
</BrowserRouter>
);
}

View File

@ -1,6 +1,6 @@
import axios from 'axios';
const API_BASE = import.meta.env.VITE_API_BASE_URL || 'http://localhost:8002';
const API_BASE = import.meta.env.VITE_API_BASE_URL || 'https://qiyuan-log-center-api.airlabs.art';
const api = axios.create({
baseURL: API_BASE,
@ -13,10 +13,11 @@ export interface ErrorLog {
project_id: string;
environment: string;
level: string;
source: string;
error_type: string;
error_message: string;
file_path: string;
line_number: number;
file_path: string | null;
line_number: number | null;
stack_trace: string;
context: Record<string, any>;
version?: string;
@ -32,6 +33,7 @@ export interface DashboardStats {
today_bugs: number;
fix_rate: number;
status_distribution: Record<string, number>;
source_distribution: Record<string, number>;
}
export interface PaginatedResponse<T> {
@ -66,6 +68,7 @@ export const getBugs = (params: {
page_size?: number;
status?: string;
project_id?: string;
source?: string;
}) => api.get<PaginatedResponse<ErrorLog>>('/api/v1/bugs', { params });
export const getBugDetail = (id: number) => api.get<ErrorLog>(`/api/v1/bugs/${id}`);

View File

@ -361,6 +361,44 @@ td a:hover {
color: var(--indigo);
}
/* ============ Source Badges ============ */
.source-badge {
display: inline-flex;
align-items: center;
padding: 2px 8px;
border-radius: 4px;
font-size: 11px;
font-weight: 500;
}
.source-runtime {
background: var(--bg-surface);
color: var(--text-secondary);
}
.source-cicd {
background: rgba(59, 130, 246, 0.12);
color: #60a5fa;
}
.source-deployment {
background: rgba(244, 114, 182, 0.12);
color: #f472b6;
}
.source-tabs {
display: flex;
align-items: center;
gap: 4px;
margin-bottom: 8px;
padding: 4px;
background: var(--bg-card);
border: 1px solid var(--border);
border-radius: var(--radius-md);
overflow-x: auto;
}
/* ============ Filters: Project Tabs ============ */
.project-tabs {
@ -948,3 +986,526 @@ td a:hover {
padding-top: 16px;
border-top: 1px solid var(--border);
}
/* ============ Mobile Header ============ */
.mobile-header {
display: none;
}
.sidebar-close-btn {
display: none;
}
.sidebar-top {
display: contents;
}
.sidebar-overlay {
display: none;
}
/* ============ Mobile Card List (replaces table on small screens) ============ */
.mobile-card-list {
display: none;
}
/* ============================================================
Responsive: Tablet (max-width: 1024px)
============================================================ */
@media (max-width: 1024px) {
.stats-grid {
grid-template-columns: repeat(2, 1fr);
}
.main-content {
padding: 24px;
}
.table-container {
overflow-x: auto;
-webkit-overflow-scrolling: touch;
}
.title-row {
flex-direction: column;
align-items: flex-start;
gap: 12px;
}
}
/* ============================================================
Responsive: Mobile (max-width: 768px)
============================================================ */
@media (max-width: 768px) {
/* --- Layout --- */
.app {
flex-direction: column;
}
/* --- Mobile Header (top bar) --- */
.mobile-header {
display: flex;
align-items: center;
justify-content: space-between;
padding: 12px 16px;
background: var(--bg-secondary);
border-bottom: 1px solid var(--border);
position: sticky;
top: 0;
z-index: 100;
}
.mobile-menu-btn {
display: flex;
align-items: center;
justify-content: center;
width: 36px;
height: 36px;
border: none;
background: var(--bg-hover);
border-radius: var(--radius-md);
color: var(--text-primary);
cursor: pointer;
transition: background 0.15s ease;
}
.mobile-menu-btn:active {
background: var(--bg-surface);
}
.mobile-logo {
display: flex;
align-items: center;
gap: 8px;
font-size: 14px;
font-weight: 600;
color: var(--text-primary);
}
.mobile-logo .logo-icon {
width: 24px;
height: 24px;
}
/* --- Sidebar (slide-in drawer) --- */
.sidebar {
position: fixed;
top: 0;
left: 0;
bottom: 0;
width: 280px;
z-index: 200;
transform: translateX(-100%);
transition: transform 0.25s cubic-bezier(0.4, 0, 0.2, 1);
padding-top: 16px;
}
.sidebar.sidebar-open {
transform: translateX(0);
}
.sidebar-top {
display: flex;
align-items: center;
justify-content: space-between;
padding: 0 12px;
margin-bottom: 8px;
}
.sidebar-top .logo {
margin-bottom: 0;
}
.sidebar-close-btn {
display: flex;
align-items: center;
justify-content: center;
width: 32px;
height: 32px;
border: none;
background: var(--bg-hover);
border-radius: var(--radius-md);
color: var(--text-secondary);
cursor: pointer;
}
.sidebar-close-btn:active {
background: var(--bg-surface);
}
.sidebar-overlay {
display: block;
position: fixed;
inset: 0;
background: rgba(0, 0, 0, 0.6);
z-index: 150;
backdrop-filter: blur(2px);
}
.nav-link {
padding: 12px 16px;
font-size: 14px;
}
.nav-menu {
gap: 4px;
}
/* --- Main Content --- */
.main-content {
padding: 20px 16px;
min-height: calc(100vh - 61px);
}
/* --- Page Header --- */
.page-header {
margin-bottom: 20px;
}
.page-title {
font-size: 18px;
}
/* --- Stats Grid --- */
.stats-grid {
grid-template-columns: repeat(2, 1fr);
gap: 12px;
margin-bottom: 20px;
}
.stat-card {
padding: 16px;
}
.stat-value {
font-size: 24px;
}
.stat-icon {
width: 32px;
height: 32px;
margin-bottom: 8px;
}
/* --- Filters --- */
.project-tabs {
gap: 2px;
padding: 3px;
-ms-overflow-style: none;
scrollbar-width: none;
}
.project-tabs::-webkit-scrollbar {
display: none;
}
.project-tab {
padding: 6px 12px;
font-size: 12px;
}
.status-tabs {
gap: 4px;
-ms-overflow-style: none;
scrollbar-width: none;
}
.status-tabs::-webkit-scrollbar {
display: none;
}
.status-tab {
padding: 4px 10px;
font-size: 10px;
}
.filters {
margin-bottom: 12px;
}
.filter-select {
width: 100%;
font-size: 14px;
padding: 10px 12px;
}
/* --- Table: horizontal scroll --- */
.table-container {
border-radius: var(--radius-md);
}
table {
min-width: 600px;
}
.table-compact table {
min-width: 0;
}
th, td {
padding: 10px 12px;
font-size: 12px;
}
th {
font-size: 10px;
}
/* --- Mobile Card List (replaces table) --- */
.mobile-card-list {
display: flex;
flex-direction: column;
}
.mobile-card-item {
padding: 14px 16px;
border-bottom: 1px solid var(--border-subtle);
display: flex;
flex-direction: column;
gap: 8px;
}
.mobile-card-item:last-child {
border-bottom: none;
}
.mobile-card-top {
display: flex;
justify-content: space-between;
align-items: center;
gap: 8px;
}
.mobile-card-id {
color: var(--accent);
font-weight: 600;
font-size: 13px;
}
.mobile-card-error {
color: var(--error);
font-weight: 500;
font-size: 13px;
line-height: 1.4;
}
.mobile-card-meta {
display: flex;
flex-wrap: wrap;
gap: 8px;
font-size: 12px;
color: var(--text-tertiary);
}
.mobile-card-meta span {
display: flex;
align-items: center;
gap: 4px;
}
.mobile-card-file {
font-family: 'SF Mono', 'Fira Code', 'Cascadia Code', monospace;
font-size: 11px;
color: var(--text-secondary);
word-break: break-all;
}
/* Hide desktop table, show mobile cards */
.table-desktop {
display: none;
}
.mobile-card-list {
display: flex;
}
/* --- Detail Page --- */
.detail-card {
padding: 16px;
border-radius: var(--radius-md);
}
.detail-header {
flex-direction: column;
gap: 12px;
}
.detail-title {
font-size: 14px;
}
.detail-meta {
flex-wrap: wrap;
gap: 8px 16px;
font-size: 12px;
}
.code-block,
.stack-trace {
padding: 12px;
font-size: 11px;
border-radius: var(--radius-sm);
}
.meta-label {
width: 100px;
}
.meta-table td {
font-size: 12px;
}
/* --- Card --- */
.card {
padding: 16px;
border-radius: var(--radius-md);
}
.card h2 {
font-size: 13px;
margin-bottom: 12px;
}
/* --- Info Row --- */
.info-row {
font-size: 12px;
flex-wrap: wrap;
}
.info-row > span:first-child {
min-width: 80px;
font-size: 12px;
}
/* --- Actions Bar --- */
.actions-bar {
flex-wrap: wrap;
gap: 10px;
}
.trigger-repair-btn {
width: 100%;
justify-content: center;
padding: 12px 20px;
}
/* --- Pagination --- */
.pagination {
padding: 12px 16px;
gap: 6px;
}
.pagination button {
padding: 8px 12px;
font-size: 12px;
}
.pagination-info {
font-size: 12px;
}
/* --- Title Row --- */
.title-row {
flex-direction: column;
align-items: flex-start;
gap: 12px;
}
.title-row h1 {
font-size: 18px;
}
/* --- Back Link --- */
.back-link {
margin-bottom: 16px;
}
/* --- Breadcrumb --- */
.breadcrumb {
flex-wrap: wrap;
font-size: 12px;
}
/* --- Table Header --- */
.table-header {
padding: 12px 16px;
}
}
/* ============================================================
Responsive: Small Mobile (max-width: 480px)
============================================================ */
@media (max-width: 480px) {
.main-content {
padding: 16px 12px;
}
.stats-grid {
grid-template-columns: 1fr;
gap: 10px;
}
.stat-card {
display: flex;
align-items: center;
gap: 14px;
padding: 14px 16px;
}
.stat-icon {
margin-bottom: 0;
flex-shrink: 0;
}
.stat-label {
margin-bottom: 2px;
}
.stat-value {
font-size: 22px;
}
.page-title {
font-size: 16px;
}
.mobile-card-item {
padding: 12px;
}
.detail-title {
font-size: 13px;
}
.detail-meta {
font-size: 11px;
}
.code-block,
.stack-trace {
font-size: 10px;
padding: 10px;
}
.empty-state {
height: 160px;
font-size: 12px;
}
}
/* ============ Safe area for notch devices ============ */
@supports (padding: env(safe-area-inset-bottom)) {
.mobile-header {
padding-top: calc(12px + env(safe-area-inset-top));
}
.main-content {
padding-bottom: calc(16px + env(safe-area-inset-bottom));
}
}

View File

@ -3,6 +3,12 @@ import { useParams, Link, useLocation } from 'react-router-dom';
import { ArrowLeft, Play, Loader2, FileCode, GitCommit, History } from 'lucide-react';
import { getBugDetail, triggerRepair, getRepairReportsByBug, type ErrorLog, type RepairReport } from '../api';
const SOURCE_LABELS: Record<string, string> = {
runtime: '运行时',
cicd: 'CI/CD',
deployment: '部署',
};
const STATUS_LABELS: Record<string, string> = {
NEW: '新发现',
VERIFYING: '验证中',
@ -77,7 +83,8 @@ export default function BugDetail() {
return <div className="loading"></div>;
}
const canTriggerRepair = ['NEW', 'FIX_FAILED'].includes(bug.status);
const isRuntime = !bug.source || bug.source === 'runtime';
const canTriggerRepair = ['NEW', 'FIX_FAILED'].includes(bug.status) && isRuntime;
return (
<div>
@ -94,6 +101,7 @@ export default function BugDetail() {
</h2>
<div className="detail-meta">
<span>{bug.project_id}</span>
<span><span className={`source-badge source-${bug.source || 'runtime'}`}>{SOURCE_LABELS[bug.source] || '运行时'}</span></span>
<span>{bug.environment}</span>
<span>{bug.level}</span>
</div>
@ -103,13 +111,40 @@ export default function BugDetail() {
</span>
</div>
<div className="detail-section">
<div className="detail-section-title"></div>
<div className="detail-section-value">
<FileCode size={14} style={{ display: 'inline', verticalAlign: 'middle', marginRight: '6px' }} />
{bug.file_path} : {bug.line_number}
{bug.file_path && (
<div className="detail-section">
<div className="detail-section-title"></div>
<div className="detail-section-value">
<FileCode size={14} style={{ display: 'inline', verticalAlign: 'middle', marginRight: '6px' }} />
{bug.file_path} : {bug.line_number}
</div>
</div>
</div>
)}
{bug.source === 'cicd' && bug.context?.workflow_name && (
<div className="detail-section">
<div className="detail-section-title">CI/CD </div>
<div className="detail-section-value">
{bug.context.workflow_name} / {bug.context.job_name} / {bug.context.step_name}
{bug.context.branch && <><br />{bug.context.branch}</>}
{bug.context.run_url && (
<><br /><a href={bug.context.run_url} target="_blank" rel="noopener noreferrer"> CI </a></>
)}
</div>
</div>
)}
{bug.source === 'deployment' && bug.context?.pod_name && (
<div className="detail-section">
<div className="detail-section-title"></div>
<div className="detail-section-value">
{bug.context.namespace} | Pod{bug.context.pod_name}
<br />
{bug.context.container_name} | {bug.context.restart_count}
{bug.context.node_name && <><br />{bug.context.node_name}</>}
</div>
</div>
)}
{bug.commit_hash && (
<div className="detail-section">
@ -163,7 +198,9 @@ export default function BugDetail() {
)}
{!canTriggerRepair && !repairing && (
<span style={{ fontSize: '13px', color: 'var(--text-tertiary)' }}>
"新发现""修复失败"
{!isRuntime
? 'CI/CD 和部署错误暂不支持自动修复'
: '仅"新发现"或"修复失败"状态的缺陷可触发修复'}
</span>
)}
</div>

View File

@ -7,6 +7,12 @@ const STATUSES = [
'FIXING', 'FIXED', 'VERIFIED', 'DEPLOYED', 'FIX_FAILED'
];
const SOURCE_LABELS: Record<string, string> = {
runtime: '运行时',
cicd: 'CI/CD',
deployment: '部署',
};
const STATUS_LABELS: Record<string, string> = {
NEW: '新发现',
VERIFYING: '验证中',
@ -29,6 +35,7 @@ export default function BugList() {
const currentProject = searchParams.get('project') || '';
const currentStatus = searchParams.get('status') ?? 'NEW';
const currentSource = searchParams.get('source') || '';
const currentPage = parseInt(searchParams.get('page') || '1', 10);
const updateParams = useCallback((updates: Record<string, string>) => {
@ -37,11 +44,13 @@ export default function BugList() {
for (const [key, value] of Object.entries(updates)) {
if (value) {
next.set(key, value);
} else if (key === 'status') {
next.set(key, value);
} else {
next.delete(key);
}
}
if ('project' in updates || 'status' in updates) {
if ('project' in updates || 'status' in updates || 'source' in updates) {
next.delete('page');
}
return next;
@ -67,6 +76,7 @@ export default function BugList() {
const params: Record<string, string | number> = { page: currentPage, page_size: 20 };
if (currentStatus) params.status = currentStatus;
if (currentProject) params.project_id = currentProject;
if (currentSource) params.source = currentSource;
const response = await getBugs(params);
setBugs(response.data.items);
@ -78,7 +88,7 @@ export default function BugList() {
}
};
fetchBugs();
}, [currentPage, currentStatus, currentProject]);
}, [currentPage, currentStatus, currentProject, currentSource]);
return (
<div>
@ -105,6 +115,24 @@ export default function BugList() {
))}
</div>
<div className="source-tabs">
<button
className={`project-tab ${currentSource === '' ? 'active' : ''}`}
onClick={() => updateParams({ source: '' })}
>
</button>
{Object.entries(SOURCE_LABELS).map(([key, label]) => (
<button
key={key}
className={`project-tab ${currentSource === key ? 'active' : ''}`}
onClick={() => updateParams({ source: key })}
>
{label}
</button>
))}
</div>
<div className="status-tabs">
<button
className={`status-tab ${currentStatus === '' ? 'active' : ''}`}
@ -136,52 +164,89 @@ export default function BugList() {
</div>
) : (
<>
<table>
<thead>
<tr>
<th></th>
<th></th>
<th></th>
<th></th>
<th></th>
<th></th>
</tr>
</thead>
<tbody>
{bugs.map(bug => (
<tr key={bug.id}>
<td>
<Link
to={`/bugs/${bug.id}`}
state={{ fromSearch: searchParams.toString() }}
>
#{bug.id}
</Link>
</td>
<td>
<button
className="project-link"
onClick={() => updateParams({ project: bug.project_id })}
>
{bug.project_id}
</button>
</td>
<td className="cell-error">{bug.error_type}</td>
<td className="cell-mono">
{bug.file_path}:{bug.line_number}
</td>
<td>
<span className={`status-badge status-${bug.status}`}>
{STATUS_LABELS[bug.status] || bug.status}
</span>
</td>
<td className="cell-secondary">
{new Date(bug.timestamp).toLocaleString()}
</td>
{/* Desktop table */}
<div className="table-desktop">
<table>
<thead>
<tr>
<th></th>
<th></th>
<th></th>
<th></th>
<th></th>
<th></th>
<th></th>
</tr>
))}
</tbody>
</table>
</thead>
<tbody>
{bugs.map(bug => (
<tr key={bug.id}>
<td>
<Link
to={`/bugs/${bug.id}`}
state={{ fromSearch: searchParams.toString() }}
>
#{bug.id}
</Link>
</td>
<td>
<button
className="project-link"
onClick={() => updateParams({ project: bug.project_id })}
>
{bug.project_id}
</button>
</td>
<td>
<span className={`source-badge source-${bug.source || 'runtime'}`}>
{SOURCE_LABELS[bug.source] || '运行时'}
</span>
</td>
<td className="cell-error">{bug.error_type}</td>
<td className="cell-mono">
{bug.file_path ? `${bug.file_path}:${bug.line_number}` : '-'}
</td>
<td>
<span className={`status-badge status-${bug.status}`}>
{STATUS_LABELS[bug.status] || bug.status}
</span>
</td>
<td className="cell-secondary">
{new Date(bug.timestamp).toLocaleString()}
</td>
</tr>
))}
</tbody>
</table>
</div>
{/* Mobile card list */}
<div className="mobile-card-list">
{bugs.map(bug => (
<Link
key={bug.id}
to={`/bugs/${bug.id}`}
state={{ fromSearch: searchParams.toString() }}
className="mobile-card-item"
style={{ textDecoration: 'none', color: 'inherit' }}
>
<div className="mobile-card-top">
<span className="mobile-card-id">#{bug.id}</span>
<span className={`status-badge status-${bug.status}`}>
{STATUS_LABELS[bug.status] || bug.status}
</span>
</div>
<div className="mobile-card-error">{bug.error_type}</div>
<div className="mobile-card-file">
{bug.file_path ? `${bug.file_path}:${bug.line_number}` : SOURCE_LABELS[bug.source] || '运行时'}
</div>
<div className="mobile-card-meta">
<span>{bug.project_id}</span>
<span>{new Date(bug.timestamp).toLocaleDateString()}</span>
</div>
</Link>
))}
</div>
{totalPages > 1 && (
<div className="pagination">

View File

@ -2,6 +2,12 @@ import { useState, useEffect } from 'react';
import { Bug, CalendarPlus, TrendingUp, AlertTriangle } from 'lucide-react';
import { getStats, type DashboardStats } from '../api';
const SOURCE_LABELS: Record<string, string> = {
runtime: '运行时',
cicd: 'CI/CD',
deployment: '部署',
};
const STATUS_LABELS: Record<string, string> = {
NEW: '新发现',
VERIFYING: '验证中',
@ -85,30 +91,60 @@ export default function Dashboard() {
</div>
</div>
<div className="table-container">
<div className="table-header">
<h3 className="table-title"></h3>
</div>
<table>
<thead>
<tr>
<th></th>
<th></th>
</tr>
</thead>
<tbody>
{Object.entries(stats.status_distribution).map(([status, count]) => (
<tr key={status}>
<td>
<span className={`status-badge status-${status}`}>
{STATUS_LABELS[status] || status}
</span>
</td>
<td>{count}</td>
<div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: '16px' }}>
<div className="table-container table-compact">
<div className="table-header">
<h3 className="table-title"></h3>
</div>
<table>
<thead>
<tr>
<th></th>
<th></th>
</tr>
))}
</tbody>
</table>
</thead>
<tbody>
{Object.entries(stats.status_distribution).map(([status, count]) => (
<tr key={status}>
<td>
<span className={`status-badge status-${status}`}>
{STATUS_LABELS[status] || status}
</span>
</td>
<td>{count}</td>
</tr>
))}
</tbody>
</table>
</div>
{stats.source_distribution && (
<div className="table-container table-compact">
<div className="table-header">
<h3 className="table-title"></h3>
</div>
<table>
<thead>
<tr>
<th></th>
<th></th>
</tr>
</thead>
<tbody>
{Object.entries(stats.source_distribution).map(([source, count]) => (
<tr key={source}>
<td>
<span className={`source-badge source-${source}`}>
{SOURCE_LABELS[source] || source}
</span>
</td>
<td>{count}</td>
</tr>
))}
</tbody>
</table>
</div>
)}
</div>
</div>
);

View File

@ -62,24 +62,26 @@ export default function RepairList() {
return (
<div>
<div className="page-header">
<div className="title-row">
<div>
<h1 className="page-title"></h1>
<p className="page-subtitle">AI </p>
</div>
<div className="filters">
<select
className="filter-select"
value={filters.project_id}
onChange={(e) => handleFilterChange('project_id', e.target.value)}
>
<option value=""></option>
{projects.map((p) => (
<option key={p} value={p}>{p}</option>
))}
</select>
</div>
</div>
<h1 className="page-title"></h1>
<p className="page-subtitle">AI </p>
</div>
<div className="project-tabs">
<button
className={`project-tab ${filters.project_id === '' ? 'active' : ''}`}
onClick={() => handleFilterChange('project_id', '')}
>
</button>
{projects.map((p) => (
<button
key={p}
className={`project-tab ${filters.project_id === p ? 'active' : ''}`}
onClick={() => handleFilterChange('project_id', p)}
>
{p}
</button>
))}
</div>
<div className="table-container">
@ -90,54 +92,90 @@ export default function RepairList() {
) : reports.length === 0 ? (
<div className="empty-state"></div>
) : (
<table>
<thead>
<tr>
<th></th>
<th></th>
<th></th>
<th></th>
<th></th>
<th></th>
<th></th>
<th></th>
<th></th>
</tr>
</thead>
<tbody>
{reports.map((report) => (
<tr key={report.id}>
<td>#{report.id}</td>
<td>{report.project_id}</td>
<td>
<Link to={`/bugs/${report.error_log_id}`}>
#{report.error_log_id}
</Link>
</td>
<td> {report.repair_round} </td>
<td>{report.modified_files.length} </td>
<td>
<span className={report.test_passed ? 'test-pass' : 'test-fail'}>
{report.test_passed ? '通过' : '失败'}
</span>
</td>
<td>
<span className={`status-badge status-${report.status}`}>
{STATUS_LABELS[report.status] || report.status}
</span>
</td>
<td className="cell-secondary">
{new Date(report.created_at).toLocaleString()}
</td>
<td>
<Link to={`/repairs/${report.id}`} className="btn-link">
</Link>
</td>
<>
{/* Desktop table */}
<div className="table-desktop">
<table>
<thead>
<tr>
<th></th>
<th></th>
<th></th>
<th></th>
<th></th>
<th></th>
<th></th>
<th></th>
<th></th>
</tr>
))}
</tbody>
</table>
</thead>
<tbody>
{reports.map((report) => (
<tr key={report.id}>
<td>#{report.id}</td>
<td>{report.project_id}</td>
<td>
<Link to={`/bugs/${report.error_log_id}`}>
#{report.error_log_id}
</Link>
</td>
<td> {report.repair_round} </td>
<td>{report.modified_files.length} </td>
<td>
<span className={report.test_passed ? 'test-pass' : 'test-fail'}>
{report.test_passed ? '通过' : '失败'}
</span>
</td>
<td>
<span className={`status-badge status-${report.status}`}>
{STATUS_LABELS[report.status] || report.status}
</span>
</td>
<td className="cell-secondary">
{new Date(report.created_at).toLocaleString()}
</td>
<td>
<Link to={`/repairs/${report.id}`} className="btn-link">
</Link>
</td>
</tr>
))}
</tbody>
</table>
</div>
{/* Mobile card list */}
<div className="mobile-card-list">
{reports.map((report) => (
<Link
key={report.id}
to={`/repairs/${report.id}`}
className="mobile-card-item"
style={{ textDecoration: 'none', color: 'inherit' }}
>
<div className="mobile-card-top">
<span className="mobile-card-id">#{report.id}</span>
<span className={`status-badge status-${report.status}`}>
{STATUS_LABELS[report.status] || report.status}
</span>
</div>
<div className="mobile-card-meta">
<span>{report.project_id}</span>
<span> #{report.error_log_id}</span>
<span> {report.repair_round} </span>
</div>
<div className="mobile-card-meta">
<span>{report.modified_files.length} </span>
<span className={report.test_passed ? 'test-pass' : 'test-fail'}>
{report.test_passed ? '通过' : '失败'}
</span>
<span>{new Date(report.created_at).toLocaleDateString()}</span>
</div>
</Link>
))}
</div>
</>
)}
</div>