diff --git a/app/database.py b/app/database.py index 5d5bccf..fdd687b 100644 --- a/app/database.py +++ b/app/database.py @@ -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: diff --git a/app/main.py b/app/main.py index ec484aa..90f36a6 100644 --- a/app/main.py +++ b/app/main.py @@ -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,11 +87,13 @@ 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,36 +197,48 @@ 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() - + # Fixed rate = (FIXED + VERIFIED + DEPLOYED) / Total 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,29 +247,34 @@ 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""" query = select(ErrorLog).order_by(ErrorLog.timestamp.desc()) - + if status: 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 query = query.offset(offset).limit(page_size) - + results = await session.exec(query) bugs = results.all() - + # Get total count for pagination info count_query = select(func.count(ErrorLog.id)) if status: 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() diff --git a/app/models.py b/app/models.py index a2e0c3e..cbdb6db 100644 --- a/app/models.py +++ b/app/models.py @@ -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] = {} diff --git a/docs/log-collection-extension-plan.md b/docs/log-collection-extension-plan.md new file mode 100644 index 0000000..b5ee75d --- /dev/null +++ b/docs/log-collection-extension-plan.md @@ -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 * * * *`) +- RBAC:ServiceAccount + 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 diff --git a/k8s-monitor/Dockerfile b/k8s-monitor/Dockerfile new file mode 100644 index 0000000..665418a --- /dev/null +++ b/k8s-monitor/Dockerfile @@ -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"] diff --git a/k8s-monitor/monitor.py b/k8s-monitor/monitor.py new file mode 100644 index 0000000..4e13a43 --- /dev/null +++ b/k8s-monitor/monitor.py @@ -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() diff --git a/k8s-monitor/requirements.txt b/k8s-monitor/requirements.txt new file mode 100644 index 0000000..f01a073 --- /dev/null +++ b/k8s-monitor/requirements.txt @@ -0,0 +1,2 @@ +kubernetes>=28.1.0 +requests>=2.31.0 diff --git a/k8s/monitor-cronjob.yaml b/k8s/monitor-cronjob.yaml new file mode 100644 index 0000000..8e3d3d2 --- /dev/null +++ b/k8s/monitor-cronjob.yaml @@ -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 diff --git a/repair_agent/agent/task_manager.py b/repair_agent/agent/task_manager.py index 40eefd8..efb860c 100644 --- a/repair_agent/agent/task_manager.py +++ b/repair_agent/agent/task_manager.py @@ -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 diff --git a/scripts/gitea-actions-example.yaml b/scripts/gitea-actions-example.yaml new file mode 100644 index 0000000..8236b31 --- /dev/null +++ b/scripts/gitea-actions-example.yaml @@ -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 diff --git a/scripts/report-cicd-error.sh b/scripts/report-cicd-error.sh new file mode 100755 index 0000000..26a5936 --- /dev/null +++ b/scripts/report-cicd-error.sh @@ -0,0 +1,92 @@ +#!/bin/bash +# report-cicd-error.sh - CI/CD 构建错误上报到 Log Center +# +# 用法: ./report-cicd-error.sh +# +# 环境变量 (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 }" +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 失败" diff --git a/web/src/App.tsx b/web/src/App.tsx index 8affeda..5dc6893 100644 --- a/web/src/App.tsx +++ b/web/src/App.tsx @@ -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 ( +
+ {/* Mobile header */} +
+ +
+
+ 日志中台 +
+
+
+ + {/* Overlay */} + {sidebarOpen && ( +
setSidebarOpen(false)} /> + )} + + + +
+ + } /> + } /> + } /> + } /> + } /> + +
+
+ ); +} + function App() { return ( -
- - -
- - } /> - } /> - } /> - } /> - } /> - -
-
+
); } diff --git a/web/src/api.ts b/web/src/api.ts index e0a0b3d..2aa9ccd 100644 --- a/web/src/api.ts +++ b/web/src/api.ts @@ -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; version?: string; @@ -32,6 +33,7 @@ export interface DashboardStats { today_bugs: number; fix_rate: number; status_distribution: Record; + source_distribution: Record; } export interface PaginatedResponse { @@ -66,6 +68,7 @@ export const getBugs = (params: { page_size?: number; status?: string; project_id?: string; + source?: string; }) => api.get>('/api/v1/bugs', { params }); export const getBugDetail = (id: number) => api.get(`/api/v1/bugs/${id}`); diff --git a/web/src/index.css b/web/src/index.css index 9bf77ee..29a2eb4 100644 --- a/web/src/index.css +++ b/web/src/index.css @@ -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)); + } +} diff --git a/web/src/pages/BugDetail.tsx b/web/src/pages/BugDetail.tsx index c3e23ca..cd709c3 100644 --- a/web/src/pages/BugDetail.tsx +++ b/web/src/pages/BugDetail.tsx @@ -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 = { + runtime: '运行时', + cicd: 'CI/CD', + deployment: '部署', +}; + const STATUS_LABELS: Record = { NEW: '新发现', VERIFYING: '验证中', @@ -77,7 +83,8 @@ export default function BugDetail() { return
未找到该缺陷
; } - 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 (
@@ -94,6 +101,7 @@ export default function BugDetail() {
项目:{bug.project_id} + 来源:{SOURCE_LABELS[bug.source] || '运行时'} 环境:{bug.environment} 级别:{bug.level}
@@ -103,13 +111,40 @@ export default function BugDetail() {
-
-
文件位置
-
- - {bug.file_path} : 第 {bug.line_number} 行 + {bug.file_path && ( +
+
文件位置
+
+ + {bug.file_path} : 第 {bug.line_number} 行 +
-
+ )} + + {bug.source === 'cicd' && bug.context?.workflow_name && ( +
+
CI/CD 信息
+
+ 工作流:{bug.context.workflow_name} / {bug.context.job_name} / {bug.context.step_name} + {bug.context.branch && <>
分支:{bug.context.branch}} + {bug.context.run_url && ( + <>
查看 CI 日志 + )} +
+
+ )} + + {bug.source === 'deployment' && bug.context?.pod_name && ( +
+
部署信息
+
+ 命名空间:{bug.context.namespace} | Pod:{bug.context.pod_name} +
+ 容器:{bug.context.container_name} | 重启次数:{bug.context.restart_count} + {bug.context.node_name && <>
节点:{bug.context.node_name}} +
+
+ )} {bug.commit_hash && (
@@ -163,7 +198,9 @@ export default function BugDetail() { )} {!canTriggerRepair && !repairing && ( - 仅"新发现"或"修复失败"状态的缺陷可触发修复 + {!isRuntime + ? 'CI/CD 和部署错误暂不支持自动修复' + : '仅"新发现"或"修复失败"状态的缺陷可触发修复'} )}
diff --git a/web/src/pages/BugList.tsx b/web/src/pages/BugList.tsx index 3eaf117..6c02b5e 100644 --- a/web/src/pages/BugList.tsx +++ b/web/src/pages/BugList.tsx @@ -7,6 +7,12 @@ const STATUSES = [ 'FIXING', 'FIXED', 'VERIFIED', 'DEPLOYED', 'FIX_FAILED' ]; +const SOURCE_LABELS: Record = { + runtime: '运行时', + cicd: 'CI/CD', + deployment: '部署', +}; + const STATUS_LABELS: Record = { 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) => { @@ -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 = { 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 (
@@ -105,6 +115,24 @@ export default function BugList() { ))}
+
+ + {Object.entries(SOURCE_LABELS).map(([key, label]) => ( + + ))} +
+
+ + + + {SOURCE_LABELS[bug.source] || '运行时'} + + + {bug.error_type} + + {bug.file_path ? `${bug.file_path}:${bug.line_number}` : '-'} + + + + {STATUS_LABELS[bug.status] || bug.status} + + + + {new Date(bug.timestamp).toLocaleString()} + + + ))} + + +
+ + {/* Mobile card list */} +
+ {bugs.map(bug => ( + +
+ #{bug.id} + + {STATUS_LABELS[bug.status] || bug.status} + +
+
{bug.error_type}
+
+ {bug.file_path ? `${bug.file_path}:${bug.line_number}` : SOURCE_LABELS[bug.source] || '运行时'} +
+
+ {bug.project_id} + {new Date(bug.timestamp).toLocaleDateString()} +
+ + ))} +
{totalPages > 1 && (
diff --git a/web/src/pages/Dashboard.tsx b/web/src/pages/Dashboard.tsx index 021306b..0514283 100644 --- a/web/src/pages/Dashboard.tsx +++ b/web/src/pages/Dashboard.tsx @@ -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 = { + runtime: '运行时', + cicd: 'CI/CD', + deployment: '部署', +}; + const STATUS_LABELS: Record = { NEW: '新发现', VERIFYING: '验证中', @@ -85,30 +91,60 @@ export default function Dashboard() {
-
-
-

状态分布

-
- - - - - - - - - {Object.entries(stats.status_distribution).map(([status, count]) => ( - - - +
+
+
+

状态分布

+
+
状态数量
- - {STATUS_LABELS[status] || status} - - {count}
+ + + + - ))} - -
状态数量
+ + + {Object.entries(stats.status_distribution).map(([status, count]) => ( + + + + {STATUS_LABELS[status] || status} + + + {count} + + ))} + + +
+ + {stats.source_distribution && ( +
+
+

来源分布

+
+ + + + + + + + + {Object.entries(stats.source_distribution).map(([source, count]) => ( + + + + + ))} + +
来源数量
+ + {SOURCE_LABELS[source] || source} + + {count}
+
+ )}
); diff --git a/web/src/pages/RepairList.tsx b/web/src/pages/RepairList.tsx index cf8d2be..6fc82df 100644 --- a/web/src/pages/RepairList.tsx +++ b/web/src/pages/RepairList.tsx @@ -62,24 +62,26 @@ export default function RepairList() { return (
-
-
-

修复报告

-

AI 自动修复记录及结果

-
-
- -
-
+

修复报告

+

AI 自动修复记录及结果

+
+ +
+ + {projects.map((p) => ( + + ))}
@@ -90,54 +92,90 @@ export default function RepairList() { ) : reports.length === 0 ? (
暂无修复报告
) : ( - - - - - - - - - - - - - - - - {reports.map((report) => ( - - - - - - - - - - + <> + {/* Desktop table */} +
+
编号项目缺陷编号轮次修改文件数测试结果状态日期操作
#{report.id}{report.project_id} - - #{report.error_log_id} - - 第 {report.repair_round} 轮{report.modified_files.length} 个文件 - - {report.test_passed ? '通过' : '失败'} - - - - {STATUS_LABELS[report.status] || report.status} - - - {new Date(report.created_at).toLocaleString()} - - - 查看 - -
+ + + + + + + + + + + - ))} - -
编号项目缺陷编号轮次修改文件数测试结果状态日期操作
+ + + {reports.map((report) => ( + + #{report.id} + {report.project_id} + + + #{report.error_log_id} + + + 第 {report.repair_round} 轮 + {report.modified_files.length} 个文件 + + + {report.test_passed ? '通过' : '失败'} + + + + + {STATUS_LABELS[report.status] || report.status} + + + + {new Date(report.created_at).toLocaleString()} + + + + 查看 + + + + ))} + + +
+ + {/* Mobile card list */} +
+ {reports.map((report) => ( + +
+ #{report.id} + + {STATUS_LABELS[report.status] || report.status} + +
+
+ {report.project_id} + 缺陷 #{report.error_log_id} + 第 {report.repair_round} 轮 +
+
+ {report.modified_files.length} 个文件修改 + + 测试{report.test_passed ? '通过' : '失败'} + + {new Date(report.created_at).toLocaleDateString()} +
+ + ))} +
+ )}