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 = [ migrations = [
"ALTER TABLE repairtask ADD COLUMN repair_round INTEGER DEFAULT 1", "ALTER TABLE repairtask ADD COLUMN repair_round INTEGER DEFAULT 1",
"ALTER TABLE repairtask ADD COLUMN failure_reason TEXT", "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: for sql in migrations:
try: try:

View File

@ -25,8 +25,17 @@ async def on_startup():
await init_db() await init_db()
def generate_fingerprint(log: ErrorLogCreate) -> str: def generate_fingerprint(log: ErrorLogCreate) -> str:
# Minimal fingerprinting: project + error_type + file + line 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')}" 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() return hashlib.md5(raw.encode()).hexdigest()
# ==================== Log Reporting ==================== # ==================== Log Reporting ====================
@ -57,6 +66,7 @@ async def report_log(log_data: ErrorLogCreate, session: AsyncSession = Depends(g
project_id=log_data.project_id, project_id=log_data.project_id,
environment=log_data.environment, environment=log_data.environment,
level=log_data.level, level=log_data.level,
source=log_data.source,
error_type=log_data.error.get("type"), error_type=log_data.error.get("type"),
error_message=log_data.error.get("message"), error_message=log_data.error.get("message"),
file_path=log_data.error.get("file_path"), 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 ==================== # ==================== Agent Tasks ====================
@app.get("/api/v1/tasks/pending", tags=["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) query = select(ErrorLog).where(ErrorLog.status == LogStatus.NEW)
if project_id: if project_id:
query = query.where(ErrorLog.project_id == project_id) query = query.where(ErrorLog.project_id == project_id)
if source:
query = query.where(ErrorLog.source == source)
results = await session.exec(query) results = await session.exec(query)
return results.all() return results.all()
@ -185,24 +197,27 @@ async def get_repair_report_detail(report_id: int, session: AsyncSession = Depen
# ==================== Dashboard APIs ==================== # ==================== Dashboard APIs ====================
@app.get("/api/v1/dashboard/stats", tags=["Dashboard"]) @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""" """Get overall statistics for dashboard"""
today = datetime.utcnow().replace(hour=0, minute=0, second=0, microsecond=0) 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 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_result = await session.exec(total_query)
total_bugs = total_result.one() total_bugs = total_result.one()
# Today's new bugs # 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_result = await session.exec(today_query)
today_bugs = today_result.one() today_bugs = today_result.one()
# Count by status # Count by status
status_counts = {} status_counts = {}
for status in LogStatus: 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) count_result = await session.exec(count_query)
status_counts[status.value] = count_result.one() 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) 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 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 { return {
"total_bugs": total_bugs, "total_bugs": total_bugs,
"today_bugs": today_bugs, "today_bugs": today_bugs,
"fix_rate": fix_rate, "fix_rate": fix_rate,
"status_distribution": status_counts "status_distribution": status_counts,
"source_distribution": source_counts,
} }
@app.get("/api/v1/bugs", tags=["Dashboard"]) @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), page_size: int = Query(20, ge=1, le=100),
status: Optional[LogStatus] = None, status: Optional[LogStatus] = None,
project_id: Optional[str] = None, project_id: Optional[str] = None,
source: Optional[str] = None,
session: AsyncSession = Depends(get_session) session: AsyncSession = Depends(get_session)
): ):
"""Get paginated list of bugs with optional filters""" """Get paginated list of bugs with optional filters"""
@ -232,6 +257,8 @@ async def get_bugs_list(
query = query.where(ErrorLog.status == status) query = query.where(ErrorLog.status == status)
if project_id: if project_id:
query = query.where(ErrorLog.project_id == project_id) query = query.where(ErrorLog.project_id == project_id)
if source:
query = query.where(ErrorLog.source == source)
# Pagination # Pagination
offset = (page - 1) * page_size offset = (page - 1) * page_size
@ -246,6 +273,8 @@ async def get_bugs_list(
count_query = count_query.where(ErrorLog.status == status) count_query = count_query.where(ErrorLog.status == status)
if project_id: if project_id:
count_query = count_query.where(ErrorLog.project_id == 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) count_result = await session.exec(count_query)
total = count_result.one() 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 sqlmodel import SQLModel, Field, Column, JSON, Text
from enum import Enum from enum import Enum
class LogSource(str, Enum):
RUNTIME = "runtime"
CICD = "cicd"
DEPLOYMENT = "deployment"
class LogStatus(str, Enum): class LogStatus(str, Enum):
NEW = "NEW" NEW = "NEW"
VERIFYING = "VERIFYING" VERIFYING = "VERIFYING"
@ -20,11 +25,14 @@ class ErrorLog(SQLModel, table=True):
environment: str environment: str
level: str level: str
# Source
source: str = Field(default="runtime", index=True)
# Error Details # Error Details
error_type: str error_type: str
error_message: str error_message: str
file_path: str file_path: Optional[str] = None
line_number: int line_number: Optional[int] = None
stack_trace: str = Field(sa_column=Column(JSON)) # Store full stack trace stack_trace: str = Field(sa_column=Column(JSON)) # Store full stack trace
# Context # Context
@ -54,6 +62,7 @@ class ErrorLogCreate(SQLModel):
version: Optional[str] = None version: Optional[str] = None
commit_hash: Optional[str] = None commit_hash: Optional[str] = None
source: str = "runtime"
error: Dict # {type, message, file_path, line_number, stack_trace} error: Dict # {type, message, file_path, line_number, stack_trace}
context: Optional[Dict] = {} context: Optional[Dict] = {}

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"): for status in ("NEW", "PENDING_FIX"):
try: try:
params: dict[str, str] = {"status": status} params: dict[str, str] = {"status": status, "source": "runtime"}
if project_id: if project_id:
params["project_id"] = 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 { useState, useEffect } from 'react';
import { LayoutDashboard, Bug, Wrench, Shield } from 'lucide-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 Dashboard from './pages/Dashboard';
import BugList from './pages/BugList'; import BugList from './pages/BugList';
import BugDetail from './pages/BugDetail'; import BugDetail from './pages/BugDetail';
@ -7,17 +8,44 @@ import RepairList from './pages/RepairList';
import RepairDetail from './pages/RepairDetail'; import RepairDetail from './pages/RepairDetail';
import './index.css'; import './index.css';
function App() { function AppLayout() {
const [sidebarOpen, setSidebarOpen] = useState(false);
const location = useLocation();
// Close sidebar on route change (mobile)
useEffect(() => {
setSidebarOpen(false);
}, [location.pathname]);
return ( return (
<BrowserRouter>
<div className="app"> <div className="app">
<aside className="sidebar"> {/* Mobile header */}
<div className="logo"> <header className="mobile-header">
<div className="logo-icon"> <button className="mobile-menu-btn" onClick={() => setSidebarOpen(true)}>
<Shield size={16} /> <Menu size={20} />
</div> </button>
<div className="mobile-logo">
<div className="logo-icon"><Shield size={14} /></div>
</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> <nav>
<ul className="nav-menu"> <ul className="nav-menu">
<li className="nav-item"> <li className="nav-item">
@ -62,6 +90,13 @@ function App() {
</Routes> </Routes>
</main> </main>
</div> </div>
);
}
function App() {
return (
<BrowserRouter>
<AppLayout />
</BrowserRouter> </BrowserRouter>
); );
} }

View File

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

View File

@ -361,6 +361,44 @@ td a:hover {
color: var(--indigo); 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 ============ */ /* ============ Filters: Project Tabs ============ */
.project-tabs { .project-tabs {
@ -948,3 +986,526 @@ td a:hover {
padding-top: 16px; padding-top: 16px;
border-top: 1px solid var(--border); 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 { ArrowLeft, Play, Loader2, FileCode, GitCommit, History } from 'lucide-react';
import { getBugDetail, triggerRepair, getRepairReportsByBug, type ErrorLog, type RepairReport } from '../api'; import { getBugDetail, triggerRepair, getRepairReportsByBug, type ErrorLog, type RepairReport } from '../api';
const SOURCE_LABELS: Record<string, string> = {
runtime: '运行时',
cicd: 'CI/CD',
deployment: '部署',
};
const STATUS_LABELS: Record<string, string> = { const STATUS_LABELS: Record<string, string> = {
NEW: '新发现', NEW: '新发现',
VERIFYING: '验证中', VERIFYING: '验证中',
@ -77,7 +83,8 @@ export default function BugDetail() {
return <div className="loading"></div>; 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 ( return (
<div> <div>
@ -94,6 +101,7 @@ export default function BugDetail() {
</h2> </h2>
<div className="detail-meta"> <div className="detail-meta">
<span>{bug.project_id}</span> <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.environment}</span>
<span>{bug.level}</span> <span>{bug.level}</span>
</div> </div>
@ -103,6 +111,7 @@ export default function BugDetail() {
</span> </span>
</div> </div>
{bug.file_path && (
<div className="detail-section"> <div className="detail-section">
<div className="detail-section-title"></div> <div className="detail-section-title"></div>
<div className="detail-section-value"> <div className="detail-section-value">
@ -110,6 +119,32 @@ export default function BugDetail() {
{bug.file_path} : {bug.line_number} {bug.file_path} : {bug.line_number}
</div> </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 && ( {bug.commit_hash && (
<div className="detail-section"> <div className="detail-section">
@ -163,7 +198,9 @@ export default function BugDetail() {
)} )}
{!canTriggerRepair && !repairing && ( {!canTriggerRepair && !repairing && (
<span style={{ fontSize: '13px', color: 'var(--text-tertiary)' }}> <span style={{ fontSize: '13px', color: 'var(--text-tertiary)' }}>
"新发现""修复失败" {!isRuntime
? 'CI/CD 和部署错误暂不支持自动修复'
: '仅"新发现"或"修复失败"状态的缺陷可触发修复'}
</span> </span>
)} )}
</div> </div>

View File

@ -7,6 +7,12 @@ const STATUSES = [
'FIXING', 'FIXED', 'VERIFIED', 'DEPLOYED', 'FIX_FAILED' 'FIXING', 'FIXED', 'VERIFIED', 'DEPLOYED', 'FIX_FAILED'
]; ];
const SOURCE_LABELS: Record<string, string> = {
runtime: '运行时',
cicd: 'CI/CD',
deployment: '部署',
};
const STATUS_LABELS: Record<string, string> = { const STATUS_LABELS: Record<string, string> = {
NEW: '新发现', NEW: '新发现',
VERIFYING: '验证中', VERIFYING: '验证中',
@ -29,6 +35,7 @@ export default function BugList() {
const currentProject = searchParams.get('project') || ''; const currentProject = searchParams.get('project') || '';
const currentStatus = searchParams.get('status') ?? 'NEW'; const currentStatus = searchParams.get('status') ?? 'NEW';
const currentSource = searchParams.get('source') || '';
const currentPage = parseInt(searchParams.get('page') || '1', 10); const currentPage = parseInt(searchParams.get('page') || '1', 10);
const updateParams = useCallback((updates: Record<string, string>) => { const updateParams = useCallback((updates: Record<string, string>) => {
@ -37,11 +44,13 @@ export default function BugList() {
for (const [key, value] of Object.entries(updates)) { for (const [key, value] of Object.entries(updates)) {
if (value) { if (value) {
next.set(key, value); next.set(key, value);
} else if (key === 'status') {
next.set(key, value);
} else { } else {
next.delete(key); next.delete(key);
} }
} }
if ('project' in updates || 'status' in updates) { if ('project' in updates || 'status' in updates || 'source' in updates) {
next.delete('page'); next.delete('page');
} }
return next; return next;
@ -67,6 +76,7 @@ export default function BugList() {
const params: Record<string, string | number> = { page: currentPage, page_size: 20 }; const params: Record<string, string | number> = { page: currentPage, page_size: 20 };
if (currentStatus) params.status = currentStatus; if (currentStatus) params.status = currentStatus;
if (currentProject) params.project_id = currentProject; if (currentProject) params.project_id = currentProject;
if (currentSource) params.source = currentSource;
const response = await getBugs(params); const response = await getBugs(params);
setBugs(response.data.items); setBugs(response.data.items);
@ -78,7 +88,7 @@ export default function BugList() {
} }
}; };
fetchBugs(); fetchBugs();
}, [currentPage, currentStatus, currentProject]); }, [currentPage, currentStatus, currentProject, currentSource]);
return ( return (
<div> <div>
@ -105,6 +115,24 @@ export default function BugList() {
))} ))}
</div> </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"> <div className="status-tabs">
<button <button
className={`status-tab ${currentStatus === '' ? 'active' : ''}`} className={`status-tab ${currentStatus === '' ? 'active' : ''}`}
@ -136,11 +164,14 @@ export default function BugList() {
</div> </div>
) : ( ) : (
<> <>
{/* Desktop table */}
<div className="table-desktop">
<table> <table>
<thead> <thead>
<tr> <tr>
<th></th> <th></th>
<th></th> <th></th>
<th></th>
<th></th> <th></th>
<th></th> <th></th>
<th></th> <th></th>
@ -166,9 +197,14 @@ export default function BugList() {
{bug.project_id} {bug.project_id}
</button> </button>
</td> </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-error">{bug.error_type}</td>
<td className="cell-mono"> <td className="cell-mono">
{bug.file_path}:{bug.line_number} {bug.file_path ? `${bug.file_path}:${bug.line_number}` : '-'}
</td> </td>
<td> <td>
<span className={`status-badge status-${bug.status}`}> <span className={`status-badge status-${bug.status}`}>
@ -182,6 +218,35 @@ export default function BugList() {
))} ))}
</tbody> </tbody>
</table> </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 && ( {totalPages > 1 && (
<div className="pagination"> <div className="pagination">

View File

@ -2,6 +2,12 @@ import { useState, useEffect } from 'react';
import { Bug, CalendarPlus, TrendingUp, AlertTriangle } from 'lucide-react'; import { Bug, CalendarPlus, TrendingUp, AlertTriangle } from 'lucide-react';
import { getStats, type DashboardStats } from '../api'; import { getStats, type DashboardStats } from '../api';
const SOURCE_LABELS: Record<string, string> = {
runtime: '运行时',
cicd: 'CI/CD',
deployment: '部署',
};
const STATUS_LABELS: Record<string, string> = { const STATUS_LABELS: Record<string, string> = {
NEW: '新发现', NEW: '新发现',
VERIFYING: '验证中', VERIFYING: '验证中',
@ -85,7 +91,8 @@ export default function Dashboard() {
</div> </div>
</div> </div>
<div className="table-container"> <div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: '16px' }}>
<div className="table-container table-compact">
<div className="table-header"> <div className="table-header">
<h3 className="table-title"></h3> <h3 className="table-title"></h3>
</div> </div>
@ -110,6 +117,35 @@ export default function Dashboard() {
</tbody> </tbody>
</table> </table>
</div> </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> </div>
); );
} }

View File

@ -62,24 +62,26 @@ export default function RepairList() {
return ( return (
<div> <div>
<div className="page-header"> <div className="page-header">
<div className="title-row">
<div>
<h1 className="page-title"></h1> <h1 className="page-title"></h1>
<p className="page-subtitle">AI </p> <p className="page-subtitle">AI </p>
</div> </div>
<div className="filters">
<select <div className="project-tabs">
className="filter-select" <button
value={filters.project_id} className={`project-tab ${filters.project_id === '' ? 'active' : ''}`}
onChange={(e) => handleFilterChange('project_id', e.target.value)} onClick={() => handleFilterChange('project_id', '')}
> >
<option value=""></option>
</button>
{projects.map((p) => ( {projects.map((p) => (
<option key={p} value={p}>{p}</option> <button
key={p}
className={`project-tab ${filters.project_id === p ? 'active' : ''}`}
onClick={() => handleFilterChange('project_id', p)}
>
{p}
</button>
))} ))}
</select>
</div>
</div>
</div> </div>
<div className="table-container"> <div className="table-container">
@ -90,6 +92,9 @@ export default function RepairList() {
) : reports.length === 0 ? ( ) : reports.length === 0 ? (
<div className="empty-state"></div> <div className="empty-state"></div>
) : ( ) : (
<>
{/* Desktop table */}
<div className="table-desktop">
<table> <table>
<thead> <thead>
<tr> <tr>
@ -138,6 +143,39 @@ export default function RepairList() {
))} ))}
</tbody> </tbody>
</table> </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> </div>