Created
June 10, 2026 00:49
-
-
Save sjwaight/8a05c9e72b9ca39439a18d4d28aa37f4 to your computer and use it in GitHub Desktop.
SPA using chart.js and GitHub Issues API.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| <!DOCTYPE html> | |
| <html> | |
| <head> | |
| <title>AKS Bug SPA</title> | |
| <script src="https://cdn.jsdelivr.net/npm/chart.js"></script> | |
| <style> | |
| body { font-family: Arial; margin: 20px; } | |
| .grid { display: grid; grid-template-columns: 1fr; gap: 20px; max-width: 1000px; } | |
| .chart { display: grid; gap: 10px; margin-bottom: 10px; } | |
| .chart h2 { margin: 0; font-size: 20px; } | |
| canvas { background: white; padding: 10px; border-radius: 8px; } | |
| </style> | |
| </head> | |
| <body> | |
| <h1>AKS Bug Dashboard (Live)</h1> | |
| <!-- note: this shouldn't be needed for an authenticated user | |
| <label> | |
| GitHub Token (optional): | |
| <input id="token" type="password" /> | |
| <button onclick="saveToken()">Save</button> | |
| </label>--> | |
| <div class="grid"> | |
| <div class="chart"> | |
| <h2>Bugs by label</h2> | |
| <canvas id="labelChart"></canvas> | |
| </div> | |
| <div class="chart"> | |
| <h2>Bugs by age bucket</h2> | |
| <canvas id="ageChart"></canvas> | |
| </div> | |
| <div class="chart"> | |
| <h2>Bugs by assignee</h2> | |
| <canvas id="authorChart"></canvas> | |
| </div> | |
| </div> | |
| <script> | |
| let labelChartInstance; | |
| let ageChartInstance; | |
| let assigneeChartInstance; | |
| function saveToken() { | |
| const val = document.getElementById("token").value; | |
| localStorage.setItem("gh_token", val); | |
| loadData(); | |
| } | |
| async function fetchIssues() { | |
| const token = localStorage.getItem("gh_token"); | |
| let page = 1; | |
| let all = []; | |
| while (true) { | |
| const url = `https://api.github.com/repos/Azure/AKS/issues?labels=bug&per_page=100&page=${page}`; | |
| const headers = token ? { | |
| Authorization: `Bearer ${token}` | |
| } : {}; | |
| const res = await fetch(url, { headers }); | |
| const data = await res.json(); | |
| if (data.length === 0) break; | |
| all = all.concat(data); | |
| page++; | |
| } | |
| return all.filter(i => !i.pull_request); | |
| } | |
| function daysOld(date) { | |
| return Math.floor((new Date() - new Date(date)) / (1000*60*60*24)); | |
| } | |
| function toISODate(date) { | |
| return date.toISOString().slice(0, 10); | |
| } | |
| function daysAgoISO(days) { | |
| const date = new Date(); | |
| date.setUTCDate(date.getUTCDate() - days); | |
| return toISODate(date); | |
| } | |
| function buildAgeBucketSearchUrl(bucket) { | |
| const d7 = daysAgoISO(7); | |
| const d30 = daysAgoISO(30); | |
| const d90 = daysAgoISO(90); | |
| const queryParts = ["is:issue", "is:open", "label:bug"]; | |
| if (bucket === "0-7") { | |
| queryParts.push(`created:>=${d7}`); | |
| } else if (bucket === "8-30") { | |
| queryParts.push(`created:>=${d30}`, `created:<${d7}`); | |
| } else if (bucket === "31-90") { | |
| queryParts.push(`created:>=${d90}`, `created:<${d30}`); | |
| } else if (bucket === "90+") { | |
| queryParts.push(`created:<${d90}`); | |
| } | |
| return `https://github.com/Azure/AKS/issues?q=${encodeURIComponent(queryParts.join(" "))}`; | |
| } | |
| function analyse(issues) { | |
| const labels = {}; | |
| const assignees = {}; | |
| const ageBuckets = {"0-7":0,"8-30":0,"31-90":0,"90+":0}; | |
| issues.forEach(issue => { | |
| const age = daysOld(issue.created_at); | |
| if (age <= 7) ageBuckets["0-7"]++; | |
| else if (age <= 30) ageBuckets["8-30"]++; | |
| else if (age <= 90) ageBuckets["31-90"]++; | |
| else ageBuckets["90+"]++; | |
| issue.labels.forEach(l => { | |
| if (l.name !== "bug") { | |
| labels[l.name] = (labels[l.name] || 0) + 1; | |
| } | |
| }); | |
| if (!issue.assignees || issue.assignees.length === 0) { | |
| assignees["unassigned"] = (assignees["unassigned"] || 0) + 1; | |
| } else { | |
| issue.assignees.forEach(a => { | |
| const assignee = a.login || "unknown"; | |
| assignees[assignee] = (assignees[assignee] || 0) + 1; | |
| }); | |
| } | |
| }); | |
| return { labels, ageBuckets, assignees }; | |
| } | |
| function render(data) { | |
| const sortedLabels = Object.entries(data.labels) | |
| .sort((a, b) => b[1] - a[1]) | |
| .slice(0, 20); | |
| const labelNames = sortedLabels.map(([name]) => name); | |
| const labelCounts = sortedLabels.map(([, count]) => count); | |
| const labelCanvas = document.getElementById("labelChart"); | |
| //labelCanvas.style.height = "560px"; | |
| if (labelChartInstance) labelChartInstance.destroy(); | |
| if (ageChartInstance) ageChartInstance.destroy(); | |
| if (assigneeChartInstance) assigneeChartInstance.destroy(); | |
| labelChartInstance = new Chart(labelCanvas, { | |
| type: "bar", | |
| data: { | |
| labels: labelNames, | |
| datasets: [{ | |
| label: "Bugs", | |
| data: labelCounts | |
| }] | |
| // }, | |
| // options: { | |
| // indexAxis: 'y', | |
| // maintainAspectRatio: false, | |
| // layout: { padding: { left: 8, right: 8, top: 8, bottom: 8 } }, | |
| // scales: { | |
| // x: { beginAtZero: true }, | |
| // y: { ticks: { autoSkip: false } } | |
| // } | |
| } | |
| }); | |
| ageChartInstance = new Chart(document.getElementById("ageChart"), { | |
| type: "pie", | |
| data: { | |
| labels: Object.keys(data.ageBuckets), | |
| datasets: [{ | |
| data: Object.values(data.ageBuckets), | |
| backgroundColor: ["green","yellow","orange","red"] | |
| }] | |
| }, | |
| options: { | |
| onClick: function(_event, elements) { | |
| if (!elements || elements.length === 0) return; | |
| const bucket = this.data.labels[elements[0].index]; | |
| const url = buildAgeBucketSearchUrl(bucket); | |
| window.open(url, "_blank", "noopener"); | |
| } | |
| } | |
| }); | |
| const sortedAssignees = Object.entries(data.assignees) | |
| .sort((a, b) => b[1] - a[1]); | |
| assigneeChartInstance = new Chart(document.getElementById("authorChart"), { | |
| type: "bar", | |
| data: { | |
| labels: sortedAssignees.map(([name]) => name), | |
| datasets: [{ | |
| label: "Assigned bugs", | |
| data: sortedAssignees.map(([, count]) => count) | |
| }] | |
| }, | |
| // options: { indexAxis: 'y' } | |
| }); | |
| } | |
| async function loadData() { | |
| const issues = await fetchIssues(); | |
| const data = analyse(issues); | |
| render(data); | |
| } | |
| loadData(); | |
| </script> | |
| </body> | |
| </html> |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment