From 7ff87850d5e962a1e40c45d454f454be44c58acf Mon Sep 17 00:00:00 2001 From: xiaoxia Date: Wed, 22 Jul 2026 12:17:59 +0800 Subject: [PATCH 1/4] =?UTF-8?q?feat:=20CI=20=E5=81=A5=E5=BA=B7=E5=BA=A6?= =?UTF-8?q?=E5=8F=AF=E8=A7=86=E5=8C=96=E7=9C=8B=E6=9D=BF=20-=20=E6=96=B0?= =?UTF-8?q?=E5=A2=9E=20HTML=20=E8=BE=93=E5=87=BA=E6=A8=A1=E5=BC=8F?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 新增 --html / --html-output 参数 - 生成自包含 HTML 看板,内嵌 ECharts CDN - 5个图表:成功率趋势、Workflow耗时、失败原因分布、Job成功率排行、Run数量趋势 - 顶部核心指标卡片:总成功率、总Run数、平均耗时、基础设施故障率 - 保留原有 Markdown 输出模式,完全向后兼容 --- scripts/ci/ci_dashboard.py | 413 ++++++++++++++++++++++++++++++++++--- 1 file changed, 389 insertions(+), 24 deletions(-) diff --git a/scripts/ci/ci_dashboard.py b/scripts/ci/ci_dashboard.py index a8bcaac5c..09b85cc3b 100644 --- a/scripts/ci/ci_dashboard.py +++ b/scripts/ci/ci_dashboard.py @@ -1,18 +1,16 @@ #!/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) GITEA_TOKEN API Token (优先) 或 GITEA_USERNAME + GITEA_PASSWORD """ - import argparse import base64 import json @@ -31,7 +29,6 @@ DEFAULT_REPO = "xiaoxia/xiaoxia-saas" DEFAULT_DAYS = 7 PAGE_LIMIT = 50 # 每页数量,最大50 - # ── API 封装 ───────────────────────────────────────── class GiteaActions: def __init__(self, base_url, repo, token=None, username=None, password=None): @@ -177,18 +174,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 +191,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 +214,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 +229,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 +253,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 +300,19 @@ 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 +321,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 +378,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 +542,340 @@ 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('') + html_parts.append('') + html_parts.append('') + html_parts.append(' ') + html_parts.append(' ') + html_parts.append(f' CI 健康度看板 - {repo}') + html_parts.append(' ') + html_parts.append(' ') + html_parts.append('') + html_parts.append('') + html_parts.append('
') + html_parts.append('
') + html_parts.append('

📊 CI 健康度看板

') + html_parts.append(f'
仓库: {repo}
') + html_parts.append(f'
统计周期: {start_date} ~ {end_date} | 生成时间: {now_str}
') + html_parts.append('
') + html_parts.append('
') + html_parts.append('
') + html_parts.append('
总成功率
') + html_parts.append(f'
{success_rate}%
') + html_parts.append('
') + html_parts.append('
') + html_parts.append('
总 Run 数
') + html_parts.append(f'
{total_runs}
') + html_parts.append('
') + html_parts.append('
') + html_parts.append('
平均耗时
') + html_parts.append(f'
{avg_dur_min}分钟
') + html_parts.append('
') + html_parts.append('
') + html_parts.append('
基础设施故障率
') + html_parts.append(f'
{infra_fail_rate}%
') + html_parts.append('
') + html_parts.append('
') + html_parts.append('
') + html_parts.append('
') + html_parts.append('

📈 CI 成功率趋势

') + html_parts.append('
') + html_parts.append('
') + html_parts.append('
') + html_parts.append('
') + html_parts.append('
') + html_parts.append('

⏱️ 各 Workflow 平均耗时

') + html_parts.append('
') + html_parts.append('
') + html_parts.append('
') + html_parts.append('

❌ 失败原因分布

') + html_parts.append('
') + html_parts.append('
') + html_parts.append('
') + html_parts.append('
') + html_parts.append('
') + html_parts.append('

📋 各 Job 成功率排行(最低 15 名)

') + html_parts.append('
') + html_parts.append('
') + html_parts.append('
') + html_parts.append('
') + html_parts.append('
') + html_parts.append('

📊 每日 Run 数量趋势

') + html_parts.append('
') + html_parts.append('
') + html_parts.append('
') + html_parts.append(' ') + html_parts.append('
') + html_parts.append(' ') + html_parts.append('') + html_parts.append('') + + return "\n".join(html_parts) + + # ── 主函数 ─────────────────────────────────────────── def main(): parser = argparse.ArgumentParser(description="CI 可观测性看板 - 生成 Gitea Actions 运行状态报表") @@ -539,6 +889,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 +916,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) -- 2.54.0 From fd47b1b563e8f964d0b2627b2ac739e50c6ad4f1 Mon Sep 17 00:00:00 2001 From: xiaoxia Date: Wed, 22 Jul 2026 12:18:08 +0800 Subject: [PATCH 2/4] =?UTF-8?q?feat:=20=E6=96=B0=E5=A2=9E=20CI=20=E7=9C=8B?= =?UTF-8?q?=E6=9D=BF=E4=B8=80=E9=94=AE=E7=94=9F=E6=88=90=E8=84=9A=E6=9C=AC?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- scripts/ci/generate_ci_dashboard.sh | 97 +++++++++++++++++++++++++++++ 1 file changed, 97 insertions(+) create mode 100644 scripts/ci/generate_ci_dashboard.sh diff --git a/scripts/ci/generate_ci_dashboard.sh b/scripts/ci/generate_ci_dashboard.sh new file mode 100644 index 000000000..fcd7abf8f --- /dev/null +++ b/scripts/ci/generate_ci_dashboard.sh @@ -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 -- 2.54.0 From 66bdff866c554386cf649c1d06aab170c887bfff Mon Sep 17 00:00:00 2001 From: xiaoxia Date: Wed, 22 Jul 2026 12:18:18 +0800 Subject: [PATCH 3/4] =?UTF-8?q?feat:=20CI=20=E6=AF=8F=E6=97=A5=E5=81=A5?= =?UTF-8?q?=E5=BA=B7=E5=B7=A1=E6=A3=80=E5=A2=9E=E5=8A=A0=20HTML=20?= =?UTF-8?q?=E7=9C=8B=E6=9D=BF=E7=94=9F=E6=88=90=E6=AD=A5=E9=AA=A4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 在每日巡检中新增 Generate CI Dashboard HTML 步骤 - 自动生成最近 7 天的可视化看板 - 看板生成失败不影响主流程 --- .gitea/workflows/ci-health-daily.yml | 36 ++++++++++++++++++++++------ 1 file changed, 29 insertions(+), 7 deletions(-) diff --git a/.gitea/workflows/ci-health-daily.yml b/.gitea/workflows/ci-health-daily.yml index d60326f2c..26a02ab8a 100644 --- a/.gitea/workflows/ci-health-daily.yml +++ b/.gitea/workflows/ci-health-daily.yml @@ -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状态(通知失败不应该标红) -- 2.54.0 From 7206bcbe5074afa1b7241d49c01fee885f27610e Mon Sep 17 00:00:00 2001 From: xiaoxia Date: Wed, 22 Jul 2026 13:50:12 +0800 Subject: [PATCH 4/4] style: fix black/isort formatting --- scripts/ci/ci_dashboard.py | 401 ++++++++++++++++++++----------------- 1 file changed, 217 insertions(+), 184 deletions(-) diff --git a/scripts/ci/ci_dashboard.py b/scripts/ci/ci_dashboard.py index 09b85cc3b..3c7c1f6f8 100644 --- a/scripts/ci/ci_dashboard.py +++ b/scripts/ci/ci_dashboard.py @@ -11,6 +11,7 @@ CI 可观测性看板 - 从 Gitea Actions API 拉取数据并生成 Markdown/HTM GITEA_REPO 仓库 (默认 xiaoxia/xiaoxia-saas) GITEA_TOKEN API Token (优先) 或 GITEA_USERNAME + GITEA_PASSWORD """ + import argparse import base64 import json @@ -29,6 +30,7 @@ DEFAULT_REPO = "xiaoxia/xiaoxia-saas" DEFAULT_DAYS = 7 PAGE_LIMIT = 50 # 每页数量,最大50 + # ── API 封装 ───────────────────────────────────────── class GiteaActions: def __init__(self, base_url, repo, token=None, username=None, password=None): @@ -310,8 +312,20 @@ def analyze_runs(runs): # 基础设施关键词(与 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", + "unit test", + "pytest", + "vitest", + "jest", + "lint", + "eslint", + "prettier", + "integration", + "e2e", + "validate", + "code quality", + "mypy", + "ruff", + "flake8", ] for r in runs: @@ -581,12 +595,14 @@ def generate_html(stats, start_date, end_date, repo): 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.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] @@ -601,277 +617,294 @@ def generate_html(stats, start_date, end_date, repo): 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) + 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('') + html_parts.append("") html_parts.append('') - html_parts.append('') + html_parts.append("") html_parts.append(' ') html_parts.append(' ') - html_parts.append(f' CI 健康度看板 - {repo}') + html_parts.append(f" CI 健康度看板 - {repo}") html_parts.append(' ') - html_parts.append(' ') - html_parts.append('') - html_parts.append('') + 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(" ") + html_parts.append("") + html_parts.append("") html_parts.append('
') html_parts.append('
') - html_parts.append('

📊 CI 健康度看板

') + html_parts.append("

📊 CI 健康度看板

") html_parts.append(f'
仓库: {repo}
') html_parts.append(f'
统计周期: {start_date} ~ {end_date} | 生成时间: {now_str}
') - html_parts.append('
') + html_parts.append("
") html_parts.append('
') html_parts.append('
') html_parts.append('
总成功率
') html_parts.append(f'
{success_rate}%
') - html_parts.append('
') + html_parts.append("
") html_parts.append('
') html_parts.append('
总 Run 数
') html_parts.append(f'
{total_runs}
') - html_parts.append('
') + html_parts.append(" ") html_parts.append('
') html_parts.append('
平均耗时
') html_parts.append(f'
{avg_dur_min}分钟
') - html_parts.append('
') + html_parts.append(" ") html_parts.append('
') html_parts.append('
基础设施故障率
') html_parts.append(f'
{infra_fail_rate}%
') - html_parts.append('
') - html_parts.append(' ') + html_parts.append(" ") + html_parts.append(" ") html_parts.append('
') html_parts.append('
') - html_parts.append('

📈 CI 成功率趋势

') + html_parts.append("

📈 CI 成功率趋势

") html_parts.append('
') - html_parts.append('
') - html_parts.append('
') + html_parts.append(" ") + html_parts.append(" ") html_parts.append('
') html_parts.append('
') - html_parts.append('

⏱️ 各 Workflow 平均耗时

') + html_parts.append("

⏱️ 各 Workflow 平均耗时

") html_parts.append('
') - html_parts.append('
') + html_parts.append("
") html_parts.append('
') - html_parts.append('

❌ 失败原因分布

') + html_parts.append("

❌ 失败原因分布

") html_parts.append('
') - html_parts.append('
') - html_parts.append(' ') + html_parts.append(" ") + html_parts.append(" ") html_parts.append('
') html_parts.append('
') - html_parts.append('

📋 各 Job 成功率排行(最低 15 名)

') + html_parts.append("

📋 各 Job 成功率排行(最低 15 名)

") html_parts.append('
') - html_parts.append('
') - html_parts.append('
') + html_parts.append(" ") + html_parts.append(" ") html_parts.append('
') html_parts.append('
') - html_parts.append('

📊 每日 Run 数量趋势

') + html_parts.append("

📊 每日 Run 数量趋势

") html_parts.append('
') - html_parts.append('
') - html_parts.append('
') + html_parts.append(" ") + html_parts.append(" ") html_parts.append(' ') - html_parts.append(' ') - html_parts.append(' ') - html_parts.append('') - html_parts.append('') + html_parts.append(" })();") + html_parts.append(" ") + html_parts.append("") + html_parts.append("") return "\n".join(html_parts) -- 2.54.0