From 82229dace9ea9a40f0a994950f8ac16b131ca8d8 Mon Sep 17 00:00:00 2001 From: Aaron Axvig Date: Sat, 25 Jul 2026 23:15:34 -0500 Subject: [PATCH] Add some statistics stuff --- package.json | 1 + scripts/generate-release-statistics.mjs | 174 ++++++++++++ scripts/refresh-generated-data.mjs | 2 + test/release-statistics.test.mjs | 50 ++++ web/index.html | 4 +- web/js/statistics.js | 25 ++ web/process.html | 8 +- web/statistics.html | 353 ++++++++++++++++++++++++ web/styles.css | 168 +++++++++++ 9 files changed, 781 insertions(+), 4 deletions(-) create mode 100644 scripts/generate-release-statistics.mjs create mode 100644 test/release-statistics.test.mjs create mode 100644 web/js/statistics.js create mode 100644 web/statistics.html diff --git a/package.json b/package.json index 4008ba1..e835841 100644 --- a/package.json +++ b/package.json @@ -5,6 +5,7 @@ "scripts": { "test": "node --test", "update:products": "node scripts/update-products-from-issues.mjs", + "update:statistics": "node scripts/generate-release-statistics.mjs", "update:fixtures": "node scripts/generate-rendered-fixtures.mjs", "update:generated": "node scripts/refresh-generated-data.mjs", "vendor:marked": "mkdir -p web/vendor && cp node_modules/marked/lib/marked.esm.js web/vendor/marked.esm.js" diff --git a/scripts/generate-release-statistics.mjs b/scripts/generate-release-statistics.mjs new file mode 100644 index 0000000..11cd751 --- /dev/null +++ b/scripts/generate-release-statistics.mjs @@ -0,0 +1,174 @@ +#!/usr/bin/env node +import { mkdir, writeFile } from 'node:fs/promises'; +import { dirname, join, resolve } from 'node:path'; +import { fileURLToPath, pathToFileURL } from 'node:url'; +import { collectIssueFiles } from './update-products-from-issues.mjs'; + +const REPO_ROOT = resolve(dirname(fileURLToPath(import.meta.url)), '..'); +const VERSION_RE = /^(?\d+)(?:\.(?\d+))?(?:\.(?\d+))?(?.*)$/; +const HOTFIX_RE = /-h\d+(?:\D|$)/i; + +function compareNumbers(left, right) { + return Number(left) - Number(right); +} + +export function buildReleaseStatistics(records) { + const products = new Map(); + + for (const { product, version } of records) { + const match = VERSION_RE.exec(version); + if (!match?.groups.minor) continue; + + const productVersions = products.get(product) ?? new Set(); + productVersions.add(version); + products.set(product, productVersions); + } + + return Array.from(products, ([product, versions]) => { + const majors = new Map(); + + for (const version of versions) { + const { major, minor, patch } = VERSION_RE.exec(version).groups; + const majorNode = majors.get(major) ?? { version: major, releases: 0, minors: new Map() }; + const minorVersion = `${major}.${minor}`; + const minorNode = majorNode.minors.get(minor) ?? { + version: minorVersion, + releases: 0, + patches: new Map() + }; + + majorNode.releases += 1; + minorNode.releases += 1; + + if (patch !== undefined) { + const patchVersion = `${minorVersion}.${patch}`; + const patchNode = minorNode.patches.get(patch) ?? { + version: patchVersion, + hotfixes: 0 + }; + if (HOTFIX_RE.test(version)) patchNode.hotfixes += 1; + minorNode.patches.set(patch, patchNode); + } + + majorNode.minors.set(minor, minorNode); + majors.set(major, majorNode); + } + + return { + product, + releases: versions.size, + majors: Array.from(majors.values()) + .sort((a, b) => compareNumbers(b.version, a.version)) + .map(major => ({ + ...major, + minors: Array.from(major.minors.values()) + .sort((a, b) => compareNumbers(b.version.split('.')[1], a.version.split('.')[1])) + .map(minor => ({ + ...minor, + patches: Array.from(minor.patches.values()) + .sort((a, b) => compareNumbers( + b.version.split('.')[2], + a.version.split('.')[2] + )) + })) + })) + }; + }).sort((a, b) => a.product.localeCompare(b.product)); +} + +function escapeHtml(value) { + return String(value) + .replaceAll('&', '&') + .replaceAll('<', '<') + .replaceAll('>', '>') + .replaceAll('"', '"') + .replaceAll("'", '''); +} + +function plural(count, singular, pluralForm = `${singular}s`) { + return `${count.toLocaleString('en-US')} ${count === 1 ? singular : pluralForm}`; +} + +function renderProduct({ product, releases, majors }, productIndex) { + const rows = majors.flatMap((major, majorIndex) => { + const majorId = `p${productIndex}-major-${majorIndex}`; + return [ + `${plural(major.releases, 'release')}`, + ...major.minors.flatMap((minor, minorIndex) => { + const minorId = `${majorId}-minor-${minorIndex}`; + return [ + `${plural(minor.releases, 'release')}`, + ...minor.patches.map(patch => + `${patch.version}${plural(patch.hotfixes, 'hotfix', 'hotfixes')}` + ) + ]; + }) + ]; + }).join('\n'); + + return `
+

${escapeHtml(product)} ${plural(releases, 'release')}

+
+ + + +${rows} + +
VersionCount
+
+
`; +} + +export function renderStatisticsPage(statistics) { + return ` + + + + + Release statistics - Firewall issues tool + + + + + + + +
+

Release statistics

+ +
+
+

Unique releases represented in the issue data. Major and minor rows include all releases beneath them; patch rows count hotfix releases.

+${statistics.map(renderProduct).join('\n')} +
+ + + +`; +} + +export async function generateReleaseStatistics({ issuesDir, outputPath }) { + const records = await collectIssueFiles(issuesDir); + const statistics = buildReleaseStatistics(records); + await mkdir(dirname(outputPath), { recursive: true }); + await writeFile(outputPath, renderStatisticsPage(statistics), 'utf8'); + return { products: statistics.length, releases: statistics.reduce((sum, item) => sum + item.releases, 0) }; +} + +export async function runGenerateStatisticsCli() { + const result = await generateReleaseStatistics({ + issuesDir: join(REPO_ROOT, 'web/data/issues'), + outputPath: join(REPO_ROOT, 'web/statistics.html') + }); + console.log(`Generated release statistics for ${result.products} product(s) and ${result.releases} release(s).`); + return result; +} + +if (process.argv[1] && import.meta.url === pathToFileURL(resolve(process.argv[1])).href) { + await runGenerateStatisticsCli(); +} diff --git a/scripts/refresh-generated-data.mjs b/scripts/refresh-generated-data.mjs index eebe1e1..ab88681 100644 --- a/scripts/refresh-generated-data.mjs +++ b/scripts/refresh-generated-data.mjs @@ -1,7 +1,9 @@ #!/usr/bin/env node import { runUpdateProductsCli } from './update-products-from-issues.mjs'; import { generateRenderedFixtures } from './generate-rendered-fixtures.mjs'; +import { runGenerateStatisticsCli } from './generate-release-statistics.mjs'; await runUpdateProductsCli(); +await runGenerateStatisticsCli(); const fixtureResult = generateRenderedFixtures(); console.log(`Regenerated ${fixtureResult.fixtures} rendered fixture set(s).`); diff --git a/test/release-statistics.test.mjs b/test/release-statistics.test.mjs new file mode 100644 index 0000000..fce05ae --- /dev/null +++ b/test/release-statistics.test.mjs @@ -0,0 +1,50 @@ +import assert from 'node:assert/strict'; +import test from 'node:test'; +import { buildReleaseStatistics, renderStatisticsPage } from '../scripts/generate-release-statistics.mjs'; + +test('counts unique releases at major and minor levels and hotfixes at patch level', () => { + const records = [ + { product: 'PAN-OS', issueType: 'known', version: '10.1.2' }, + { product: 'PAN-OS', issueType: 'addressed', version: '10.1.2' }, + { product: 'PAN-OS', issueType: 'addressed', version: '10.1.2-h1' }, + { product: 'PAN-OS', issueType: 'addressed', version: '10.1.2-h3' }, + { product: 'PAN-OS', issueType: 'addressed', version: '10.1.3' }, + { product: 'PAN-OS', issueType: 'known', version: '10.2.1' } + ]; + + const [product] = buildReleaseStatistics(records); + assert.equal(product.releases, 5); + assert.equal(product.majors[0].releases, 5); + assert.equal(product.majors[0].minors[0].version, '10.2'); + assert.equal(product.majors[0].minors[1].releases, 4); + assert.deepEqual( + product.majors[0].minors[1].patches.map(({ version, hotfixes }) => ({ version, hotfixes })), + [ + { version: '10.1.3', hotfixes: 0 }, + { version: '10.1.2', hotfixes: 2 } + ] + ); +}); + +test('renders the nested levels into a static HTML page', () => { + const html = renderStatisticsPage(buildReleaseStatistics([ + { product: 'PAN-OS', issueType: 'addressed', version: '10.1.2-h1' } + ])); + + assert.match(html, /class="stats-major"/); + assert.match(html, /class="stats-minor"[^>]+hidden/); + assert.match(html, /class="stats-patch"[^>]+hidden/); + assert.match(html, /class="stats-toggle"[^>]+aria-expanded="false"/); + assert.match(html, /src="js\/statistics\.js"/); + assert.match(html, />1 hotfix { + const html = renderStatisticsPage(buildReleaseStatistics([ + { product: 'PAN-OS', issueType: 'addressed', version: '10.1.2-h1' }, + { product: 'PAN-OS', issueType: 'addressed', version: '10.1.2-h2' } + ])); + + assert.match(html, />2 hotfixes

Firewall issues tool

diff --git a/web/js/statistics.js b/web/js/statistics.js new file mode 100644 index 0000000..57b529c --- /dev/null +++ b/web/js/statistics.js @@ -0,0 +1,25 @@ +function collapseDescendants(row) { + const toggle = row.querySelector('.stats-toggle'); + if (!toggle) return; + + toggle.setAttribute('aria-expanded', 'false'); + const group = toggle.getAttribute('aria-controls'); + document.querySelectorAll(`[data-stats-parent="${group}"]`).forEach(child => { + child.hidden = true; + collapseDescendants(child); + }); +} + +document.addEventListener('click', event => { + const toggle = event.target.closest('.stats-toggle'); + if (!toggle) return; + + const expanded = toggle.getAttribute('aria-expanded') === 'true'; + toggle.setAttribute('aria-expanded', String(!expanded)); + const group = toggle.getAttribute('aria-controls'); + + document.querySelectorAll(`[data-stats-parent="${group}"]`).forEach(row => { + row.hidden = expanded; + if (expanded) collapseDescendants(row); + }); +}); diff --git a/web/process.html b/web/process.html index f6a3598..5dae372 100644 --- a/web/process.html +++ b/web/process.html @@ -109,8 +109,10 @@

Process Issues

@@ -154,4 +156,4 @@ - \ No newline at end of file + diff --git a/web/statistics.html b/web/statistics.html new file mode 100644 index 0000000..51179e3 --- /dev/null +++ b/web/statistics.html @@ -0,0 +1,353 @@ + + + + + + Release statistics - Firewall issues tool + + + + + + + +
+

Release statistics

+ +
+
+

Unique releases represented in the issue data. Major and minor rows include all releases beneath them; patch rows count hotfix releases.

+
+

GlobalProtect 50 releases

+
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
VersionCount
50 releases
+
+
+
+

GlobalProtect-Android 16 releases

+
+ + + + + + + + + + + + + + + + + + + + + + +
VersionCount
16 releases
+
+
+
+

GlobalProtect-iOS 14 releases

+
+ + + + + + + + + + + + + + + + + + + + +
VersionCount
14 releases
+
+
+
+

GlobalProtect-Linux 15 releases

+
+ + + + + + + + + + + + + + + + + + + + + + +
VersionCount
15 releases
+
+
+
+

PAN-OS 487 releases

+
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
VersionCount
17 releases
195 releases
154 releases
76 releases
45 releases
+
+
+
+

Prisma Access Agent 15 releases

+
+ + + + + + + + + + + + + + + + + + + +
VersionCount
3 releases
12 releases
+
+
+
+ + + diff --git a/web/styles.css b/web/styles.css index fe53516..22260a8 100644 --- a/web/styles.css +++ b/web/styles.css @@ -34,6 +34,13 @@ header { text-decoration: underline; } +.top-links a[aria-current="page"] { + color: #ffffff; + font-weight: 700; + text-decoration: underline; + text-underline-offset: 4px; +} + h1 { margin: 0; } @@ -211,6 +218,167 @@ li:hover { background-color: #f0f0f0; } +.statistics-container { + width: min(900px, calc(100% - 40px)); + margin: 0 auto; + padding: 28px 0 48px; +} + +.statistics-intro { + margin: 0 0 28px; + color: #4e5d6c; + line-height: 1.5; +} + +.stats-product { + margin-top: 32px; +} + +.stats-product h2 { + display: flex; + align-items: baseline; + gap: 12px; + margin: 0 0 10px; +} + +.stats-product-total { + color: #667788; + font-size: 0.65em; + font-weight: 400; +} + +.stats-table-wrap { + overflow-x: auto; +} + +.stats-table { + width: 100%; + border-collapse: collapse; + border: 1px solid #ccd4dc; + background: #ffffff; +} + +.stats-table th, +.stats-table td { + border-bottom: 1px solid #dce2e8; + padding: 9px 14px; + text-align: left; +} + +.stats-table thead th { + background: #2c3e50; + color: #ffffff; + font-size: 0.8rem; + letter-spacing: 0.04em; + text-transform: uppercase; +} + +.stats-table td { + width: 180px; + color: #526272; + white-space: nowrap; +} + +.stats-table tbody th { + position: relative; +} + +.stats-table tr[hidden] { + display: none; +} + +.stats-toggle { + display: flex; + align-items: center; + width: 100%; + border: 0; + padding: 0; + background: transparent; + color: inherit; + cursor: pointer; + font: inherit; + text-align: left; +} + +.stats-toggle::before { + width: 18px; + color: #617588; + content: "▶"; + font-size: 0.65rem; + transform-origin: center; + transition: transform 120ms ease; +} + +.stats-toggle[aria-expanded="true"]::before { + transform: rotate(90deg); +} + +.stats-toggle:hover .version-label, +.stats-toggle:focus-visible .version-label { + text-decoration: underline; + text-underline-offset: 3px; +} + +.stats-toggle:focus-visible { + outline: 2px solid #2c7be5; + outline-offset: 3px; +} + +.stats-major th, +.stats-major td { + background: #e7edf2; + color: #22313f; + font-weight: 700; +} + +.stats-minor th, +.stats-minor td { + background: #f5f7f9; + font-weight: 600; +} + +.stats-minor .version-label { + display: inline-block; + margin-left: 10px; +} + +.stats-patch th::before { + position: absolute; + color: #91a0ad; + content: "└"; +} + +.stats-patch .version-label { + display: inline-block; + margin-left: 58px; +} + +.stats-patch th::before { + left: 44px; +} + +@media (max-width: 600px) { + .statistics-container { + width: min(100% - 24px, 900px); + padding-top: 20px; + } + + .stats-product h2 { + align-items: flex-start; + flex-direction: column; + gap: 3px; + } + + .stats-table th, + .stats-table td { + padding: 8px 10px; + } + + .stats-table td { + width: 120px; + } +} + @media (max-width: 900px) { .container { flex-direction: column;