Files
Git-Identity-Audit/internal/report/template.html
T
2026-07-13 05:45:48 +07:00

324 lines
12 KiB
HTML

<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8">
<title>Git Identity Audit Report</title>
<style>
:root { color-scheme: light dark; }
body { font-family: -apple-system, Segoe UI, Roboto, sans-serif; margin: 0; padding: 2rem; max-width: 1100px; margin: 0 auto; }
h1 { font-size: 1.5rem; margin-bottom: 0.15rem; }
h2 { font-size: 1.1rem; margin: 2rem 0 0.75rem; }
.meta { color: #888; margin-bottom: 1.5rem; font-size: 0.9rem; }
.cards { display: grid; grid-template-columns: repeat(auto-fit, minmax(160px, 1fr)); gap: 0.75rem; margin-bottom: 1.5rem; }
.card { border: 1px solid rgba(128,128,128,0.3); border-radius: 8px; padding: 0.75rem 1rem; }
.card .value { font-size: 1.6rem; font-weight: 600; }
.card .label { color: #888; font-size: 0.8rem; text-transform: uppercase; letter-spacing: 0.03em; }
.charts { display: grid; grid-template-columns: 1fr 1fr; gap: 2rem; margin-bottom: 1rem; }
@media (max-width: 800px) { .charts { grid-template-columns: 1fr; } }
.chart-row { display: flex; align-items: center; gap: 0.5rem; margin-bottom: 0.35rem; font-size: 0.82rem; }
.chart-label { width: 40%; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; text-align: right; }
.chart-bar-track { flex: 1; background: rgba(128,128,128,0.15); border-radius: 3px; height: 14px; position: relative; }
.chart-bar-fill { background: #4a90d9; height: 100%; border-radius: 3px; }
.chart-count { width: 3rem; color: #888; }
.controls { display: flex; gap: 0.75rem; margin: 1.5rem 0 1rem; align-items: center; }
#search { flex: 1; padding: 0.5rem; font-size: 1rem; }
button { padding: 0.5rem 1rem; font-size: 0.9rem; cursor: pointer; }
.identity { border: 1px solid rgba(128,128,128,0.3); border-radius: 8px; margin-bottom: 0.6rem; }
.identity summary { cursor: pointer; padding: 0.6rem 0.9rem; list-style: none; display: flex; justify-content: space-between; align-items: baseline; gap: 1rem; }
.identity summary::-webkit-details-marker { display: none; }
.identity summary .who { font-weight: 600; }
.identity summary .email { color: #888; font-weight: 400; margin-left: 0.4rem; }
.identity summary .stats { color: #888; font-size: 0.85rem; white-space: nowrap; }
.badge-pushed-by { display: inline-block; margin-left: 0.4rem; padding: 0.05rem 0.4rem; border-radius: 4px; font-size: 0.72rem; font-weight: 600; background: rgba(63,185,80,0.15); color: #3fb950; }
.events { border: 1px solid rgba(128,128,128,0.3); border-radius: 8px; }
.events table { width: 100%; border-collapse: collapse; }
.events th, .events td { text-align: left; padding: 0.4rem 0.9rem; font-size: 0.85rem; border-top: 1px solid rgba(128,128,128,0.15); }
.events th { color: #888; font-weight: 500; }
.events .empty { padding: 0.9rem; color: #888; font-size: 0.85rem; }
.identity table { width: 100%; border-collapse: collapse; margin: 0 0 0.6rem; }
.identity th, .identity td { text-align: left; padding: 0.3rem 0.9rem; font-size: 0.85rem; border-top: 1px solid rgba(128,128,128,0.15); }
.identity th { color: #888; font-weight: 500; cursor: pointer; user-select: none; }
#count { color: #888; font-size: 0.85rem; margin-top: 0.5rem; }
.hidden { display: none !important; }
</style>
</head>
<body>
<h1>Git Identity Audit Report</h1>
<div class="meta">Generated {{.GeneratedAt}}</div>
<div class="cards">
<div class="card"><div class="value" id="stat-entries">0</div><div class="label">Identity entries</div></div>
<div class="card"><div class="value" id="stat-identities">0</div><div class="label">Unique identities</div></div>
<div class="card"><div class="value" id="stat-repos">0</div><div class="label">Repos scanned</div></div>
<div class="card"><div class="value" id="stat-range">-</div><div class="label">Date range</div></div>
</div>
<div class="charts">
<div>
<h2>Top identities by commits</h2>
<div id="chart-identities"></div>
</div>
<div>
<h2>Top repos by commits</h2>
<div id="chart-repos"></div>
</div>
</div>
<h2>Identities</h2>
<div class="controls">
<input id="search" type="text" placeholder="Filter by name, email, repo, field...">
<button id="toggle-all">Expand all</button>
<button id="export">Export CSV</button>
</div>
<div id="groups"></div>
<div id="count"></div>
<h2>GitHub Activity</h2>
<div class="meta">Recent public events for the token owner, used to cross-reference which GitHub login pushed each commit.</div>
<div class="events"><div id="events-body"></div></div>
<script>
var DATA = {{.DataJSON}};
var EVENTS = {{.EventsJSON}} || [];
var COLS = ["Field", "Repo", "CommitSHA", "Date", "PushedByLogin"];
function groupData(data) {
var map = {};
data.forEach(function(e) {
var key = e.Name + "" + e.Email;
if (!map[key]) {
map[key] = { name: e.Name, email: e.Email, count: 0, repos: {}, commits: [] };
}
var g = map[key];
g.count++;
g.repos[e.Repo] = (g.repos[e.Repo] || 0) + 1;
g.commits.push(e);
});
var groups = Object.keys(map).map(function(k) { return map[k]; });
groups.sort(function(a, b) { return b.count - a.count; });
return groups;
}
function renderBarChart(container, items, max) {
container.innerHTML = "";
items.forEach(function(item) {
var row = document.createElement("div");
row.className = "chart-row";
var label = document.createElement("div");
label.className = "chart-label";
label.textContent = item.label;
label.title = item.label;
var track = document.createElement("div");
track.className = "chart-bar-track";
var fill = document.createElement("div");
fill.className = "chart-bar-fill";
fill.style.width = (max > 0 ? (item.count / max * 100) : 0) + "%";
track.appendChild(fill);
var count = document.createElement("div");
count.className = "chart-count";
count.textContent = item.count;
row.appendChild(label);
row.appendChild(track);
row.appendChild(count);
container.appendChild(row);
});
}
function renderCharts(groups, data) {
var topIdentities = groups.slice(0, 8).map(function(g) {
return { label: g.name + " <" + g.email + ">", count: g.count };
});
var maxIdentity = topIdentities.length ? topIdentities[0].count : 0;
renderBarChart(document.getElementById("chart-identities"), topIdentities, maxIdentity);
var repoCounts = {};
data.forEach(function(e) { repoCounts[e.Repo] = (repoCounts[e.Repo] || 0) + 1; });
var topRepos = Object.keys(repoCounts).map(function(r) { return { label: r, count: repoCounts[r] }; });
topRepos.sort(function(a, b) { return b.count - a.count; });
topRepos = topRepos.slice(0, 8);
var maxRepo = topRepos.length ? topRepos[0].count : 0;
renderBarChart(document.getElementById("chart-repos"), topRepos, maxRepo);
}
function renderStats(data, groups) {
document.getElementById("stat-entries").textContent = data.length;
document.getElementById("stat-identities").textContent = groups.length;
var repos = {};
var dates = [];
data.forEach(function(e) {
repos[e.Repo] = true;
if (e.Date) dates.push(e.Date);
});
document.getElementById("stat-repos").textContent = Object.keys(repos).length;
if (dates.length) {
dates.sort();
var earliest = dates[0].slice(0, 10);
var latest = dates[dates.length - 1].slice(0, 10);
document.getElementById("stat-range").textContent = earliest + " to " + latest;
}
}
function csvEscape(v) {
v = String(v == null ? "" : v);
if (/[",\n]/.test(v)) v = '"' + v.replace(/"/g, '""') + '"';
return v;
}
var expandAll = false;
var filterText = "";
var commitSortKey = "Date";
var commitSortDir = -1;
function matchesFilter(g) {
if (!filterText) return true;
var hay = (g.name + " " + g.email).toLowerCase();
if (hay.indexOf(filterText) !== -1) return true;
return g.commits.some(function(c) {
return (c.Repo + " " + c.Field).toLowerCase().indexOf(filterText) !== -1;
});
}
function renderGroups(groups) {
var container = document.getElementById("groups");
container.innerHTML = "";
var shown = 0;
groups.filter(matchesFilter).forEach(function(g) {
shown++;
var repoNames = Object.keys(g.repos).sort();
var details = document.createElement("details");
details.className = "identity";
if (expandAll) details.open = true;
var summary = document.createElement("summary");
summary.innerHTML =
'<span><span class="who">' + escapeHTML(g.name) + '</span><span class="email">&lt;' + escapeHTML(g.email) + '&gt;</span></span>' +
'<span class="stats">' + g.count + ' commits &middot; ' + repoNames.length + ' repo(s)</span>';
details.appendChild(summary);
var table = document.createElement("table");
var thead = document.createElement("thead");
var headRow = document.createElement("tr");
COLS.forEach(function(col) {
var th = document.createElement("th");
th.textContent = col === "PushedByLogin" ? "Pushed by" : col;
th.addEventListener("click", function() {
if (commitSortKey === col) commitSortDir *= -1; else { commitSortKey = col; commitSortDir = 1; }
renderGroups(groups);
});
headRow.appendChild(th);
});
thead.appendChild(headRow);
table.appendChild(thead);
var tbody = document.createElement("tbody");
var commits = g.commits.slice().sort(function(a, b) {
var av = a[commitSortKey] || "", bv = b[commitSortKey] || "";
if (av < bv) return -1 * commitSortDir;
if (av > bv) return 1 * commitSortDir;
return 0;
});
commits.forEach(function(c) {
var tr = document.createElement("tr");
COLS.forEach(function(col) {
var td = document.createElement("td");
if (col === "PushedByLogin") {
td.innerHTML = c[col] ? '<span class="badge-pushed-by">⇪ ' + escapeHTML(c[col]) + '</span>' : "";
} else {
td.textContent = c[col];
}
tr.appendChild(td);
});
tbody.appendChild(tr);
});
table.appendChild(tbody);
details.appendChild(table);
container.appendChild(details);
});
document.getElementById("count").textContent = shown + " of " + groups.length + " identities shown";
}
function renderEvents(events) {
var body = document.getElementById("events-body");
if (!events.length) {
body.innerHTML = '<div class="empty">No public GitHub events found for the token owner.</div>';
return;
}
var table = document.createElement("table");
var thead = document.createElement("thead");
thead.innerHTML = "<tr><th>Type</th><th>Actor</th><th>Repo</th><th>Ref</th><th>Commit</th><th>Date</th></tr>";
table.appendChild(thead);
var tbody = document.createElement("tbody");
events.slice().sort(function(a, b) { return a.created_at < b.created_at ? 1 : -1; }).forEach(function(e) {
var tr = document.createElement("tr");
var sha = (e.payload && e.payload.head) ? e.payload.head.slice(0, 7) : "";
tr.innerHTML =
"<td>" + escapeHTML(e.type || "") + "</td>" +
"<td>" + escapeHTML(e.actor ? e.actor.login : "") + "</td>" +
"<td>" + escapeHTML(e.repo ? e.repo.name : "") + "</td>" +
"<td>" + escapeHTML(e.payload ? e.payload.ref || "" : "") + "</td>" +
"<td>" + escapeHTML(sha) + "</td>" +
"<td>" + escapeHTML(e.created_at || "") + "</td>";
tbody.appendChild(tr);
});
table.appendChild(tbody);
body.innerHTML = "";
body.appendChild(table);
}
function escapeHTML(s) {
var div = document.createElement("div");
div.textContent = s;
return div.innerHTML;
}
var GROUPS = groupData(DATA);
renderStats(DATA, GROUPS);
renderCharts(GROUPS, DATA);
renderGroups(GROUPS);
renderEvents(EVENTS);
document.getElementById("search").addEventListener("input", function(e) {
filterText = e.target.value.toLowerCase();
renderGroups(GROUPS);
});
document.getElementById("toggle-all").addEventListener("click", function() {
expandAll = !expandAll;
this.textContent = expandAll ? "Collapse all" : "Expand all";
renderGroups(GROUPS);
});
document.getElementById("export").addEventListener("click", function() {
var cols = ["Name", "Email", "Field", "Repo", "CommitSHA", "Date"];
var lines = [cols.join(",")];
DATA.forEach(function(e) {
lines.push(cols.map(function(c) { return csvEscape(e[c]); }).join(","));
});
var blob = new Blob([lines.join("\n")], { type: "text/csv" });
var a = document.createElement("a");
a.href = URL.createObjectURL(blob);
a.download = "git-identity-audit-export.csv";
a.click();
URL.revokeObjectURL(a.href);
});
</script>
</body>
</html>