feat: 扩展日志收集,支持 CI/CD 构建错误和 K8s 部署错误
All checks were successful
Build and Deploy Log Center / build-and-deploy (push) Successful in 2m24s
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:
parent
e9ba36db92
commit
0d4b2d634c
@ -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:
|
||||||
|
|||||||
43
app/main.py
43
app/main.py
@ -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()
|
||||||
|
|
||||||
|
|||||||
@ -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] = {}
|
||||||
|
|
||||||
|
|||||||
197
docs/log-collection-extension-plan.md
Normal file
197
docs/log-collection-extension-plan.md
Normal 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 * * * *`)
|
||||||
|
- 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
|
||||||
13
k8s-monitor/Dockerfile
Normal file
13
k8s-monitor/Dockerfile
Normal 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
155
k8s-monitor/monitor.py
Normal 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()
|
||||||
2
k8s-monitor/requirements.txt
Normal file
2
k8s-monitor/requirements.txt
Normal file
@ -0,0 +1,2 @@
|
|||||||
|
kubernetes>=28.1.0
|
||||||
|
requests>=2.31.0
|
||||||
60
k8s/monitor-cronjob.yaml
Normal file
60
k8s/monitor-cronjob.yaml
Normal 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
|
||||||
@ -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
|
||||||
|
|
||||||
|
|||||||
87
scripts/gitea-actions-example.yaml
Normal file
87
scripts/gitea-actions-example.yaml
Normal 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
92
scripts/report-cicd-error.sh
Executable 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 失败"
|
||||||
@ -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>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@ -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}`);
|
||||||
|
|||||||
@ -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));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@ -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>
|
||||||
|
|||||||
@ -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">
|
||||||
|
|||||||
@ -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>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@ -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>
|
||||||
|
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user