Jenkins Job History UI(jenkins的历史job)
1. 需求背景
需要 合并多个 Jenkins View 的 Job 信息到 一张总表
支持 下拉选择环境(View)进行过滤
支持 按列排序
由 Jenkins Job 定时每分钟生成并发布 Dashboard 页面
页面刷新后 保留用户上一次选择的环境和刷新间隔
访问稳定,不出现构建过程中 500 报错
2. 效果图

3. 实现方案
用 Jenkins Job 定时每分钟执行 Python 生成最新 dashboard HTML
通过 HTML Publisher 发布
浏览器访问时使用 /lastSuccessfulBuild/<report>/ 作为稳定入口,避免 500
UI 过滤/排序状态写入 URL query 保留
4. 核心实现路径
- Python 端
逐个 View 批量拉取 jobs(用 tree 一次获取 view 下所有 job 信息)
对每个 job 再请求一次 lastBuild 接口,解析参数 image_tag 作为构建分支
rows 统一存入一个 list,并给每条 row 加字段:
view(所属环境)
last_build_time_ms(用于排序)
jenkins_branch(image_tag 参数值)
生成一个总 HTML dashboard(单文件)嵌入 JSON 数据
- Jenkins Job
每分钟定时执行脚本,更新 dashboard.html
cp 覆盖写入 workspace 里的 HTML(或 report 目录)
构建成功后 Publisher 发布 HTML 报告
- Jenkins插件
HTML Publisher:发布 HTML 报告,job中会用到这个( Dashboard 生成后发布靠这个)
Sidebar Links:创建首页进入入口,如下截图所示
![image]()
5. 开始实施
5.1. 将下面的python文件放到git仓库
需要替换 JENKINS_URL、JENKINS_USER、JENKINS_TOKEN变量
cat jenkins_dashboard.py
#!/usr/bin/env python3
# -*- coding: utf-8 -*-
from __future__ import annotations
import os
import sys
import json
import logging
import datetime as dt
from dataclasses import dataclass
from typing import Any, Dict, List, Optional, Tuple
from urllib.parse import quote
from string import Template
import requests
# -------------------------
# logging
# -------------------------
def setup_logger(level: str = "INFO") -> logging.Logger:
logger = logging.getLogger("jenkins-dashboard")
logger.setLevel(getattr(logging, level.upper(), logging.INFO))
handler = logging.StreamHandler(sys.stdout)
fmt = logging.Formatter("[%(asctime)s] [%(levelname)s] %(message)s")
handler.setFormatter(fmt)
logger.handlers.clear()
logger.addHandler(handler)
return logger
# -------------------------
# config
# -------------------------
@dataclass(frozen=True)
class JenkinsConfig:
base_url: str
view: str
user: Optional[str]
token: Optional[str]
verify_tls: bool = True
timeout_sec: int = 15
# -------------------------
# client
# -------------------------
class JenkinsClient:
def __init__(self, cfg: JenkinsConfig, logger: logging.Logger):
self.cfg = cfg
self.log = logger
self.sess = requests.Session()
self.sess.headers.update({"Accept": "application/json"})
if cfg.user and cfg.token:
self.sess.auth = (cfg.user, cfg.token)
# 小缓存,避免同一个 job 重复打 lastBuild
self._last_build_cache: Dict[str, Dict[str, Any]] = {}
def _url(self, path: str) -> str:
return self.cfg.base_url.rstrip("/") + path
def get_json(self, path: str) -> Dict[str, Any]:
url = self._url(path)
self.log.debug(f"GET {url}")
r = self.sess.get(url, timeout=self.cfg.timeout_sec, verify=self.cfg.verify_tls)
if r.status_code == 403:
raise RuntimeError("403 Forbidden:可能缺少登录/Token 权限或 CSRF 设置限制。")
r.raise_for_status()
return r.json()
def fetch_view_jobs(self) -> List[Dict[str, Any]]:
"""
一次性拉取 view 下 jobs 的核心信息(避免每个 job 单独请求)。
"""
view = quote(self.cfg.view)
tree = (
"jobs[name,url,color,"
"lastBuild[number,url,timestamp,duration,result,building,"
"actions[causes[shortDescription,userId,userName,upstreamProject,upstreamBuild],"
"lastBuiltRevision[branch[name]]"
"]"
"],"
"lastSuccessfulBuild[number,url,timestamp],"
"lastFailedBuild[number,url,timestamp]"
"]"
)
path = f"/view/{view}/api/json?tree={tree}"
data = self.get_json(path)
return data.get("jobs", [])
def fetch_last_build_json(self, job_name: str) -> Dict[str, Any]:
"""
获取 /job/<job>/lastBuild/api/json,带缓存。
"""
if not job_name:
return {}
if job_name in self._last_build_cache:
return self._last_build_cache[job_name]
path = f"/job/{quote(job_name)}/lastBuild/api/json"
try:
data = self.get_json(path)
except Exception as e:
self.log.warning(f"fetch lastBuild api failed job={job_name} path={path}: {e}")
data = {}
self._last_build_cache[job_name] = data
return data
# -------------------------
# transform
# -------------------------
def fmt_ts_ms(ts_ms: Optional[int], tz: dt.tzinfo) -> str:
if not ts_ms:
return "-"
d = dt.datetime.fromtimestamp(ts_ms / 1000.0, tz=tz)
return d.strftime("%Y-%m-%d %H:%M:%S")
def fmt_duration_ms(ms: Optional[int]) -> str:
if ms is None:
return "-"
sec = int(ms // 1000)
m, s = divmod(sec, 60)
h, m = divmod(m, 60)
if h > 0:
return f"{h}h {m}m {s}s"
if m > 0:
return f"{m}m {s}s"
return f"{s}s"
def normalize_color(color: str) -> str:
return (color or "").replace("_anime", "")
def status_text(job: Dict[str, Any]) -> str:
c = normalize_color(job.get("color", ""))
mapping = {
"blue": "SUCCESS",
"red": "FAIL",
"yellow": "UNSTABLE",
"aborted": "ABORTED",
"disabled": "DISABLED",
"notbuilt": "NOT_BUILT",
}
return mapping.get(c, c.upper() or "-")
def safe_get(d: Dict[str, Any], path: List[str]) -> Optional[Any]:
cur: Any = d
for k in path:
if not isinstance(cur, dict) or k not in cur:
return None
cur = cur[k]
return cur
def extract_trigger(last_build: Dict[str, Any]) -> Tuple[str, str]:
"""
返回 (trigger_user, trigger_desc)
"""
trigger_user = "-"
trigger_desc = "-"
actions = last_build.get("actions") or []
for act in actions:
causes = act.get("causes") or []
for c in causes:
if c.get("userName"):
trigger_user = c.get("userName") or c.get("userId") or "-"
trigger_desc = c.get("shortDescription") or "Started by user"
return trigger_user, trigger_desc
if c.get("upstreamProject"):
trigger_user = "upstream"
up = c.get("upstreamProject")
ub = c.get("upstreamBuild")
trigger_desc = f"Upstream: {up} #{ub}" if ub is not None else f"Upstream: {up}"
sd = c.get("shortDescription")
if sd and trigger_desc == "-":
trigger_desc = sd
return trigger_user, trigger_desc
def parse_image_tag_from_last_build(last_build_json: Dict[str, Any]) -> str:
"""
从 /lastBuild/api/json 的 ParametersAction 里取 name=image_tag 的 value
"""
actions = last_build_json.get("actions") or []
for a in actions:
if a.get("_class") == "hudson.model.ParametersAction":
for p in a.get("parameters") or []:
if p.get("name") == "image_tag":
val = p.get("value")
if isinstance(val, str) and val.strip():
return val.strip()
return "-"
def fetch_last_build_branch(job_name: str, client: JenkinsClient) -> str:
"""
拼接接口并请求 /job/<job>/lastBuild/api/json,然后解析 image_tag
"""
if not job_name:
return "-"
last_build_json = client.fetch_last_build_json(job_name)
if not last_build_json:
return "-"
v = parse_image_tag_from_last_build(last_build_json)
if v != "-":
return v
return "-"
def sort_rows(rows: List[Dict[str, Any]], by: str = "time") -> List[Dict[str, Any]]:
"""
Python 侧默认排序(只是默认展示顺序;UI 仍可点击排序)
"""
if by == "time":
return sorted(rows, key=lambda r: r.get("last_build_time_ms", 0), reverse=True)
if by == "status":
priority = {
"BUILDING": 6,
"FAIL": 5,
"UNSTABLE": 4,
"SUCCESS": 3,
"DISABLED": 2,
"NOT_BUILT": 1,
"ABORTED": 1,
}
return sorted(rows, key=lambda r: priority.get(r.get("status", ""), 0), reverse=True)
if by == "duration":
return sorted(rows, key=lambda r: int(r.get("last_build_duration_sec", 0)), reverse=True)
return rows
def build_rows(
jobs: List[Dict[str, Any]],
tz: dt.tzinfo,
client: JenkinsClient,
view_name: str,
) -> List[Dict[str, Any]]:
rows: List[Dict[str, Any]] = []
for j in jobs:
last = j.get("lastBuild") or {}
trigger_user, trigger_desc = extract_trigger(last)
job_name = j.get("name", "") or ""
branch = fetch_last_build_branch(job_name, client)
ts_ms = last.get("timestamp") if isinstance(last.get("timestamp"), int) else 0
duration_ms = last.get("duration") if isinstance(last.get("duration"), int) else 0
last_result = last.get("result") or ("BUILDING" if last.get("building") else "-")
row = {
"view": view_name, # 所属 view
"name": job_name,
"url": j.get("url", ""),
"status": status_text(j),
"last_build_no": last.get("number") if last.get("number") is not None else "-",
"last_build_url": last.get("url") or "",
"last_build_time": fmt_ts_ms(ts_ms, tz),
"last_build_time_ms": int(ts_ms or 0),
"last_build_result": last_result,
"last_build_duration": fmt_duration_ms(duration_ms),
"last_build_duration_sec": int((duration_ms or 0) // 1000),
"last_build_user": trigger_user,
"last_build_cause": trigger_desc,
"jenkins_branch": branch,
"last_success_time": fmt_ts_ms(safe_get(j, ["lastSuccessfulBuild", "timestamp"]), tz),
"last_fail_time": fmt_ts_ms(safe_get(j, ["lastFailedBuild", "timestamp"]), tz),
}
rows.append(row)
return rows
# -------------------------
# render (JSON-driven)
# -------------------------
COLUMNS: List[Dict[str, Any]] = [
{"key": "index", "title": "#", "sortable": True, "sort": "index"},
{"key": "name", "title": "Job", "sortable": True, "sort": "name"},
{"key": "view", "title": "环境", "sortable": True, "sort": "view"},
{"key": "jenkins_branch", "title": "构建分支(image_tag)", "sortable": True, "sort": "branch"},
{"key": "last_build_user", "title": "构建人员", "sortable": True, "sort": "user"},
{"key": "status", "title": "状态", "sortable": True, "sort": "status"},
{"key": "last_build_time", "title": "构建时间", "sortable": True, "sort": "time"},
{"key": "last_build_duration", "title": "耗时", "sortable": True, "sort": "duration"},
{"key": "last_build_cause", "title": "触发方式", "sortable": False},
{"key": "last_success_time", "title": "上次成功", "sortable": True, "sort": "success"},
{"key": "last_fail_time", "title": "上次失败", "sortable": True, "sort": "fail"},
]
# ✅ 关键修复:Template 遇到 JS 正则里的 `$` 会炸
# 用自定义 delimiter 避免与 JS/CSS 冲突
class HTMLTemplate(Template):
delimiter = "@"
HTML_TEMPLATE = HTMLTemplate(
r"""<!doctype html>
<html lang="zh-CN">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width,initial-scale=1" />
<title>Jenkins Dashboard</title>
<style>
body { font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "PingFang SC", "Microsoft YaHei", Arial, sans-serif; margin: 20px; }
h1 { margin: 0 0 12px 0; font-size: 20px; }
.meta { color: #666; margin-bottom: 12px; display:flex; gap:12px; align-items:center; flex-wrap: wrap; }
.pill { display:inline-block; padding:2px 8px; border:1px solid #eee; border-radius: 999px; font-size:12px; color:#555; background:#fff; }
.controls { display:flex; gap:10px; align-items:center; flex-wrap: wrap; margin-bottom: 12px; }
select { padding:8px 10px; border:1px solid #ddd; border-radius: 8px; background:#fff; }
button { padding:8px 10px; border:1px solid #ddd; border-radius: 8px; background:#fff; cursor:pointer; }
button:hover { background:#fafafa; }
table { border-collapse: collapse; width: 100%; }
th, td { border-bottom: 1px solid #eee; padding: 10px 8px; text-align: left; white-space: nowrap; }
th { background: #fafafa; position: sticky; top: 0; z-index: 1; }
tr:hover td { background: #fcfcfc; }
.status { font-weight: 600; }
.SUCCESS { color: #1a7f37; }
.FAIL { color: #cf222e; }
.UNSTABLE { color: #9a6700; }
.DISABLED { color: #6e7781; }
.BUILDING { color: #0969da; }
.name a { text-decoration: none; }
.name a:hover { text-decoration: underline; }
.muted { color:#666; }
.sortable { cursor: pointer; user-select: none; }
</style>
</head>
<body>
<h1>Jenkins Dashboard — <span class="pill" id="titleView">ALL</span></h1>
<div class="meta">
<span>Generated at: @generated_at</span>
<span>总 Jobs: <b id="countAll">0</b></span>
<span>当前展示: <b id="countShown">0</b></span>
</div>
<div class="controls">
<label>选择 环境:</label>
<select id="viewSelect"></select>
<button id="btnAll">显示全部</button>
<label style="margin-left:18px;">自动刷新:</label>
<select id="refreshSelect">
<option value="0">关闭</option>
<option value="10">10s</option>
<option value="30">30s</option>
<option value="60" selected>60s</option>
<option value="120">120s</option>
</select>
<span class="muted" id="refreshTip"></span>
</div>
<table id="tbl">
<thead><tr id="hdr"></tr></thead>
<tbody id="body"></tbody>
</table>
<script id="data" type="application/json">@embedded_json</script>
<script>
(function () {
var payload = JSON.parse(document.getElementById('data').textContent);
var columns = payload.columns;
var allRows = payload.rows.slice(); // 原始全量
var rows = payload.rows.slice(); // 当前过滤后的数据
var hdr = document.getElementById('hdr');
var tbody = document.getElementById('body');
var viewSelect = document.getElementById('viewSelect');
var titleView = document.getElementById('titleView');
var countAll = document.getElementById('countAll');
var countShown = document.getElementById('countShown');
var btnAll = document.getElementById('btnAll');
// 自动刷新控件
var refreshSelect = document.getElementById('refreshSelect');
var refreshTip = document.getElementById('refreshTip');
var refreshTimer = null;
// -------------------------
// 用 URL query 保存/恢复状态
// -------------------------
function getQueryParam(key, defVal) {
try {
var u = new URL(window.location.href);
var v = u.searchParams.get(key);
return (v === null || v === undefined || v === "") ? defVal : v;
} catch (e) {
return defVal;
}
}
function setQueryParam(key, val) {
try {
var u = new URL(window.location.href);
if (val === null || val === undefined || val === "") u.searchParams.delete(key);
else u.searchParams.set(key, String(val));
// _t 只在刷新时写;这里用 replaceState 保存选择
window.history.replaceState({}, "", u.toString());
} catch (e) {}
}
// -------------------------
// 关键新增:把“固定 build 的 report URL”改成 lastSuccessfulBuild
// -------------------------
function toLastSuccessfulBuildUrl() {
// 例:
// /job/jenkins-dashboard-ui/136/BBBB/ -> /job/jenkins-dashboard-ui/lastSuccessfulBuild/BBBB/
// /job/jenkins-dashboard-ui/lastSuccessfulBuild/BBBB/ -> 原样
try {
var u = new URL(window.location.href);
// 已经是 lastSuccessfulBuild/lastBuild 就不改
if (u.pathname.indexOf("/lastSuccessfulBuild/") >= 0 || u.pathname.indexOf("/lastBuild/") >= 0) {
return u;
}
var p = u.pathname;
// 匹配:/job/<job>/<buildNumber>/<reportName>/
var m = p.match(/^(.*\/job\/[^\/]+)\/(\d+)\/([^\/]+)\/?$/);
if (m) {
var jobPrefix = m[1];
var reportName = m[3];
u.pathname = jobPrefix + "/lastSuccessfulBuild/" + reportName + "/";
return u;
}
// 匹配:/job/<job>/<buildNumber>/<reportName>/xxx...
var m2 = p.match(/^(.*\/job\/[^\/]+)\/(\d+)\/([^\/]+)\/(.*)$/);
if (m2) {
var jobPrefix2 = m2[1];
var reportName2 = m2[3];
var rest = m2[4];
u.pathname = jobPrefix2 + "/lastSuccessfulBuild/" + reportName2 + "/" + rest;
return u;
}
return u;
} catch (e) {
return null;
}
}
function esc(s) {
s = (s === null || s === undefined) ? "" : String(s);
return s.replace(/[&<>"']/g, function (c) {
return ({ "&":"&", "<":"<", ">":">", "\"":""", "'":"'" })[c];
});
}
function statusRank(text) {
var s = (text || "").toUpperCase();
var rank = { "BUILDING": 6, "FAIL": 5, "UNSTABLE": 4, "SUCCESS": 3, "DISABLED": 2, "NOT_BUILT": 1, "ABORTED": 1 };
return rank[s] || 0;
}
function parseTime(text) {
if (!text || text === "-") return 0;
var s = String(text);
var iso = (s.indexOf("T") >= 0) ? s : s.replace(" ", "T");
var ms = Date.parse(iso);
return isFinite(ms) ? ms : 0;
}
function parseDurationSec(text) {
if (!text || text === "-") return 0;
var s = String(text);
var total = 0;
var parts = s.split(/\s+/);
for (var i = 0; i < parts.length; i++) {
var p = parts[i];
if (p.endsWith("h")) total += parseInt(p, 10) * 3600;
else if (p.endsWith("m")) total += parseInt(p, 10) * 60;
else if (p.endsWith("s")) total += parseInt(p, 10);
}
return isFinite(total) ? total : 0;
}
function renderHeader() {
hdr.innerHTML = "";
for (var i = 0; i < columns.length; i++) {
var col = columns[i];
var th = document.createElement("th");
th.textContent = col.title;
if (col.sortable) {
th.className = "sortable";
(function (sortKey) {
th.onclick = function () { sortTable(sortKey); };
})(col.sort || col.key);
}
hdr.appendChild(th);
}
}
function renderBody() {
tbody.innerHTML = "";
for (var i = 0; i < rows.length; i++) rows[i].index = i + 1;
for (var i = 0; i < rows.length; i++) {
var r = rows[i];
var tr = document.createElement("tr");
for (var c = 0; c < columns.length; c++) {
var col = columns[c];
var td = document.createElement("td");
if (col.key === "name") {
td.className = "name";
var a = document.createElement("a");
a.href = r.url || "#";
a.target = "_blank";
a.rel = "noreferrer";
a.textContent = r.name || "-";
td.appendChild(a);
} else if (col.key === "status") {
td.className = "status " + esc(r.status || "");
td.textContent = r.status || "-";
} else {
var v = r[col.key];
td.textContent = (v === null || v === undefined || v === "") ? "-" : String(v);
}
tr.appendChild(td);
}
tbody.appendChild(tr);
}
countShown.textContent = String(rows.length);
}
// 排序(补齐 view/name/user/branch)
var sortState = { key: null, asc: true };
function sortTable(key) {
if (sortState.key === key) sortState.asc = !sortState.asc;
else { sortState.key = key; sortState.asc = true; }
var asc = sortState.asc ? 1 : -1;
rows.sort(function (a, b) {
var va, vb;
if (key === "index") { va = a.index || 0; vb = b.index || 0; }
else if (key === "view") { va = String(a.view || ""); vb = String(b.view || ""); return va.localeCompare(vb) * asc; }
else if (key === "name") { va = String(a.name || ""); vb = String(b.name || ""); return va.localeCompare(vb) * asc; }
else if (key === "user") { va = String(a.last_build_user || ""); vb = String(b.last_build_user || ""); return va.localeCompare(vb) * asc; }
else if (key === "branch") { va = String(a.jenkins_branch || ""); vb = String(b.jenkins_branch || ""); return va.localeCompare(vb) * asc; }
else if (key === "status") { va = statusRank(a.status); vb = statusRank(b.status); }
else if (key === "time") { va = a.last_build_time_ms || parseTime(a.last_build_time); vb = b.last_build_time_ms || parseTime(b.last_build_time); }
else if (key === "duration") { va = a.last_build_duration_sec || parseDurationSec(a.last_build_duration); vb = b.last_build_duration_sec || parseDurationSec(b.last_build_duration); }
else if (key === "success") { va = parseTime(a.last_success_time); vb = parseTime(b.last_success_time); }
else if (key === "fail") { va = parseTime(a.last_fail_time); vb = parseTime(b.last_fail_time); }
else { va = String(a[key] || ""); vb = String(b[key] || ""); return va.localeCompare(vb) * asc; }
if (va === vb) return 0;
return (va > vb ? 1 : -1) * asc;
});
renderBody();
}
// View 下拉
function buildViewSelect() {
var set = {};
for (var i = 0; i < allRows.length; i++) {
var v = allRows[i].view || "UNKNOWN";
set[v] = true;
}
var views = Object.keys(set).sort();
viewSelect.innerHTML = "";
var optAll = document.createElement("option");
optAll.value = "__ALL__";
optAll.textContent = "ALL (所有)";
viewSelect.appendChild(optAll);
for (var i = 0; i < views.length; i++) {
var opt = document.createElement("option");
opt.value = views[i];
opt.textContent = views[i];
viewSelect.appendChild(opt);
}
}
function applyFilter(viewVal) {
if (!viewVal || viewVal === "__ALL__") {
rows = allRows.slice();
titleView.textContent = "ALL";
} else {
rows = allRows.filter(function (r) { return (r.view || "") === viewVal; });
titleView.textContent = viewVal;
}
// 过滤后默认按时间最新
rows.sort(function(a, b){ return (b.last_build_time_ms||0) - (a.last_build_time_ms||0); });
renderBody();
}
viewSelect.addEventListener("change", function () {
setQueryParam("view", viewSelect.value);
applyFilter(viewSelect.value);
});
btnAll.addEventListener("click", function () {
viewSelect.value = "__ALL__";
setQueryParam("view", "__ALL__");
applyFilter("__ALL__");
});
// --- 自动刷新 ---
function startRefresh(sec) {
if (refreshTimer) { clearInterval(refreshTimer); refreshTimer = null; }
if (!sec || sec <= 0) {
refreshTip.textContent = "";
return;
}
refreshTip.textContent = "(数据每分钟更新一次 | 该页面默认 " + sec + "秒 自动刷新一次)";
refreshTimer = setInterval(function () {
// 每次刷新都先确保跳到 lastSuccessfulBuild 的 URL
var u = toLastSuccessfulBuildUrl();
if (!u) { window.location.reload(true); return; }
// 保留 view/refresh 等参数,只更新 _t
u.searchParams.set("view", getQueryParam("view", "__ALL__"));
u.searchParams.set("refresh", getQueryParam("refresh", String(sec)));
u.searchParams.set("_t", Date.now().toString());
window.location.replace(u.toString());
}, sec * 1000);
}
if (refreshSelect) {
refreshSelect.addEventListener("change", function () {
var sec = parseInt(refreshSelect.value, 10) || 0;
setQueryParam("refresh", sec);
startRefresh(sec);
});
// 初始化刷新:优先 URL,其次控件默认
var initRefresh = parseInt(getQueryParam("refresh", ""), 10);
if (!isFinite(initRefresh)) initRefresh = parseInt(refreshSelect.value, 10) || 60;
refreshSelect.value = String(initRefresh);
startRefresh(initRefresh);
}
// 初始化
countAll.textContent = String(allRows.length);
buildViewSelect();
renderHeader();
// 初始化 view:优先 URL
var initView = getQueryParam("view", "__ALL__");
if (!initView) initView = "__ALL__";
// 校验 view 是否存在
if (initView !== "__ALL__") {
var ok = false;
for (var i = 0; i < viewSelect.options.length; i++) {
if (viewSelect.options[i].value === initView) { ok = true; break; }
}
if (!ok) initView = "__ALL__";
}
viewSelect.value = initView;
applyFilter(initView);
// 首次加载:如果当前是 /job/<job>/<build>/report/ 这种固定快照,就纠正到 lastSuccessfulBuild
(function redirectToLatestSuccessIfNeeded() {
try {
var cur = new URL(window.location.href);
if (cur.pathname.indexOf("/lastSuccessfulBuild/") >= 0) return;
var u = toLastSuccessfulBuildUrl();
if (!u) return;
// 如果转换后还是没变化,说明匹配失败,不跳
if (u.pathname === cur.pathname) return;
// 保留状态
u.searchParams.set("view", getQueryParam("view", "__ALL__"));
u.searchParams.set("refresh", getQueryParam("refresh", refreshSelect ? refreshSelect.value : "60"));
// 防循环
if (u.searchParams.get("_r") === "1") return;
u.searchParams.set("_r", "1");
window.location.replace(u.toString());
} catch (e) {}
})();
})();
</script>
</body>
</html>
"""
)
def render_dashboard_html(generated_at: str, rows: List[Dict[str, Any]]) -> str:
payload = {
"generated_at": generated_at,
"columns": COLUMNS,
"rows": rows,
}
# 防止 </script> 截断
embedded_json = json.dumps(payload, ensure_ascii=False).replace("</", "<\\/")
return HTML_TEMPLATE.substitute(
generated_at=generated_at,
embedded_json=embedded_json,
)
# -------------------------
# main
# -------------------------
def main():
logger = setup_logger(os.getenv("LOG_LEVEL", "INFO"))
base_url = os.getenv("JENKINS_URL", "https://jenkins.url").strip()
# 支持多个 view:逗号分隔
views_env = os.getenv("JENKINS_VIEWS", "🚀dev,🚀test,🚀pre,🚀prod").strip()
if views_env:
views = [v.strip() for v in views_env.split(",") if v.strip()]
else:
views = [os.getenv("JENKINS_VIEW", "🚀pre").strip()]
user = os.getenv("JENKINS_USER", "user").strip() or None
token = os.getenv("JENKINS_TOKEN", "JENKINS_TOKEN").strip() or None
if not base_url:
logger.error("请设置环境变量 JENKINS_URL,例如 https://jenkins.url")
sys.exit(1)
tz = dt.timezone(dt.timedelta(hours=8)) # +0800
all_rows: List[Dict[str, Any]] = []
# 逐个 view 拉取,并合并成“总表”
for view in views:
cfg = JenkinsConfig(
base_url=base_url,
view=view,
user=user,
token=token,
verify_tls=(os.getenv("JENKINS_TLS_VERIFY", "true").lower() == "true"),
timeout_sec=int(os.getenv("JENKINS_TIMEOUT", "15")),
)
logger.info(f"Fetching Jenkins view jobs... url={cfg.base_url} view={cfg.view}")
client = JenkinsClient(cfg, logger)
jobs = client.fetch_view_jobs()
logger.info(f"Fetched jobs: {len(jobs)} view={cfg.view}")
rows = build_rows(jobs, tz, client, view_name=view)
rows = sort_rows(rows, "time")
all_rows.extend(rows)
# 总表默认再按时间排序一次
all_rows = sort_rows(all_rows, "time")
html = render_dashboard_html(
generated_at=dt.datetime.now(tz).strftime("%Y-%m-%d %H:%M:%S"),
rows=all_rows,
)
out = os.getenv("OUT_HTML", "dashboard.html")
with open(out, "w", encoding="utf-8") as f:
f.write(html)
logger.info(f"OK: wrote {out} (views={len(views)} total_jobs={len(all_rows)})")
if __name__ == "__main__":
main()
5.2 创建freestyle类型的job
-
1 git配置
![image]()
-
2 定时任务
![image]()
-
3 执行python脚本并生成html文件
![image]()
python3 jenkins-dashboard/jenkins_dashboard.py
mkdir -p /home/jms/jenkins-dashboard/
mv dashboard.html index.html
cp -a index.html /home/jms/jenkins-dashboard/
- 4 构建后步骤增加 Publish HTML reports
![image]()

5.3 构建一下看下效果

- Jenkins Dashboard页面

5.4 在首页添加自动跳转
最终的效果是job 定时运行,然后我们在首页就能跳转到 Jenkins Dashboard
- 安装插件
Sidebar Links - 配置Sidebar Links
路径:Jenkins manage 》 系统配置 》Sidebar Links 》Add Link,配置如下,配置完之后点保存
![image]()
- 在首页进行访问
- 首页入口
![image]()
- 效果图
![image]()









浙公网安备 33010602011771号