| <!DOCTYPE html> |
| <html lang="en"> |
| <head> |
| <meta charset="UTF-8"> |
| <meta name="viewport" content="width=device-width, initial-scale=1.0"> |
| <title>ASF Community Metrics</title> |
| <style> |
| * { box-sizing: border-box; margin: 0; padding: 0; } |
| body { |
| font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif; |
| line-height: 1.6; |
| color: #333; |
| max-width: 1200px; |
| margin: 0 auto; |
| padding: 2rem; |
| background: #f8f9fa; |
| } |
| h1 { margin-bottom: 0.5rem; color: #1a1a2e; } |
| .subtitle { color: #666; margin-bottom: 2rem; } |
| .stats-bar { |
| display: flex; |
| gap: 1.5rem; |
| margin-bottom: 2rem; |
| flex-wrap: wrap; |
| } |
| .stat-card { |
| background: white; |
| border-radius: 8px; |
| padding: 1.5rem; |
| box-shadow: 0 1px 3px rgba(0,0,0,0.1); |
| min-width: 160px; |
| flex: 1; |
| } |
| .stat-card .number { |
| font-size: 2rem; |
| font-weight: 700; |
| color: #d45500; |
| } |
| .stat-card .label { color: #666; font-size: 0.9rem; } |
| .section { |
| background: white; |
| border-radius: 8px; |
| padding: 1.5rem; |
| margin-bottom: 1.5rem; |
| box-shadow: 0 1px 3px rgba(0,0,0,0.1); |
| } |
| .section h2 { margin-bottom: 1rem; color: #1a1a2e; } |
| table { |
| width: 100%; |
| border-collapse: collapse; |
| } |
| th, td { |
| text-align: left; |
| padding: 0.5rem 1rem; |
| border-bottom: 1px solid #eee; |
| } |
| th { font-weight: 600; color: #666; font-size: 0.85rem; text-transform: uppercase; } |
| td a { color: #d45500; text-decoration: none; } |
| td a:hover { text-decoration: underline; } |
| .bar { |
| background: #fff3e0; |
| border-radius: 3px; |
| height: 20px; |
| position: relative; |
| } |
| .bar-fill { |
| background: #d45500; |
| border-radius: 3px; |
| height: 100%; |
| min-width: 2px; |
| } |
| .bar-label { |
| position: absolute; |
| right: 6px; |
| top: 1px; |
| font-size: 0.75rem; |
| color: #333; |
| } |
| #loading { color: #999; font-style: italic; padding: 2rem; text-align: center; } |
| .tab-bar { display: flex; gap: 0; margin-bottom: 0; } |
| .tab { |
| padding: 0.6rem 1.2rem; |
| cursor: pointer; |
| border: 1px solid #ddd; |
| border-bottom: none; |
| border-radius: 6px 6px 0 0; |
| background: #f0f0f0; |
| font-size: 0.9rem; |
| } |
| .tab.active { background: white; font-weight: 600; border-bottom: 1px solid white; margin-bottom: -1px; } |
| .tab-content { display: none; } |
| .tab-content.active { display: block; } |
| </style> |
| </head> |
| <body> |
| <h1>ASF Community Metrics</h1> |
| <p class="subtitle">Apache project metrics — <a href="about.html" style="color:#666;text-decoration:underline">about the data</a></p> |
| |
| <div id="loading">Loading data...</div> |
| <div id="app" style="display:none"> |
| |
| <div class="stats-bar" id="stats-bar"> |
| <div class="stat-card"> |
| <div class="number" id="project-count">—</div> |
| <div class="label">Active PMCs</div> |
| </div> |
| <div class="stat-card"> |
| <div class="number" id="committer-count">—</div> |
| <div class="label">Total Roster Entries</div> |
| </div> |
| <div class="stat-card"> |
| <div class="number" id="new-committers-6mo">—</div> |
| <div class="label">New Committers/PMC members (12 mo)</div> |
| </div> |
| <div class="stat-card"> |
| <div class="number" id="releases-6mo">—</div> |
| <div class="label">Releases (12 months)</div> |
| </div> |
| </div> |
| |
| <div class="tab-bar"> |
| <div class="tab" data-tab="growth">Community Growth</div> |
| <div class="tab" data-tab="releases">Recent Releases</div> |
| <div class="tab" data-tab="mailing">Mailing Lists</div> |
| <div class="tab active" data-tab="projects">All Projects</div> |
| <div class="tab" data-tab="health">Activity Trends</div> |
| </div> |
| |
| <div class="section" style="border-radius: 0 8px 8px 8px;"> |
| <div class="tab-content" id="tab-growth"> |
| <h2>Community Growth (Last 12 Months)</h2> |
| <p style="color:#666;font-size:0.9rem;margin-bottom:1rem"><span style="display:inline-block;width:12px;height:12px;background:#1a73e8;border-radius:2px;vertical-align:middle"></span> New Committers <span style="display:inline-block;width:12px;height:12px;background:#d45500;border-radius:2px;vertical-align:middle"></span> New PMC members</p> |
| <table id="growth-table"> |
| <thead><tr><th>Project</th><th>Committers</th><th>PMC</th><th></th></tr></thead> |
| <tbody></tbody> |
| </table> |
| </div> |
| |
| <div class="tab-content" id="tab-releases"> |
| <h2>Releases in Last 6 Months</h2> |
| <table id="releases-table"> |
| <thead><tr><th>Project</th><th>Releases</th><th></th></tr></thead> |
| <tbody></tbody> |
| </table> |
| </div> |
| |
| <div class="tab-content" id="tab-mailing"> |
| <h2>Mailing List Activity (Last 12 Months)</h2> |
| <p style="color:#666;margin-bottom:1rem">Messages per month on dev@ lists (12-month window, with trend line)</p> |
| <table id="mailing-table"> |
| <thead><tr><th>Project</th><th>Messages (12mo)</th><th>Trend</th></tr></thead> |
| <tbody></tbody> |
| </table> |
| <p id="mailing-note" style="color:#999;font-size:0.85rem;margin-top:1rem;display:none"> |
| Run <code>uv run asfmetrics</code> (without --skip-mailing-lists) to collect per-project mailing list data.</p> |
| </div> |
| |
| <div class="tab-content active" id="tab-projects"> |
| <h2>Active Projects</h2> |
| <table id="projects-table"> |
| <thead><tr><th>Project</th><th>Established</th><th>PMC Size</th><th>Chair</th></tr></thead> |
| <tbody></tbody> |
| </table> |
| </div> |
| <div class="tab-content" id="tab-health"> |
| <h2 id="health-title">Activity Trends</h2> |
| <p id="health-window" style="color:#666;font-size:0.9rem;margin-bottom:0.5rem"></p> |
| <p style="color:#666;font-size:0.85rem;margin-bottom:1.5rem" id="health-methodology"></p> |
| |
| <h3 style="color:#555">⚫ Dormant <span id="dormant-count" style="font-weight:normal;color:#666"></span></h3> |
| <p style="color:#666;font-size:0.85rem;margin-bottom:0.5rem">Near-zero activity across all metrics.</p> |
| <table id="dormant-table"> |
| <thead><tr><th>Project</th><th id="dormant-ml-header">Mailing List Msgs</th><th id="dormant-git-header">Git Commits</th></tr></thead> |
| <tbody></tbody> |
| </table> |
| |
| <h3 style="color:#c0392b;margin-top:2rem">🔴 Sharp Decline <span id="at-risk-count" style="font-weight:normal;color:#666"></span></h3> |
| <p style="color:#666;font-size:0.85rem;margin-bottom:0.5rem">Both mailing list discussion and git commits down ≥ 30% quarter-over-quarter.</p> |
| <table id="at-risk-table"> |
| <thead><tr><th>Project</th><th class="health-ml-header">Mailing List (Q1 vs Q2)</th><th class="health-git-header">Git Commits (Q1 vs Q2)</th></tr></thead> |
| <tbody></tbody> |
| </table> |
| |
| <h3 style="color:#e67e22;margin-top:2rem">🟡 Declining <span id="declining-count" style="font-weight:normal;color:#666"></span></h3> |
| <p style="color:#666;font-size:0.85rem;margin-bottom:0.5rem">One or both metrics declining significantly, with meaningful prior activity.</p> |
| <table id="declining-table"> |
| <thead><tr><th>Project</th><th class="health-ml-header">Mailing List (Q1 vs Q2)</th><th class="health-git-header">Git Commits (Q1 vs Q2)</th></tr></thead> |
| <tbody></tbody> |
| </table> |
| |
| <div style="margin-top:2rem;padding:1rem;background:#f8f9fa;border-radius:6px;font-size:0.85rem;color:#555"> |
| <strong>Methodology:</strong> <span id="health-method-detail"></span><br> |
| <strong>Thresholds:</strong> Sharp Decline = both axes ≤ <span id="threshold-decline"></span>%. |
| Declining = one axis ≤ <span id="threshold-decline2"></span>% (min <span id="threshold-prior"></span> prior activity). |
| Dormant = ≤ <span id="threshold-dormant-commits"></span> commits + ≤ <span id="threshold-dormant-msgs"></span> messages in recent quarter. |
| </div> |
| </div> |
| </div> |
| |
| <p id="generated-date" style="color:#999;font-size:0.8rem;text-align:right;margin-top:0.5rem"></p> |
| |
| </div><!-- #app --> |
| |
| <script> |
| // Tab switching |
| document.querySelectorAll('.tab').forEach(tab => { |
| tab.addEventListener('click', () => { |
| document.querySelectorAll('.tab').forEach(t => t.classList.remove('active')); |
| document.querySelectorAll('.tab-content').forEach(c => c.classList.remove('active')); |
| tab.classList.add('active'); |
| document.getElementById('tab-' + tab.dataset.tab).classList.add('active'); |
| }); |
| }); |
| |
| async function loadData() { |
| try { |
| const [committeesResp, releasesResp, newCommResp] = await Promise.all([ |
| fetch('data/_cache/committees.json'), |
| fetch('data/_cache/releases.json'), |
| fetch('data/_cache/new_committers.json'), |
| ]); |
| |
| if (!committeesResp.ok) { |
| document.getElementById('loading').textContent = |
| 'No data found. Run: uv run asfmetrics --skip-mailing-lists'; |
| return; |
| } |
| |
| const committees = await committeesResp.json(); |
| const releases = releasesResp.ok ? await releasesResp.json() : {}; |
| const newCommData = newCommResp && newCommResp.ok ? await newCommResp.json() : {by_project: {}, total: 0}; |
| |
| document.getElementById('loading').style.display = 'none'; |
| document.getElementById('app').style.display = 'block'; |
| |
| // Show data generation date from Last-Modified header |
| const lastMod = committeesResp.headers.get('Last-Modified'); |
| if (lastMod) { |
| document.getElementById('generated-date').textContent = `Data generated: ${new Date(lastMod).toLocaleDateString('en-US', {weekday: 'long', year: 'numeric', month: 'long', day: 'numeric'})}`; |
| } |
| |
| const sixMonthsAgo = new Date(); |
| sixMonthsAgo.setMonth(sixMonthsAgo.getMonth() - 12); |
| const sixMonthStr = sixMonthsAgo.toISOString().slice(0, 10); |
| const quarterStr = `${sixMonthsAgo.getFullYear()}-${String(sixMonthsAgo.getMonth()+1).padStart(2,'0')}`; |
| |
| // Compute metrics |
| let totalRoster = 0; |
| let newIn6Mo = 0; |
| const growthByProject = {}; |
| |
| committees.forEach(pmc => { |
| const roster = pmc.roster || {}; |
| const size = Object.keys(roster).length; |
| totalRoster += size; |
| |
| let newCount = 0; |
| Object.values(roster).forEach(entry => { |
| if (entry.date && entry.date >= sixMonthStr) { |
| newCount++; |
| newIn6Mo++; |
| } |
| }); |
| if (newCount > 0) { |
| growthByProject[pmc.id] = { count: newCount, id: pmc.id, name: pmc.name }; |
| } |
| }); |
| |
| // Releases in last 6 months |
| let releasesIn6Mo = 0; |
| const releasesByProject = {}; |
| Object.entries(releases).forEach(([project, rels]) => { |
| let count = 0; |
| Object.entries(rels).forEach(([name, date]) => { |
| if (date >= sixMonthStr) { |
| count++; |
| releasesIn6Mo++; |
| } |
| }); |
| if (count > 0) { |
| releasesByProject[project] = count; |
| } |
| }); |
| |
| // Update stat cards |
| document.getElementById('project-count').textContent = committees.length; |
| document.getElementById('committer-count').textContent = totalRoster.toLocaleString(); |
| document.getElementById('new-committers-6mo').textContent = `${newCommData.total || 0} / ${newIn6Mo}`; |
| document.getElementById('releases-6mo').textContent = releasesIn6Mo.toLocaleString(); |
| |
| // Growth table — merge PMC members + new committers |
| const newCommByProject = newCommData.by_project || {}; |
| // Build id->name lookup from all committees (not just ones with growth) |
| const projectNames = {}; |
| committees.forEach(pmc => { projectNames[pmc.id] = pmc.name; }); |
| |
| const allGrowthProjects = new Set([ |
| ...Object.keys(growthByProject), |
| ...Object.keys(newCommByProject), |
| ]); |
| const growthMerged = []; |
| allGrowthProjects.forEach(proj => { |
| const pmcEntry = growthByProject[proj]; |
| const pmcCount = pmcEntry ? pmcEntry.count : 0; |
| const commCount = newCommByProject[proj] ? newCommByProject[proj].length : 0; |
| const id = pmcEntry ? pmcEntry.id : proj; |
| const displayName = pmcEntry ? pmcEntry.name : (projectNames[proj] || proj); |
| growthMerged.push({name: displayName, id, pmcCount, commCount, total: pmcCount + commCount}); |
| }); |
| growthMerged.sort((a, b) => b.total - a.total); |
| const maxGrowth = growthMerged.length ? growthMerged[0].total : 1; |
| const growthTbody = document.querySelector('#growth-table tbody'); |
| growthMerged.forEach(data => { |
| const pmcPct = (data.pmcCount / maxGrowth * 100).toFixed(0); |
| const commPct = (data.commCount / maxGrowth * 100).toFixed(0); |
| const row = document.createElement('tr'); |
| row.innerHTML = ` |
| <td><a href="project.html?id=${data.id}">${data.name}</a></td> |
| <td style="width:80px;text-align:right">${data.commCount > 0 ? data.commCount : ''}</td> |
| <td style="width:80px;text-align:right">${data.pmcCount > 0 ? data.pmcCount : ''}</td> |
| <td style="width:35%"><div class="bar" style="display:flex"><div style="background:#1a73e8;border-radius:3px 0 0 3px;height:100%;width:${commPct}%"></div><div style="background:#d45500;height:100%;width:${pmcPct}%"></div></div></td> |
| `; |
| growthTbody.appendChild(row); |
| }); |
| |
| // Releases table |
| const relSorted = Object.entries(releasesByProject) |
| .sort((a, b) => b[1] - a[1]); |
| const maxRel = relSorted.length ? relSorted[0][1] : 1; |
| const relTbody = document.querySelector('#releases-table tbody'); |
| relSorted.forEach(([project, count]) => { |
| const pct = (count / maxRel * 100).toFixed(0); |
| const row = document.createElement('tr'); |
| row.innerHTML = ` |
| <td>${project}</td> |
| <td style="width:60px;text-align:right">${count}</td> |
| <td style="width:40%"><div class="bar"><div class="bar-fill" style="width:${pct}%"></div></div></td> |
| `; |
| relTbody.appendChild(row); |
| }); |
| |
| // Projects table |
| const projTbody = document.querySelector('#projects-table tbody'); |
| committees |
| .sort((a, b) => a.name.localeCompare(b.name)) |
| .forEach(c => { |
| const rosterSize = Object.keys(c.roster || {}).length; |
| const row = document.createElement('tr'); |
| row.innerHTML = ` |
| <td><a href="project.html?id=${c.id}">${c.name}</a></td> |
| <td>${c.established || '—'}</td> |
| <td>${rosterSize}</td> |
| <td>${c.chair || '—'}</td> |
| `; |
| projTbody.appendChild(row); |
| }); |
| |
| // Mailing list data — load per-project JSON files |
| // Load single summary file (written by collector) instead of 200+ individual fetches |
| const mailingTbody = document.querySelector('#mailing-table tbody'); |
| const mailingNote = document.getElementById('mailing-note'); |
| const sixMonthsAgoDate = new Date(); |
| sixMonthsAgoDate.setMonth(sixMonthsAgoDate.getMonth() - 12); |
| const cutoffYear = sixMonthsAgoDate.getFullYear(); |
| const cutoffMonth = sixMonthsAgoDate.getMonth() + 1; |
| const cutoffStr = `${cutoffYear}-${String(cutoffMonth).padStart(2, '0')}`; |
| |
| const mailingResults = []; |
| |
| try { |
| const summaryResp = await fetch('data/_cache/mailing_summary.json'); |
| if (summaryResp.ok) { |
| const summaryData = await summaryResp.json(); |
| summaryData.forEach(entry => { |
| const months = entry.active_months || {}; |
| let recent = 0; |
| Object.entries(months).forEach(([month, count]) => { |
| if (month >= cutoffStr) recent += count; |
| }); |
| if (recent > 0) { |
| mailingResults.push({ |
| project: entry.project, |
| list: entry.list_name, |
| recent: recent, |
| months: months, |
| }); |
| } |
| }); |
| } |
| } catch(e) {} |
| |
| if (mailingResults.length === 0) { |
| mailingNote.style.display = 'block'; |
| } else { |
| mailingResults.sort((a, b) => b.recent - a.recent); |
| const maxMail = mailingResults[0].recent; |
| mailingResults.forEach(({project, list, recent}) => { |
| const pct = (recent / maxMail * 100).toFixed(0); |
| const row = document.createElement('tr'); |
| row.innerHTML = ` |
| <td>${project} (${list}@)</td> |
| <td style="width:80px;text-align:right">${recent}</td> |
| <td style="width:40%"><div class="bar"><div class="bar-fill" style="width:${pct}%"></div></div></td> |
| `; |
| mailingTbody.appendChild(row); |
| }); |
| } |
| |
| // --- Activity Trends tab --- |
| let healthData = null; |
| try { |
| const healthResp = await fetch('data/_cache/project_health.json'); |
| if (healthResp.ok) { |
| healthData = await healthResp.json(); |
| } |
| } catch (healthErr) { console.warn('project_health.json not available:', healthErr); } |
| |
| if (healthData) { |
| const win = healthData.window; |
| // Update table headers with actual quarter labels |
| const qLabel = `(${win.prior_label} → ${win.recent_label})`; |
| document.querySelectorAll('.health-ml-header').forEach(el => { el.textContent = `Mailing List ${qLabel}`; }); |
| document.querySelectorAll('.health-git-header').forEach(el => { el.textContent = `Git Commits ${qLabel}`; }); |
| |
| document.getElementById('dormant-ml-header').textContent = `Mailing List Msgs (${win.recent_label})`; |
| document.getElementById('dormant-git-header').textContent = `Git Commits (${win.recent_label})`; |
| |
| document.getElementById('health-title').textContent = |
| `Activity Trends — ${win.recent_label} vs ${win.prior_label}`; |
| document.getElementById('health-window').textContent = win.description; |
| document.getElementById('health-methodology').textContent = |
| 'Mailing list counts include only human discussion lists (dev, user, general, discuss). ' + |
| 'Automated lists (notifications, commits, issues) are excluded. Git commits are summed across all repos.'; |
| |
| const thr = healthData.thresholds; |
| document.getElementById('threshold-decline').textContent = thr.decline_pct; |
| document.getElementById('threshold-decline2').textContent = thr.decline_pct; |
| document.getElementById('threshold-prior').textContent = thr.min_prior_activity; |
| document.getElementById('threshold-dormant-commits').textContent = thr.dormant_commits; |
| document.getElementById('threshold-dormant-msgs').textContent = thr.dormant_messages; |
| document.getElementById('health-method-detail').textContent = |
| `Quarter-over-quarter comparison: ${win.recent_label} (recent) vs ${win.prior_label} (prior). ` + |
| `Current partial month excluded. Only projects with data files are assessed.`; |
| |
| // In JSON: "declining" = both axes down (severe), "at_risk" = one axis down (moderate) |
| // On page: "Sharp Decline" (red) = severe, "Declining" (orange) = moderate |
| document.getElementById('at-risk-count').textContent = `(${healthData.declining.length})`; |
| document.getElementById('declining-count').textContent = `(${healthData.at_risk.length})`; |
| document.getElementById('dormant-count').textContent = `(${healthData.dormant.length})`; |
| |
| function trendCell(pct, prior, recent, label) { |
| if (pct === null) return `<span style="color:#999">no data</span>`; |
| const color = pct <= -30 ? '#c0392b' : pct <= -15 ? '#e67e22' : '#27ae60'; |
| return `<span style="color:${color};font-weight:600">${pct > 0 ? '+' : ''}${pct}%</span> ` + |
| `<span style="color:#888;font-size:0.85rem">(${prior}→${recent} ${label})</span>`; |
| } |
| |
| function fillHealthTable(tableId, entries) { |
| const tbody = document.querySelector(`#${tableId} tbody`); |
| entries.forEach(e => { |
| const row = document.createElement('tr'); |
| const mlCell = trendCell(e.ml_trend_pct, e.ml_prior, e.ml_recent, 'msgs'); |
| const gitCell = trendCell(e.git_trend_pct, e.git_prior, e.git_recent, 'commits'); |
| row.innerHTML = ` |
| <td><a href="project.html?id=${e.project}">${projectNames[e.project] || e.project}</a></td> |
| <td>${mlCell}</td> |
| <td>${gitCell}</td> |
| `; |
| tbody.appendChild(row); |
| }); |
| } |
| |
| // "declining" in JSON = both axes down = "At Risk" on the page (red, severe) |
| // "at_risk" in JSON = one axis down = "Declining" on the page (orange, warning) |
| fillHealthTable('at-risk-table', healthData.declining); |
| fillHealthTable('declining-table', healthData.at_risk); |
| |
| // Dormant table is simpler |
| const dormantTbody = document.querySelector('#dormant-table tbody'); |
| healthData.dormant.forEach(e => { |
| const row = document.createElement('tr'); |
| row.innerHTML = ` |
| <td><a href="project.html?id=${e.project}">${projectNames[e.project] || e.project}</a></td> |
| <td>${e.ml_recent}</td> |
| <td>${e.git_recent}</td> |
| `; |
| dormantTbody.appendChild(row); |
| }); |
| } |
| |
| } catch (e) { |
| console.error('Failed to load data:', e); |
| document.getElementById('loading').textContent = 'Error loading data: ' + e.message; |
| } |
| } |
| |
| loadData(); |
| </script> |
| </body> |
| </html> |