优化:CI 健康度可视化看板(ECharts HTML) #706
@@ -1,19 +1,15 @@
|
||||
name: CI Health Daily Report
|
||||
|
||||
on:
|
||||
schedule:
|
||||
- cron: '0 1 * * *' # UTC 01:00 = 北京时间 09:00
|
||||
workflow_dispatch:
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
jobs:
|
||||
ci-health-report:
|
||||
name: CI健康度每日巡检
|
||||
runs-on: saas
|
||||
timeout-minutes: 10
|
||||
|
||||
timeout-minutes: 15
|
||||
steps:
|
||||
- name: Checkout code
|
||||
shell: sh
|
||||
@@ -61,6 +57,34 @@ jobs:
|
||||
tar.extract(member, '.')
|
||||
PY
|
||||
|
||||
- name: Generate CI Dashboard HTML
|
||||
shell: sh
|
||||
env:
|
||||
GITEA_TOKEN: ${{ github.token }}
|
||||
run: |
|
||||
set +e
|
||||
echo "=== 生成 CI 健康度 HTML 看板 ==="
|
||||
echo "时间: $(date '+%Y-%m-%d %H:%M:%S')"
|
||||
echo ""
|
||||
python3 scripts/ci/ci_dashboard.py --days 7 --html --html-output ci_dashboard.html
|
||||
EXIT_CODE=$?
|
||||
if [ $EXIT_CODE -eq 0 ] && [ -f ci_dashboard.html ]; then
|
||||
HTML_SIZE=$(wc -c < ci_dashboard.html)
|
||||
echo ""
|
||||
echo "✅ HTML 看板生成成功 (${HTML_SIZE} bytes)"
|
||||
echo "路径: $(pwd)/ci_dashboard.html"
|
||||
# 输出文件内容前几行,方便在 Actions 日志中确认
|
||||
echo ""
|
||||
echo "--- 看板预览 (前 5 行) ---"
|
||||
head -5 ci_dashboard.html
|
||||
echo "...(完整内容见产物文件)"
|
||||
else
|
||||
echo "❌ HTML 看板生成失败 (exit code: $EXIT_CODE)"
|
||||
fi
|
||||
echo ""
|
||||
# 永远成功,看板生成失败不影响主流程
|
||||
exit 0
|
||||
|
||||
- name: Run CI health check and report
|
||||
shell: sh
|
||||
env:
|
||||
@@ -71,10 +95,8 @@ jobs:
|
||||
echo "=== CI健康度每日巡检 ==="
|
||||
echo "时间: $(date '+%Y-%m-%d %H:%M:%S')"
|
||||
echo ""
|
||||
|
||||
python3 scripts/ci/ci_health_report.py --limit 30
|
||||
EXIT_CODE=$?
|
||||
|
||||
echo ""
|
||||
echo "巡检完成 (exit code: $EXIT_CODE)"
|
||||
# 永远成功,不影响CI状态(通知失败不应该标红)
|
||||
|
||||
+420
-22
@@ -1,12 +1,11 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
CI 可观测性看板 - 从 Gitea Actions API 拉取数据并生成 Markdown 日报
|
||||
|
||||
CI 可观测性看板 - 从 Gitea Actions API 拉取数据并生成 Markdown/HTML 日报
|
||||
用法:
|
||||
python3 scripts/ci/ci_dashboard.py --days 7
|
||||
python3 scripts/ci/ci_dashboard.py --days 30 --output ci_report.md
|
||||
python3 scripts/ci/ci_dashboard.py --workflow ci-cd.yml --days 7
|
||||
|
||||
python3 scripts/ci/ci_dashboard.py --days 7 --html --html-output dashboard.html
|
||||
环境变量:
|
||||
GITEA_URL Gitea 地址 (默认 https://git.xiaoxiajianji.com)
|
||||
GITEA_REPO 仓库 (默认 xiaoxia/xiaoxia-saas)
|
||||
@@ -177,18 +176,14 @@ def fetch_runs_in_range(ga, start_date, end_date, workflow_filter=None):
|
||||
all_runs = []
|
||||
page = 1
|
||||
print(f"[INFO] 拉取 {start_date} ~ {end_date} 的 CI runs...", file=sys.stderr)
|
||||
|
||||
while True:
|
||||
runs, total = ga.list_runs(status="completed", page=page, limit=PAGE_LIMIT)
|
||||
if not runs:
|
||||
break
|
||||
|
||||
if workflow_filter:
|
||||
runs = [r for r in runs if workflow_filter in r.get("path", "")]
|
||||
|
||||
in_range = []
|
||||
out_range_old = False
|
||||
|
||||
for run in runs:
|
||||
started = to_shanghai(parse_datetime(run.get("started_at")))
|
||||
if not started:
|
||||
@@ -198,27 +193,22 @@ def fetch_runs_in_range(ga, start_date, end_date, workflow_filter=None):
|
||||
in_range.append(run)
|
||||
elif run_date < start_date:
|
||||
out_range_old = True
|
||||
|
||||
all_runs.extend(in_range)
|
||||
print(
|
||||
f"[INFO] 第 {page} 页: {len(runs)} 条, 范围内 {len(in_range)} 条, 累计 {len(all_runs)} 条", file=sys.stderr
|
||||
)
|
||||
|
||||
if out_range_old or len(runs) < PAGE_LIMIT:
|
||||
break
|
||||
|
||||
page += 1
|
||||
if page > 100:
|
||||
print("[WARN] 超过100页,停止拉取", file=sys.stderr)
|
||||
break
|
||||
|
||||
print(f"[INFO] 共获取 {len(all_runs)} 条 run 数据", file=sys.stderr)
|
||||
return all_runs
|
||||
|
||||
|
||||
def enrich_with_jobs(ga, runs, max_failures=50):
|
||||
"""为 runs 补充 job 详情(失败原因分析 + runner 统计)
|
||||
|
||||
失败 run 按时间倒序取最近 N 个(避免 API 调用过多),
|
||||
成功 run 采样用于 runner 分布统计。
|
||||
"""
|
||||
@@ -226,13 +216,11 @@ def enrich_with_jobs(ga, runs, max_failures=50):
|
||||
failure_runs = [r for r in runs if r.get("conclusion") != "success"]
|
||||
failure_runs = failure_runs[:max_failures] # 已经是时间倒序
|
||||
print(f"[INFO] 为最近 {len(failure_runs)} 个失败 run 拉取 job 详情...", file=sys.stderr)
|
||||
|
||||
for i, run in enumerate(failure_runs):
|
||||
jobs = ga.get_run_jobs(run["id"])
|
||||
run["_jobs"] = jobs
|
||||
if (i + 1) % 10 == 0:
|
||||
print(f"[INFO] 已处理 {i+1}/{len(failure_runs)}", file=sys.stderr)
|
||||
|
||||
# 成功 run 采样用于 runner 分布
|
||||
success_runs = [r for r in runs if r.get("conclusion") == "success"]
|
||||
sample_size = min(50, len(success_runs))
|
||||
@@ -243,7 +231,6 @@ def enrich_with_jobs(ga, runs, max_failures=50):
|
||||
if "_jobs" not in run:
|
||||
jobs = ga.get_run_jobs(run["id"])
|
||||
run["_jobs"] = jobs
|
||||
|
||||
return runs
|
||||
|
||||
|
||||
@@ -268,7 +255,6 @@ def analyze_runs(runs):
|
||||
if d and d > 0:
|
||||
durations.append(d)
|
||||
durations.sort()
|
||||
|
||||
avg_dur = statistics.mean(durations) if durations else None
|
||||
median_dur = percentile(durations, 50)
|
||||
p95_dur = percentile(durations, 95)
|
||||
@@ -316,8 +302,31 @@ def analyze_runs(runs):
|
||||
# 失败原因 + runner + job 耗时(需要 _jobs 数据)
|
||||
failure_categories = defaultdict(int)
|
||||
failed_jobs_by_name = defaultdict(int)
|
||||
job_success_stats = defaultdict(lambda: {"total": 0, "success": 0, "failure": 0})
|
||||
runner_stats = defaultdict(lambda: {"jobs": 0, "success": 0, "failure": 0, "durations": []})
|
||||
job_time_stats = defaultdict(list)
|
||||
infra_failures = 0
|
||||
business_failures = 0
|
||||
other_failures_count = 0
|
||||
|
||||
# 基础设施关键词(与 ci_health_check.py 保持一致的分类逻辑)
|
||||
infra_job_keywords = ["checkout", "build", "deploy", "cleanup", "setup", "cache", "install", "docker"]
|
||||
business_job_keywords = [
|
||||
"unit test",
|
||||
"pytest",
|
||||
"vitest",
|
||||
"jest",
|
||||
"lint",
|
||||
"eslint",
|
||||
"prettier",
|
||||
"integration",
|
||||
"e2e",
|
||||
"validate",
|
||||
"code quality",
|
||||
"mypy",
|
||||
"ruff",
|
||||
"flake8",
|
||||
]
|
||||
|
||||
for r in runs:
|
||||
jobs = r.get("_jobs", [])
|
||||
@@ -326,27 +335,45 @@ def analyze_runs(runs):
|
||||
for job in jobs:
|
||||
runner = job.get("runner_name", "unknown")
|
||||
conclusion = job.get("conclusion", "unknown")
|
||||
job_name = job.get("name", "unknown")
|
||||
job_name_lower = job_name.lower()
|
||||
|
||||
runner_stats[runner]["jobs"] += 1
|
||||
job_success_stats[job_name]["total"] += 1
|
||||
if conclusion == "success":
|
||||
runner_stats[runner]["success"] += 1
|
||||
job_success_stats[job_name]["success"] += 1
|
||||
elif conclusion == "failure":
|
||||
runner_stats[runner]["failure"] += 1
|
||||
job_success_stats[job_name]["failure"] += 1
|
||||
|
||||
jd = duration_seconds(job.get("started_at"), job.get("completed_at"))
|
||||
if jd and jd > 0:
|
||||
runner_stats[runner]["durations"].append(jd)
|
||||
job_time_stats[job.get("name", "unknown")].append(jd)
|
||||
job_time_stats[job_name].append(jd)
|
||||
|
||||
if conclusion == "failure":
|
||||
failed_jobs_by_name[job.get("name", "unknown")] += 1
|
||||
failed_jobs_by_name[job_name] += 1
|
||||
failed_step = None
|
||||
for step in job.get("steps", []):
|
||||
if step.get("conclusion") == "failure":
|
||||
failed_step = step.get("name")
|
||||
break
|
||||
category = classify_failure(job.get("name", ""), failed_step)
|
||||
category = classify_failure(job_name, failed_step)
|
||||
failure_categories[category] += 1
|
||||
|
||||
# 基础设施 vs 业务代码分类
|
||||
is_infra = any(k in job_name_lower for k in infra_job_keywords) and not any(
|
||||
k in job_name_lower for k in business_job_keywords
|
||||
)
|
||||
is_business = any(k in job_name_lower for k in business_job_keywords)
|
||||
if is_infra:
|
||||
infra_failures += 1
|
||||
elif is_business:
|
||||
business_failures += 1
|
||||
else:
|
||||
other_failures_count += 1
|
||||
|
||||
return {
|
||||
"total": total,
|
||||
"success": success,
|
||||
@@ -365,14 +392,17 @@ def analyze_runs(runs):
|
||||
"failed_jobs_top": dict(sorted(failed_jobs_by_name.items(), key=lambda x: -x[1])[:15]),
|
||||
"runner_stats": dict(runner_stats),
|
||||
"job_time_stats": dict(job_time_stats),
|
||||
"job_success_stats": dict(job_success_stats),
|
||||
"infra_failures": infra_failures,
|
||||
"business_failures": business_failures,
|
||||
"other_failures_combined": other_failures_count,
|
||||
}
|
||||
|
||||
|
||||
# ── 报表生成 ─────────────────────────────────────────
|
||||
# ── Markdown 报表生成 ────────────────────────────────
|
||||
def generate_markdown(stats, start_date, end_date, repo):
|
||||
"""生成 Markdown 格式的日报"""
|
||||
lines = []
|
||||
|
||||
lines.append("# CI 运行状态看板")
|
||||
lines.append("")
|
||||
lines.append(f"> 统计周期: **{start_date} ~ {end_date}**")
|
||||
@@ -526,6 +556,359 @@ def generate_markdown(stats, start_date, end_date, repo):
|
||||
return "\n".join(lines)
|
||||
|
||||
|
||||
# ── HTML 看板生成 ────────────────────────────────────
|
||||
def generate_html(stats, start_date, end_date, repo):
|
||||
"""生成 HTML 格式的可视化看板(内嵌 ECharts)"""
|
||||
# 准备图表数据
|
||||
|
||||
# 1. 每日成功率趋势
|
||||
daily_dates = list(stats["daily_stats"].keys())
|
||||
daily_success_rates = []
|
||||
daily_run_counts = []
|
||||
for day in daily_dates:
|
||||
s = stats["daily_stats"][day]
|
||||
rate = (s["success"] / s["total"] * 100) if s["total"] > 0 else 0
|
||||
daily_success_rates.append(round(rate, 1))
|
||||
daily_run_counts.append(s["total"])
|
||||
|
||||
# 2. 各 Workflow 耗时对比
|
||||
wf_sorted = sorted(stats["workflow_stats"].items(), key=lambda x: -x[1]["total"])
|
||||
wf_names = []
|
||||
wf_avg_durations = []
|
||||
for wf, s in wf_sorted:
|
||||
wf_short = wf.split("/")[-1] if "/" in wf else wf
|
||||
wf_names.append(wf_short)
|
||||
avg = statistics.mean(s["durations"]) if s["durations"] else 0
|
||||
wf_avg_durations.append(round(avg / 60, 1)) # 转为分钟
|
||||
|
||||
# 3. 失败原因分布(饼图数据 - 基础设施 vs 业务 vs 其他)
|
||||
total_infra_biz = stats["infra_failures"] + stats["business_failures"] + stats["other_failures_combined"]
|
||||
infra_rate = (stats["infra_failures"] / total_infra_biz * 100) if total_infra_biz > 0 else 0
|
||||
failure_pie_data = [
|
||||
{"value": stats["infra_failures"], "name": "基础设施问题"},
|
||||
{"value": stats["business_failures"], "name": "业务代码问题"},
|
||||
{"value": stats["other_failures_combined"], "name": "其他"},
|
||||
]
|
||||
|
||||
# 4. 各 Job 成功率排行(横向柱状图,取成功率最低的 Top 15)
|
||||
job_stats_list = []
|
||||
for name, s in stats["job_success_stats"].items():
|
||||
if s["total"] >= 3: # 至少有3次才统计
|
||||
rate = (s["success"] / s["total"] * 100) if s["total"] > 0 else 0
|
||||
job_stats_list.append(
|
||||
{
|
||||
"name": name,
|
||||
"rate": round(rate, 1),
|
||||
"total": s["total"],
|
||||
"success": s["success"],
|
||||
}
|
||||
)
|
||||
job_stats_list.sort(key=lambda x: x["rate"])
|
||||
job_stats_list = job_stats_list[:15] # 取成功率最低的15个
|
||||
job_names = [j["name"] for j in job_stats_list]
|
||||
job_rates = [j["rate"] for j in job_stats_list]
|
||||
|
||||
# 核心指标
|
||||
total_runs = stats["total"]
|
||||
success_rate = round(stats["success_rate"], 1)
|
||||
avg_dur_min = round(stats["avg_duration"] / 60, 1) if stats["avg_duration"] else 0
|
||||
infra_fail_rate = round(infra_rate, 1)
|
||||
|
||||
now_str = datetime.now().strftime("%Y-%m-%d %H:%M:%S")
|
||||
|
||||
# 序列化数据为 JSON(供 JS 使用)
|
||||
data_json = json.dumps(
|
||||
{
|
||||
"daily_dates": daily_dates,
|
||||
"daily_success_rates": daily_success_rates,
|
||||
"daily_run_counts": daily_run_counts,
|
||||
"wf_names": wf_names,
|
||||
"wf_avg_durations": wf_avg_durations,
|
||||
"failure_pie_data": failure_pie_data,
|
||||
"job_names": job_names,
|
||||
"job_rates": job_rates,
|
||||
},
|
||||
ensure_ascii=False,
|
||||
)
|
||||
|
||||
# HTML 模板(注意:不使用 f-string,避免与 CSS/JS 的大括号冲突)
|
||||
html_parts = []
|
||||
html_parts.append("<!DOCTYPE html>")
|
||||
html_parts.append('<html lang="zh-CN">')
|
||||
html_parts.append("<head>")
|
||||
html_parts.append(' <meta charset="UTF-8">')
|
||||
html_parts.append(' <meta name="viewport" content="width=device-width, initial-scale=1.0">')
|
||||
html_parts.append(f" <title>CI 健康度看板 - {repo}</title>")
|
||||
html_parts.append(' <script src="https://cdn.jsdelivr.net/npm/echarts/dist/echarts.min.js"></script>')
|
||||
html_parts.append(" <style>")
|
||||
html_parts.append(" * { margin: 0; padding: 0; box-sizing: border-box; }")
|
||||
html_parts.append(" body {")
|
||||
html_parts.append(
|
||||
' font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", "PingFang SC", "Hiragino Sans GB",'
|
||||
)
|
||||
html_parts.append(' "Microsoft YaHei", sans-serif;')
|
||||
html_parts.append(" background: #f0f2f5;")
|
||||
html_parts.append(" color: #333;")
|
||||
html_parts.append(" padding: 20px;")
|
||||
html_parts.append(" }")
|
||||
html_parts.append(" .container { max-width: 1400px; margin: 0 auto; }")
|
||||
html_parts.append(" .header {")
|
||||
html_parts.append(" background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);")
|
||||
html_parts.append(" color: white;")
|
||||
html_parts.append(" padding: 24px 32px;")
|
||||
html_parts.append(" border-radius: 12px;")
|
||||
html_parts.append(" margin-bottom: 20px;")
|
||||
html_parts.append(" }")
|
||||
html_parts.append(" .header h1 { font-size: 24px; margin-bottom: 8px; }")
|
||||
html_parts.append(" .header .subtitle { font-size: 14px; opacity: 0.9; }")
|
||||
html_parts.append(" .header .meta { font-size: 12px; opacity: 0.8; margin-top: 8px; }")
|
||||
html_parts.append(" .metrics-row {")
|
||||
html_parts.append(" display: grid;")
|
||||
html_parts.append(" grid-template-columns: repeat(4, 1fr);")
|
||||
html_parts.append(" gap: 16px;")
|
||||
html_parts.append(" margin-bottom: 20px;")
|
||||
html_parts.append(" }")
|
||||
html_parts.append(" .metric-card {")
|
||||
html_parts.append(" background: white;")
|
||||
html_parts.append(" border-radius: 12px;")
|
||||
html_parts.append(" padding: 20px;")
|
||||
html_parts.append(" box-shadow: 0 2px 8px rgba(0,0,0,0.06);")
|
||||
html_parts.append(" transition: transform 0.2s;")
|
||||
html_parts.append(" }")
|
||||
html_parts.append(
|
||||
" .metric-card:hover { transform: translateY(-2px); box-shadow: 0 4px 12px rgba(0,0,0,0.1); }"
|
||||
)
|
||||
html_parts.append(" .metric-card .label { font-size: 13px; color: #8c8c8c; margin-bottom: 8px; }")
|
||||
html_parts.append(" .metric-card .value { font-size: 28px; font-weight: 600; }")
|
||||
html_parts.append(" .metric-card .unit { font-size: 14px; color: #8c8c8c; margin-left: 4px; }")
|
||||
html_parts.append(" .metric-card.success .value { color: #52c41a; }")
|
||||
html_parts.append(" .metric-card.warning .value { color: #faad14; }")
|
||||
html_parts.append(" .metric-card.danger .value { color: #ff4d4f; }")
|
||||
html_parts.append(" .metric-card.info .value { color: #1890ff; }")
|
||||
html_parts.append(" .charts-grid {")
|
||||
html_parts.append(" display: grid;")
|
||||
html_parts.append(" grid-template-columns: 1fr 1fr;")
|
||||
html_parts.append(" gap: 16px;")
|
||||
html_parts.append(" margin-bottom: 20px;")
|
||||
html_parts.append(" }")
|
||||
html_parts.append(" .chart-card {")
|
||||
html_parts.append(" background: white;")
|
||||
html_parts.append(" border-radius: 12px;")
|
||||
html_parts.append(" padding: 20px;")
|
||||
html_parts.append(" box-shadow: 0 2px 8px rgba(0,0,0,0.06);")
|
||||
html_parts.append(" }")
|
||||
html_parts.append(" .chart-card.full-width { grid-column: 1 / -1; }")
|
||||
html_parts.append(" .chart-card h3 {")
|
||||
html_parts.append(" font-size: 16px;")
|
||||
html_parts.append(" margin-bottom: 12px;")
|
||||
html_parts.append(" color: #262626;")
|
||||
html_parts.append(" font-weight: 600;")
|
||||
html_parts.append(" }")
|
||||
html_parts.append(" .chart-container { width: 100%; height: 320px; }")
|
||||
html_parts.append(" .chart-container.tall { height: 400px; }")
|
||||
html_parts.append(" .footer {")
|
||||
html_parts.append(" text-align: center;")
|
||||
html_parts.append(" color: #8c8c8c;")
|
||||
html_parts.append(" font-size: 12px;")
|
||||
html_parts.append(" padding: 16px 0;")
|
||||
html_parts.append(" }")
|
||||
html_parts.append(" @media (max-width: 900px) {")
|
||||
html_parts.append(" .metrics-row { grid-template-columns: repeat(2, 1fr); }")
|
||||
html_parts.append(" .charts-grid { grid-template-columns: 1fr; }")
|
||||
html_parts.append(" }")
|
||||
html_parts.append(" @media (max-width: 600px) {")
|
||||
html_parts.append(" .metrics-row { grid-template-columns: 1fr; }")
|
||||
html_parts.append(" body { padding: 12px; }")
|
||||
html_parts.append(" }")
|
||||
html_parts.append(" </style>")
|
||||
html_parts.append("</head>")
|
||||
html_parts.append("<body>")
|
||||
html_parts.append(' <div class="container">')
|
||||
html_parts.append(' <div class="header">')
|
||||
html_parts.append(" <h1>📊 CI 健康度看板</h1>")
|
||||
html_parts.append(f' <div class="subtitle">仓库: {repo}</div>')
|
||||
html_parts.append(f' <div class="meta">统计周期: {start_date} ~ {end_date} | 生成时间: {now_str}</div>')
|
||||
html_parts.append(" </div>")
|
||||
html_parts.append(' <div class="metrics-row">')
|
||||
html_parts.append(' <div class="metric-card success">')
|
||||
html_parts.append(' <div class="label">总成功率</div>')
|
||||
html_parts.append(f' <div class="value">{success_rate}<span class="unit">%</span></div>')
|
||||
html_parts.append(" </div>")
|
||||
html_parts.append(' <div class="metric-card info">')
|
||||
html_parts.append(' <div class="label">总 Run 数</div>')
|
||||
html_parts.append(f' <div class="value">{total_runs}<span class="unit">次</span></div>')
|
||||
html_parts.append(" </div>")
|
||||
html_parts.append(' <div class="metric-card warning">')
|
||||
html_parts.append(' <div class="label">平均耗时</div>')
|
||||
html_parts.append(f' <div class="value">{avg_dur_min}<span class="unit">分钟</span></div>')
|
||||
html_parts.append(" </div>")
|
||||
html_parts.append(' <div class="metric-card danger">')
|
||||
html_parts.append(' <div class="label">基础设施故障率</div>')
|
||||
html_parts.append(f' <div class="value">{infra_fail_rate}<span class="unit">%</span></div>')
|
||||
html_parts.append(" </div>")
|
||||
html_parts.append(" </div>")
|
||||
html_parts.append(' <div class="charts-grid">')
|
||||
html_parts.append(' <div class="chart-card full-width">')
|
||||
html_parts.append(" <h3>📈 CI 成功率趋势</h3>")
|
||||
html_parts.append(' <div id="chart-success-rate" class="chart-container"></div>')
|
||||
html_parts.append(" </div>")
|
||||
html_parts.append(" </div>")
|
||||
html_parts.append(' <div class="charts-grid">')
|
||||
html_parts.append(' <div class="chart-card">')
|
||||
html_parts.append(" <h3>⏱️ 各 Workflow 平均耗时</h3>")
|
||||
html_parts.append(' <div id="chart-wf-duration" class="chart-container"></div>')
|
||||
html_parts.append(" </div>")
|
||||
html_parts.append(' <div class="chart-card">')
|
||||
html_parts.append(" <h3>❌ 失败原因分布</h3>")
|
||||
html_parts.append(' <div id="chart-failure-pie" class="chart-container"></div>')
|
||||
html_parts.append(" </div>")
|
||||
html_parts.append(" </div>")
|
||||
html_parts.append(' <div class="charts-grid">')
|
||||
html_parts.append(' <div class="chart-card full-width">')
|
||||
html_parts.append(" <h3>📋 各 Job 成功率排行(最低 15 名)</h3>")
|
||||
html_parts.append(' <div id="chart-job-success" class="chart-container tall"></div>')
|
||||
html_parts.append(" </div>")
|
||||
html_parts.append(" </div>")
|
||||
html_parts.append(' <div class="charts-grid">')
|
||||
html_parts.append(' <div class="chart-card full-width">')
|
||||
html_parts.append(" <h3>📊 每日 Run 数量趋势</h3>")
|
||||
html_parts.append(' <div id="chart-run-count" class="chart-container"></div>')
|
||||
html_parts.append(" </div>")
|
||||
html_parts.append(" </div>")
|
||||
html_parts.append(' <div class="footer">')
|
||||
html_parts.append(" 由 ci_dashboard.py 自动生成 | ECharts 可视化")
|
||||
html_parts.append(" </div>")
|
||||
html_parts.append(" </div>")
|
||||
html_parts.append(" <script>")
|
||||
html_parts.append(f" const DATA = {data_json};")
|
||||
html_parts.append("")
|
||||
# 图表 1: 成功率趋势
|
||||
html_parts.append(" (function() {")
|
||||
html_parts.append(' const chart = echarts.init(document.getElementById("chart-success-rate"));')
|
||||
html_parts.append(" chart.setOption({")
|
||||
html_parts.append(' tooltip: { trigger: "axis", formatter: function(params) {')
|
||||
html_parts.append(' const p = params[0]; return p.name + "<br/>成功率: <b>" + p.value + "%</b>";')
|
||||
html_parts.append(" }},")
|
||||
html_parts.append(' grid: { left: "3%", right: "4%", bottom: "3%", containLabel: true },')
|
||||
html_parts.append(' xAxis: { type: "category", boundaryGap: false, data: DATA.daily_dates,')
|
||||
html_parts.append(" axisLabel: { rotate: 30, fontSize: 11 } },")
|
||||
html_parts.append(' yAxis: { type: "value", min: 0, max: 100, axisLabel: { formatter: "{value}%" } },')
|
||||
html_parts.append(
|
||||
' series: [{ name: "成功率", type: "line", smooth: true, data: DATA.daily_success_rates,'
|
||||
)
|
||||
html_parts.append(' itemStyle: { color: "#52c41a" },')
|
||||
html_parts.append(" areaStyle: { color: new echarts.graphic.LinearGradient(0, 0, 0, 1, [")
|
||||
html_parts.append(' { offset: 0, color: "rgba(82, 196, 26, 0.3)" },')
|
||||
html_parts.append(' { offset: 1, color: "rgba(82, 196, 26, 0.05)" }')
|
||||
html_parts.append(" ])},")
|
||||
html_parts.append(' markLine: { silent: true, data: [{ type: "average", name: "平均值",')
|
||||
html_parts.append(' label: { formatter: "均值 {c}%" } }] }')
|
||||
html_parts.append(" }]")
|
||||
html_parts.append(" });")
|
||||
html_parts.append(' window.addEventListener("resize", () => chart.resize());')
|
||||
html_parts.append(" })();")
|
||||
html_parts.append("")
|
||||
# 图表 2: Workflow 耗时对比
|
||||
html_parts.append(" (function() {")
|
||||
html_parts.append(' const chart = echarts.init(document.getElementById("chart-wf-duration"));')
|
||||
html_parts.append(" chart.setOption({")
|
||||
html_parts.append(' tooltip: { trigger: "axis", formatter: function(params) {')
|
||||
html_parts.append(
|
||||
' const p = params[0]; return p.name + "<br/>平均耗时: <b>" + p.value + " 分钟</b>";'
|
||||
)
|
||||
html_parts.append(" }},")
|
||||
html_parts.append(' grid: { left: "3%", right: "4%", bottom: "15%", containLabel: true },')
|
||||
html_parts.append(' xAxis: { type: "category", data: DATA.wf_names,')
|
||||
html_parts.append(" axisLabel: { rotate: 30, fontSize: 10, interval: 0 } },")
|
||||
html_parts.append(' yAxis: { type: "value", name: "分钟", axisLabel: { formatter: "{value} min" } },')
|
||||
html_parts.append(' series: [{ name: "平均耗时", type: "bar", data: DATA.wf_avg_durations,')
|
||||
html_parts.append(" itemStyle: { color: new echarts.graphic.LinearGradient(0, 0, 0, 1, [")
|
||||
html_parts.append(' { offset: 0, color: "#1890ff" },')
|
||||
html_parts.append(' { offset: 1, color: "#096dd9" }')
|
||||
html_parts.append(" ]), borderRadius: [4, 4, 0, 0] },")
|
||||
html_parts.append(" barMaxWidth: 40")
|
||||
html_parts.append(" }]")
|
||||
html_parts.append(" });")
|
||||
html_parts.append(' window.addEventListener("resize", () => chart.resize());')
|
||||
html_parts.append(" })();")
|
||||
html_parts.append("")
|
||||
# 图表 3: 失败原因饼图
|
||||
html_parts.append(" (function() {")
|
||||
html_parts.append(' const chart = echarts.init(document.getElementById("chart-failure-pie"));')
|
||||
html_parts.append(" chart.setOption({")
|
||||
html_parts.append(' tooltip: { trigger: "item", formatter: "{b}: {c} 次 ({d}%)" },')
|
||||
html_parts.append(' legend: { orient: "vertical", right: "5%", top: "center" },')
|
||||
html_parts.append(
|
||||
' series: [{ name: "失败原因", type: "pie", radius: ["40%", "70%"], center: ["35%", "50%"],'
|
||||
)
|
||||
html_parts.append(" avoidLabelOverlap: false,")
|
||||
html_parts.append(' itemStyle: { borderRadius: 6, borderColor: "#fff", borderWidth: 2 },')
|
||||
html_parts.append(' label: { show: false, position: "center" },')
|
||||
html_parts.append(' emphasis: { label: { show: true, fontSize: 16, fontWeight: "bold" } },')
|
||||
html_parts.append(" labelLine: { show: false },")
|
||||
html_parts.append(" data: DATA.failure_pie_data,")
|
||||
html_parts.append(' color: ["#ff4d4f", "#faad14", "#8c8c8c"]')
|
||||
html_parts.append(" }]")
|
||||
html_parts.append(" });")
|
||||
html_parts.append(' window.addEventListener("resize", () => chart.resize());')
|
||||
html_parts.append(" })();")
|
||||
html_parts.append("")
|
||||
# 图表 4: Job 成功率排行(横向柱状图)
|
||||
html_parts.append(" (function() {")
|
||||
html_parts.append(' const chart = echarts.init(document.getElementById("chart-job-success"));')
|
||||
html_parts.append(" const barData = DATA.job_rates.map(function(rate, i) {")
|
||||
html_parts.append(" return { value: rate, itemStyle: {")
|
||||
html_parts.append(' color: rate >= 90 ? "#52c41a" : (rate >= 70 ? "#faad14" : "#ff4d4f")')
|
||||
html_parts.append(" }};")
|
||||
html_parts.append(" });")
|
||||
html_parts.append(" chart.setOption({")
|
||||
html_parts.append(' tooltip: { trigger: "axis", formatter: function(params) {')
|
||||
html_parts.append(' const p = params[0]; return p.name + "<br/>成功率: <b>" + p.value + "%</b>";')
|
||||
html_parts.append(" }},")
|
||||
html_parts.append(' grid: { left: "3%", right: "8%", bottom: "3%", top: "3%", containLabel: true },')
|
||||
html_parts.append(' xAxis: { type: "value", min: 0, max: 100, axisLabel: { formatter: "{value}%" } },')
|
||||
html_parts.append(' yAxis: { type: "category", data: DATA.job_names, axisLabel: { fontSize: 11 } },')
|
||||
html_parts.append(' series: [{ name: "成功率", type: "bar", data: barData, barWidth: "60%",')
|
||||
html_parts.append(' label: { show: true, position: "right", formatter: "{c}%", fontSize: 11 }')
|
||||
html_parts.append(" }]")
|
||||
html_parts.append(" });")
|
||||
html_parts.append(' window.addEventListener("resize", () => chart.resize());')
|
||||
html_parts.append(" })();")
|
||||
html_parts.append("")
|
||||
# 图表 5: 每日 Run 数量趋势(面积图)
|
||||
html_parts.append(" (function() {")
|
||||
html_parts.append(' const chart = echarts.init(document.getElementById("chart-run-count"));')
|
||||
html_parts.append(" chart.setOption({")
|
||||
html_parts.append(' tooltip: { trigger: "axis", formatter: function(params) {')
|
||||
html_parts.append(
|
||||
' const p = params[0]; return p.name + "<br/>Run 数量: <b>" + p.value + " 次</b>";'
|
||||
)
|
||||
html_parts.append(" }},")
|
||||
html_parts.append(' grid: { left: "3%", right: "4%", bottom: "3%", containLabel: true },')
|
||||
html_parts.append(' xAxis: { type: "category", boundaryGap: false, data: DATA.daily_dates,')
|
||||
html_parts.append(" axisLabel: { rotate: 30, fontSize: 11 } },")
|
||||
html_parts.append(' yAxis: { type: "value", name: "次数" },')
|
||||
html_parts.append(
|
||||
' series: [{ name: "Run 数量", type: "line", smooth: true, data: DATA.daily_run_counts,'
|
||||
)
|
||||
html_parts.append(' itemStyle: { color: "#722ed1" },')
|
||||
html_parts.append(" areaStyle: { color: new echarts.graphic.LinearGradient(0, 0, 0, 1, [")
|
||||
html_parts.append(' { offset: 0, color: "rgba(114, 46, 209, 0.3)" },')
|
||||
html_parts.append(' { offset: 1, color: "rgba(114, 46, 209, 0.05)" }')
|
||||
html_parts.append(" ])},")
|
||||
html_parts.append(' markLine: { silent: true, data: [{ type: "average", name: "平均值",')
|
||||
html_parts.append(' label: { formatter: "均值 {c} 次" } }] }')
|
||||
html_parts.append(" }]")
|
||||
html_parts.append(" });")
|
||||
html_parts.append(' window.addEventListener("resize", () => chart.resize());')
|
||||
html_parts.append(" })();")
|
||||
html_parts.append(" </script>")
|
||||
html_parts.append("</body>")
|
||||
html_parts.append("</html>")
|
||||
|
||||
return "\n".join(html_parts)
|
||||
|
||||
|
||||
# ── 主函数 ───────────────────────────────────────────
|
||||
def main():
|
||||
parser = argparse.ArgumentParser(description="CI 可观测性看板 - 生成 Gitea Actions 运行状态报表")
|
||||
@@ -539,6 +922,11 @@ def main():
|
||||
parser.add_argument("--password", type=str, default=os.environ.get("GITEA_PASSWORD"))
|
||||
parser.add_argument("--no-job-detail", action="store_true", help="不拉取 job 详情")
|
||||
parser.add_argument("--max-failures", type=int, default=50, help="最多分析多少个失败 run 的 job 详情 (默认 50)")
|
||||
|
||||
# HTML 输出相关参数
|
||||
parser.add_argument("--html", action="store_true", help="生成 HTML 可视化看板")
|
||||
parser.add_argument("--html-output", type=str, help="HTML 输出文件路径 (默认 ci_dashboard.html)")
|
||||
|
||||
args = parser.parse_args()
|
||||
|
||||
ga = GiteaActions(
|
||||
@@ -561,8 +949,18 @@ def main():
|
||||
runs = enrich_with_jobs(ga, runs, max_failures=args.max_failures)
|
||||
|
||||
stats = analyze_runs(runs)
|
||||
md = generate_markdown(stats, start_date, end_date, args.repo)
|
||||
|
||||
# HTML 模式
|
||||
if args.html:
|
||||
html = generate_html(stats, start_date, end_date, args.repo)
|
||||
html_output = args.html_output or args.output or "ci_dashboard.html"
|
||||
with open(html_output, "w", encoding="utf-8") as f:
|
||||
f.write(html)
|
||||
print(f"[INFO] HTML 看板已保存到 {html_output}", file=sys.stderr)
|
||||
return
|
||||
|
||||
# 默认 Markdown 模式(向后兼容)
|
||||
md = generate_markdown(stats, start_date, end_date, args.repo)
|
||||
if args.output:
|
||||
with open(args.output, "w", encoding="utf-8") as f:
|
||||
f.write(md)
|
||||
|
||||
@@ -0,0 +1,97 @@
|
||||
#!/usr/bin/env bash
|
||||
#
|
||||
# CI 健康度看板一键生成脚本
|
||||
# - 从 Gitea Actions API 拉取数据
|
||||
# - 生成 HTML 可视化看板
|
||||
# - 输出文件路径
|
||||
#
|
||||
# 用法:
|
||||
# bash scripts/ci/generate_ci_dashboard.sh [--days 7] [--output ci_dashboard.html]
|
||||
#
|
||||
# 环境变量:
|
||||
# GITEA_TOKEN API Token(必需)
|
||||
# GITEA_URL Gitea 地址(可选,默认 https://git.xiaoxiajianji.com)
|
||||
# GITEA_REPO 仓库(可选,默认 xiaoxia/xiaoxia-saas)
|
||||
#
|
||||
|
||||
set -euo pipefail
|
||||
|
||||
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||
REPO_ROOT="$(cd "${SCRIPT_DIR}/../.." && pwd)"
|
||||
|
||||
# 默认参数
|
||||
DAYS=7
|
||||
OUTPUT="ci_dashboard.html"
|
||||
|
||||
# 解析参数
|
||||
while [[ $# -gt 0 ]]; do
|
||||
case "$1" in
|
||||
--days)
|
||||
DAYS="$2"
|
||||
shift 2
|
||||
;;
|
||||
--output|-o)
|
||||
OUTPUT="$2"
|
||||
shift 2
|
||||
;;
|
||||
--help|-h)
|
||||
echo "用法: bash scripts/ci/generate_ci_dashboard.sh [--days 7] [--output ci_dashboard.html]"
|
||||
echo ""
|
||||
echo "选项:"
|
||||
echo " --days N 统计最近 N 天 (默认 7)"
|
||||
echo " --output PATH HTML 输出路径 (默认 ci_dashboard.html)"
|
||||
echo " --help 显示帮助"
|
||||
echo ""
|
||||
echo "环境变量:"
|
||||
echo " GITEA_TOKEN API Token(必需)"
|
||||
echo " GITEA_URL Gitea 地址"
|
||||
echo " GITEA_REPO 仓库"
|
||||
exit 0
|
||||
;;
|
||||
*)
|
||||
echo "未知参数: $1"
|
||||
exit 1
|
||||
;;
|
||||
esac
|
||||
done
|
||||
|
||||
# 检查 Python
|
||||
if ! command -v python3 &> /dev/null; then
|
||||
echo "[ERROR] 未找到 python3,请先安装 Python 3"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# 检查 Token
|
||||
if [[ -z "${GITEA_TOKEN:-}" ]]; then
|
||||
echo "[ERROR] 请设置 GITEA_TOKEN 环境变量"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
echo "========================================"
|
||||
echo " CI 健康度看板生成器"
|
||||
echo "========================================"
|
||||
echo ""
|
||||
echo "统计天数: ${DAYS} 天"
|
||||
echo "输出文件: ${OUTPUT}"
|
||||
echo ""
|
||||
|
||||
# 生成 HTML 看板
|
||||
echo "[INFO] 正在拉取数据并生成看板..."
|
||||
python3 "${SCRIPT_DIR}/ci_dashboard.py" \
|
||||
--days "${DAYS}" \
|
||||
--html \
|
||||
--html-output "${OUTPUT}"
|
||||
|
||||
echo ""
|
||||
echo "========================================"
|
||||
echo " ✅ 看板生成完成!"
|
||||
echo "========================================"
|
||||
echo ""
|
||||
echo "文件路径: $(realpath "${OUTPUT}")"
|
||||
echo ""
|
||||
|
||||
# 如果在 macOS 上,尝试打开
|
||||
if [[ "$(uname)" == "Darwin" ]]; then
|
||||
echo "[INFO] 正在打开浏览器..."
|
||||
open "${OUTPUT}"
|
||||
fi
|
||||
Reference in New Issue
Block a user