This commit is contained in:
@@ -1,17 +1,70 @@
|
|||||||
#!/usr/bin/env node
|
#!/usr/bin/env node
|
||||||
import { mkdir, writeFile } from 'node:fs/promises';
|
import { mkdir, readFile, writeFile } from 'node:fs/promises';
|
||||||
import { dirname, join, resolve } from 'node:path';
|
import { dirname, join, resolve } from 'node:path';
|
||||||
import { fileURLToPath, pathToFileURL } from 'node:url';
|
import { fileURLToPath, pathToFileURL } from 'node:url';
|
||||||
import { collectIssueFiles } from './update-products-from-issues.mjs';
|
import { collectIssueFiles } from './update-products-from-issues.mjs';
|
||||||
|
import { extractIssueIds } from '../web/js/issue-id.js';
|
||||||
|
|
||||||
const REPO_ROOT = resolve(dirname(fileURLToPath(import.meta.url)), '..');
|
const REPO_ROOT = resolve(dirname(fileURLToPath(import.meta.url)), '..');
|
||||||
const VERSION_RE = /^(?<major>\d+)(?:\.(?<minor>\d+))?(?:\.(?<patch>\d+))?(?<rest>.*)$/;
|
const VERSION_RE = /^(?<major>\d+)(?:\.(?<minor>\d+))?(?:\.(?<patch>\d+))?(?<rest>.*)$/;
|
||||||
const HOTFIX_RE = /-h\d+(?:\D|$)/i;
|
const HOTFIX_RE = /-h\d+(?:\D|$)/i;
|
||||||
|
const ISSUE_HEADING_RE = /^##\s+(.+?)\s*$/gm;
|
||||||
|
|
||||||
function compareNumbers(left, right) {
|
function compareNumbers(left, right) {
|
||||||
return Number(left) - Number(right);
|
return Number(left) - Number(right);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function productDataFilename(product) {
|
||||||
|
const slug = product.replace(/[^a-z0-9.-]+/gi, '-').replace(/^-+|-+$/g, '');
|
||||||
|
return `${slug || 'product'}.json`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function compareVersions(left, right) {
|
||||||
|
const leftParts = left.match(/\d+/g)?.map(Number) ?? [];
|
||||||
|
const rightParts = right.match(/\d+/g)?.map(Number) ?? [];
|
||||||
|
const length = Math.max(leftParts.length, rightParts.length);
|
||||||
|
for (let index = 0; index < length; index++) {
|
||||||
|
const difference = (leftParts[index] ?? -1) - (rightParts[index] ?? -1);
|
||||||
|
if (difference) return difference;
|
||||||
|
}
|
||||||
|
return left.localeCompare(right);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function extractAddressedIssueIds(markdown) {
|
||||||
|
const ids = [];
|
||||||
|
for (const match of String(markdown).matchAll(ISSUE_HEADING_RE)) {
|
||||||
|
ids.push(...extractIssueIds(match[1]));
|
||||||
|
}
|
||||||
|
return Array.from(new Set(ids));
|
||||||
|
}
|
||||||
|
|
||||||
|
export function buildIssuePlotProduct(product, releases) {
|
||||||
|
const versions = Array.from(releases.keys()).sort(compareVersions);
|
||||||
|
const points = [];
|
||||||
|
versions.forEach((version, releaseIndex) => {
|
||||||
|
for (const id of releases.get(version)) {
|
||||||
|
const issueNumber = Number(id.match(/-(\d+)$/)?.[1]);
|
||||||
|
if (Number.isSafeInteger(issueNumber)) points.push([releaseIndex, issueNumber, id]);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
return { product, releases: versions, points };
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function buildIssuePlotData(records, issuesDir) {
|
||||||
|
const products = new Map();
|
||||||
|
const addressed = records.filter(record => record.issueType === 'addressed');
|
||||||
|
await Promise.all(addressed.map(async record => {
|
||||||
|
const markdown = await readFile(join(issuesDir, record.product, 'addressed', record.filename), 'utf8');
|
||||||
|
const ids = extractAddressedIssueIds(markdown);
|
||||||
|
const releases = products.get(record.product) ?? new Map();
|
||||||
|
releases.set(record.version, ids);
|
||||||
|
products.set(record.product, releases);
|
||||||
|
}));
|
||||||
|
return Array.from(products, ([product, releases]) => buildIssuePlotProduct(product, releases))
|
||||||
|
.filter(item => item.points.length > 0)
|
||||||
|
.sort((a, b) => a.product.localeCompare(b.product));
|
||||||
|
}
|
||||||
|
|
||||||
export function buildReleaseStatistics(records) {
|
export function buildReleaseStatistics(records) {
|
||||||
const products = new Map();
|
const products = new Map();
|
||||||
|
|
||||||
@@ -143,7 +196,28 @@ export function renderStatisticsPage(statistics) {
|
|||||||
</nav>
|
</nav>
|
||||||
</header>
|
</header>
|
||||||
<main class="statistics-container">
|
<main class="statistics-container">
|
||||||
<p class="statistics-intro">Unique releases represented in the issue data. Major and minor rows include all releases beneath them; patch rows count hotfix releases.</p>
|
<p class="statistics-intro">Explore addressed Issue-IDs by release, followed by counts of the releases represented in the issue data.</p>
|
||||||
|
<section class="issue-plot" aria-labelledby="issue-plot-heading">
|
||||||
|
<div class="issue-plot-heading">
|
||||||
|
<div>
|
||||||
|
<h2 id="issue-plot-heading">Addressed issues by release</h2>
|
||||||
|
<p>Each dot is an Issue-ID. Hover for details or select a dot to find that issue.</p>
|
||||||
|
</div>
|
||||||
|
<div class="issue-plot-controls">
|
||||||
|
<label>Product
|
||||||
|
<select id="issue-plot-product"></select>
|
||||||
|
</label>
|
||||||
|
<label>Release train
|
||||||
|
<select id="issue-plot-train"></select>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="issue-plot-frame">
|
||||||
|
<canvas id="issue-plot-canvas" role="img" aria-label="Scatter plot of addressed Issue-IDs by release"></canvas>
|
||||||
|
<div id="issue-plot-tooltip" class="issue-plot-tooltip" role="status" hidden></div>
|
||||||
|
</div>
|
||||||
|
<p id="issue-plot-summary" class="issue-plot-summary" aria-live="polite"></p>
|
||||||
|
</section>
|
||||||
${statistics.map(renderProduct).join('\n')}
|
${statistics.map(renderProduct).join('\n')}
|
||||||
</main>
|
</main>
|
||||||
<script type="module" src="js/statistics.js"></script>
|
<script type="module" src="js/statistics.js"></script>
|
||||||
@@ -152,12 +226,25 @@ ${statistics.map(renderProduct).join('\n')}
|
|||||||
`;
|
`;
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function generateReleaseStatistics({ issuesDir, outputPath }) {
|
export async function generateReleaseStatistics({ issuesDir, outputPath, plotDataDir = join(dirname(outputPath), 'data/statistics') }) {
|
||||||
const records = await collectIssueFiles(issuesDir);
|
const records = await collectIssueFiles(issuesDir);
|
||||||
const statistics = buildReleaseStatistics(records);
|
const statistics = buildReleaseStatistics(records);
|
||||||
|
const plotData = await buildIssuePlotData(records, issuesDir);
|
||||||
await mkdir(dirname(outputPath), { recursive: true });
|
await mkdir(dirname(outputPath), { recursive: true });
|
||||||
|
await mkdir(plotDataDir, { recursive: true });
|
||||||
|
const plotProducts = [];
|
||||||
|
for (const item of plotData) {
|
||||||
|
const filename = productDataFilename(item.product);
|
||||||
|
await writeFile(join(plotDataDir, filename), JSON.stringify(item), 'utf8');
|
||||||
|
plotProducts.push([item.product, filename]);
|
||||||
|
}
|
||||||
|
await writeFile(join(plotDataDir, 'products.json'), JSON.stringify(plotProducts), 'utf8');
|
||||||
await writeFile(outputPath, renderStatisticsPage(statistics), 'utf8');
|
await writeFile(outputPath, renderStatisticsPage(statistics), 'utf8');
|
||||||
return { products: statistics.length, releases: statistics.reduce((sum, item) => sum + item.releases, 0) };
|
return {
|
||||||
|
products: statistics.length,
|
||||||
|
releases: statistics.reduce((sum, item) => sum + item.releases, 0),
|
||||||
|
points: plotData.reduce((sum, item) => sum + item.points.length, 0)
|
||||||
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function runGenerateStatisticsCli() {
|
export async function runGenerateStatisticsCli() {
|
||||||
|
|||||||
@@ -1,6 +1,36 @@
|
|||||||
import assert from 'node:assert/strict';
|
import assert from 'node:assert/strict';
|
||||||
import test from 'node:test';
|
import test from 'node:test';
|
||||||
import { buildReleaseStatistics, renderStatisticsPage } from '../scripts/generate-release-statistics.mjs';
|
import {
|
||||||
|
buildIssuePlotProduct,
|
||||||
|
buildReleaseStatistics,
|
||||||
|
extractAddressedIssueIds,
|
||||||
|
renderStatisticsPage
|
||||||
|
} from '../scripts/generate-release-statistics.mjs';
|
||||||
|
|
||||||
|
test('extracts unique Issue-IDs only from Markdown issue headings', () => {
|
||||||
|
const markdown = `## PAN-123456
|
||||||
|
|
||||||
|
Mentions PAN-999999 in the description.
|
||||||
|
|
||||||
|
## GPC-001234
|
||||||
|
|
||||||
|
## PAN-123456
|
||||||
|
`;
|
||||||
|
assert.deepEqual(extractAddressedIssueIds(markdown), ['PAN-123456', 'GPC-001234']);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('builds compact plot points in semantic release order', () => {
|
||||||
|
const plot = buildIssuePlotProduct('PAN-OS', new Map([
|
||||||
|
['10.2.10-h1', ['PAN-130000']],
|
||||||
|
['10.2.9', ['PAN-100000', 'WF500-120000']]
|
||||||
|
]));
|
||||||
|
assert.deepEqual(plot.releases, ['10.2.9', '10.2.10-h1']);
|
||||||
|
assert.deepEqual(plot.points, [
|
||||||
|
[0, 100000, 'PAN-100000'],
|
||||||
|
[0, 120000, 'WF500-120000'],
|
||||||
|
[1, 130000, 'PAN-130000']
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
test('counts unique releases at major and minor levels and hotfixes at patch level', () => {
|
test('counts unique releases at major and minor levels and hotfixes at patch level', () => {
|
||||||
const records = [
|
const records = [
|
||||||
@@ -36,6 +66,7 @@ test('renders the nested levels into a static HTML page', () => {
|
|||||||
assert.match(html, /class="stats-patch"[^>]+hidden/);
|
assert.match(html, /class="stats-patch"[^>]+hidden/);
|
||||||
assert.match(html, /class="stats-toggle"[^>]+aria-expanded="false"/);
|
assert.match(html, /class="stats-toggle"[^>]+aria-expanded="false"/);
|
||||||
assert.match(html, /src="js\/statistics\.js"/);
|
assert.match(html, /src="js\/statistics\.js"/);
|
||||||
|
assert.match(html, /id="issue-plot-canvas"/);
|
||||||
assert.match(html, />1 hotfix</);
|
assert.match(html, />1 hotfix</);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1 @@
|
|||||||
|
{"product":"GlobalProtect-Android","releases":["6.0.1","6.0.2","6.0.3","6.0.4","6.0.5","6.0.12","6.1.0","6.1.4","6.1.5","6.1.6","6.1.7","6.1.9","6.1.10","6.1.11","6.1.12"],"points":[[0,14612,"GPC-14612"],[1,15068,"GPC-15068"],[1,15023,"GPC-15023"],[2,15088,"GPC-15088"],[3,16414,"GPC-16414"],[4,15977,"GPC-15977"],[4,15231,"GPC-15231"],[4,15023,"GPC-15023"],[5,21389,"GPC-21389"],[5,21707,"GPC-21707"],[5,20071,"GPC-20071"],[6,19030,"GPC-19030"],[6,18672,"GPC-18672"],[6,18207,"GPC-18207"],[6,17875,"GPC-17875"],[6,17635,"GPC-17635"],[6,17435,"GPC-17435"],[6,16741,"GPC-16741"],[6,15137,"GPC-15137"],[7,19340,"GPC-19340"],[7,19331,"GPC-19331"],[7,19289,"GPC-19289"],[7,19280,"GPC-19280"],[7,19193,"GPC-19193"],[7,19187,"GPC-19187"],[7,19162,"GPC-19162"],[7,19153,"GPC-19153"],[7,19143,"GPC-19143"],[7,19104,"GPC-19104"],[7,19083,"GPC-19083"],[7,19061,"GPC-19061"],[7,19060,"GPC-19060"],[7,19044,"GPC-19044"],[7,19023,"GPC-19023"],[7,19009,"GPC-19009"],[7,18983,"GPC-18983"],[7,18968,"GPC-18968"],[7,18903,"GPC-18903"],[7,18703,"GPC-18703"],[7,18854,"GPC-18854"],[7,18828,"GPC-18828"],[7,18525,"GPC-18525"],[7,16975,"GPC-16975"],[7,16597,"GPC-16597"],[8,20071,"GPC-20071"],[9,19861,"GPC-19861"],[10,21067,"GPC-21067"],[10,22254,"GPC-22254"],[10,22586,"GPC-22586"],[11,23499,"GPC-23499"],[11,22929,"GPC-22929"],[11,21308,"GPC-21308"],[12,24688,"GPC-24688"],[12,24657,"GPC-24657"],[12,22689,"GPC-22689"],[12,22929,"GPC-22929"],[12,23778,"GPC-23778"],[12,24024,"GPC-24024"],[13,24709,"GPC-24709"],[13,24688,"GPC-24688"],[13,24657,"GPC-24657"],[13,24024,"GPC-24024"],[13,22689,"GPC-22689"],[13,22929,"GPC-22929"],[13,23778,"GPC-23778"],[13,23965,"GPC-23965"],[13,24117,"GPC-24117"],[13,24228,"GPC-24228"],[13,24937,"GPC-24937"],[13,24185,"GPC-24185"],[13,24053,"GPC-24053"],[14,25634,"GPC-25634"]]}
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
{"product":"GlobalProtect-Linux","releases":["6.0.3","6.0.5","6.1.1","6.1.3","6.1.4","6.1.5","6.2.0","6.2.1","6.2.6","6.2.7","6.2.8","6.3.3","6.3.3-h1"],"points":[[0,15458,"GPC-15458"],[1,16269,"GPC-16269"],[1,15539,"GPC-15539"],[1,14490,"GPC-14490"],[2,16324,"GPC-16324"],[2,16029,"GPC-16029"],[2,15989,"GPC-15989"],[2,15994,"GPC-15994"],[2,15972,"GPC-15972"],[2,15834,"GPC-15834"],[2,15677,"GPC-15677"],[2,15991,"GPC-15991"],[2,15534,"GPC-15534"],[2,15167,"GPC-15167"],[3,19336,"GPC-19336"],[3,18964,"GPC-18964"],[3,18913,"GPC-18913"],[3,18907,"GPC-18907"],[3,18822,"GPC-18822"],[3,18788,"GPC-18788"],[3,18733,"GPC-18733"],[3,18728,"GPC-18728"],[3,18720,"GPC-18720"],[3,18599,"GPC-18599"],[3,18598,"GPC-18598"],[3,18594,"GPC-18594"],[3,18566,"GPC-18566"],[3,18528,"GPC-18528"],[3,18512,"GPC-18512"],[3,18471,"GPC-18471"],[3,18426,"GPC-18426"],[3,18383,"GPC-18383"],[3,18379,"GPC-18379"],[3,18367,"GPC-18367"],[3,18336,"GPC-18336"],[3,18318,"GPC-18318"],[3,18251,"GPC-18251"],[3,18230,"GPC-18230"],[3,18223,"GPC-18223"],[3,18200,"GPC-18200"],[3,18173,"GPC-18173"],[3,18171,"GPC-18171"],[3,18167,"GPC-18167"],[3,18157,"GPC-18157"],[3,18155,"GPC-18155"],[3,18146,"GPC-18146"],[3,18135,"GPC-18135"],[3,18107,"GPC-18107"],[3,18092,"GPC-18092"],[3,18060,"GPC-18060"],[3,18039,"GPC-18039"],[3,17914,"GPC-17914"],[3,17896,"GPC-17896"],[3,17640,"GPC-17640"],[3,17518,"GPC-17518"],[3,17492,"GPC-17492"],[3,17204,"GPC-17204"],[3,17161,"GPC-17161"],[3,17001,"GPC-17001"],[3,16609,"GPC-16609"],[3,16597,"GPC-16597"],[3,16441,"GPC-16441"],[3,16397,"GPC-16397"],[3,15697,"GPC-15697"],[4,19340,"GPC-19340"],[4,19331,"GPC-19331"],[4,19289,"GPC-19289"],[4,19280,"GPC-19280"],[4,19193,"GPC-19193"],[4,19187,"GPC-19187"],[4,19162,"GPC-19162"],[4,19153,"GPC-19153"],[4,19143,"GPC-19143"],[4,19104,"GPC-19104"],[4,19083,"GPC-19083"],[4,19061,"GPC-19061"],[4,19060,"GPC-19060"],[4,19044,"GPC-19044"],[4,19023,"GPC-19023"],[4,19009,"GPC-19009"],[4,18983,"GPC-18983"],[4,18968,"GPC-18968"],[4,18903,"GPC-18903"],[4,18703,"GPC-18703"],[4,18854,"GPC-18854"],[4,18828,"GPC-18828"],[4,18525,"GPC-18525"],[4,16975,"GPC-16975"],[4,16597,"GPC-16597"],[5,20193,"GPC-20193"],[5,20124,"GPC-20124"],[5,19792,"GPC-19792"],[5,19748,"GPC-19748"],[5,19353,"GPC-19353"],[5,19297,"GPC-19297"],[5,18903,"GPC-18903"],[5,17378,"GPC-17378"],[6,19792,"GPC-19792"],[6,19748,"GPC-19748"],[6,19353,"GPC-19353"],[6,19143,"GPC-19143"],[6,18903,"GPC-18903"],[6,18820,"GPC-18820"],[6,18512,"GPC-18512"],[6,18155,"GPC-18155"],[6,17598,"GPC-17598"],[6,16980,"GPC-16980"],[7,21151,"GPC-21151"],[7,20945,"GPC-20945"],[7,20605,"GPC-20605"],[7,20574,"GPC-20574"],[7,20544,"GPC-20544"],[7,20525,"GPC-20525"],[7,20449,"GPC-20449"],[7,20398,"GPC-20398"],[7,20340,"GPC-20340"],[7,19973,"GPC-19973"],[7,19297,"GPC-19297"],[7,18105,"GPC-18105"],[7,17378,"GPC-17378"],[8,22950,"GPC-22950"],[8,22872,"GPC-22872"],[8,22774,"GPC-22774"],[8,22766,"GPC-22766"],[8,22390,"GPC-22390"],[8,22306,"GPC-22306"],[8,22249,"GPC-22249"],[8,22130,"GPC-22130"],[8,22051,"GPC-22051"],[8,21951,"GPC-21951"],[8,21920,"GPC-21920"],[8,21915,"GPC-21915"],[8,21861,"GPC-21861"],[8,21762,"GPC-21762"],[8,21724,"GPC-21724"],[8,21651,"GPC-21651"],[8,21092,"GPC-21092"],[8,21478,"GPC-21478"],[9,23208,"GPC-23208"],[10,23208,"GPC-23208"],[11,23773,"GPC-23773"],[11,23647,"GPC-23647"],[11,23452,"GPC-23452"],[11,23447,"GPC-23447"],[11,23405,"GPC-23405"],[11,23208,"GPC-23208"],[11,21049,"GPC-21049"],[12,24173,"GPC-24173"]]}
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
{"product":"GlobalProtect-iOS","releases":["6.0.2","6.0.4","6.0.5","6.0.6","6.0.12","6.1.0","6.1.4","6.1.5","6.1.6","6.1.7","6.1.9","6.1.10","6.1.11"],"points":[[0,15068,"GPC-15068"],[0,15023,"GPC-15023"],[1,15739,"GPC-15739"],[1,14941,"GPC-14941"],[1,14913,"GPC-14913"],[1,14686,"GPC-14686"],[2,16485,"GPC-16485"],[2,16149,"GPC-16149"],[2,15998,"GPC-15998"],[2,15984,"GPC-15984"],[2,15509,"GPC-15509"],[2,14952,"GPC-14952"],[3,16684,"GPC-16684"],[4,21389,"GPC-21389"],[4,21707,"GPC-21707"],[4,20071,"GPC-20071"],[5,19030,"GPC-19030"],[5,18672,"GPC-18672"],[5,18207,"GPC-18207"],[5,17875,"GPC-17875"],[5,17635,"GPC-17635"],[5,17435,"GPC-17435"],[5,16741,"GPC-16741"],[5,15137,"GPC-15137"],[6,19340,"GPC-19340"],[6,19331,"GPC-19331"],[6,19289,"GPC-19289"],[6,19280,"GPC-19280"],[6,19193,"GPC-19193"],[6,19187,"GPC-19187"],[6,19162,"GPC-19162"],[6,19153,"GPC-19153"],[6,19143,"GPC-19143"],[6,19104,"GPC-19104"],[6,19083,"GPC-19083"],[6,19061,"GPC-19061"],[6,19060,"GPC-19060"],[6,19044,"GPC-19044"],[6,19023,"GPC-19023"],[6,19009,"GPC-19009"],[6,18983,"GPC-18983"],[6,18968,"GPC-18968"],[6,18903,"GPC-18903"],[6,18703,"GPC-18703"],[6,18854,"GPC-18854"],[6,18828,"GPC-18828"],[6,18525,"GPC-18525"],[6,16975,"GPC-16975"],[6,16597,"GPC-16597"],[7,19631,"GPC-19631"],[8,21463,"GPC-21463"],[9,19861,"GPC-19861"],[10,23347,"GPC-23347"],[11,24688,"GPC-24688"],[11,24657,"GPC-24657"],[11,22689,"GPC-22689"],[11,22929,"GPC-22929"],[11,23778,"GPC-23778"],[11,24024,"GPC-24024"],[12,24709,"GPC-24709"],[12,24688,"GPC-24688"],[12,24657,"GPC-24657"],[12,24024,"GPC-24024"],[12,22689,"GPC-22689"],[12,22929,"GPC-22929"],[12,23778,"GPC-23778"],[12,23965,"GPC-23965"],[12,24117,"GPC-24117"],[12,24228,"GPC-24228"],[12,24937,"GPC-24937"],[12,24185,"GPC-24185"],[12,24053,"GPC-24053"]]}
|
||||||
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
@@ -0,0 +1 @@
|
|||||||
|
{"product":"Prisma Access Agent","releases":["25.3","25.3.1","25.4","25.6","25.6.2","25.7","25.7.1","26.1.1","26.1.2"],"points":[[0,6738,"PANG-6738"],[0,4616,"EPM-4616"],[1,7012,"PANG-7012"],[2,7949,"PANG-7949"],[2,7865,"PANG-7865"],[2,7960,"PANG-7960"],[2,7309,"PANG-7309"],[3,8845,"PANG-8845"],[3,8200,"PANG-8200"],[4,9620,"PANG-9620"],[4,9630,"PANG-9630"],[4,9276,"PANG-9276"],[4,9220,"PANG-9220"],[4,9092,"PANG-9092"],[4,9067,"PANG-9067"],[5,9242,"PANG-9242"],[5,8945,"PANG-8945"],[5,8929,"PANG-8929"],[6,9876,"PANG-9876"],[7,11201,"PANG-11201"],[7,11153,"PANG-11153"],[7,10723,"PANG-10723"],[8,11395,"PANG-11395"],[8,11328,"PANG-11328"],[8,11311,"PANG-11311"],[8,11293,"PANG-11293"],[8,11284,"PANG-11284"],[8,11274,"PANG-11274"]]}
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
[["GlobalProtect","GlobalProtect.json"],["GlobalProtect-Android","GlobalProtect-Android.json"],["GlobalProtect-iOS","GlobalProtect-iOS.json"],["GlobalProtect-Linux","GlobalProtect-Linux.json"],["PAN-OS","PAN-OS.json"],["Prisma Access Agent","Prisma-Access-Agent.json"]]
|
||||||
@@ -23,3 +23,231 @@ document.addEventListener('click', event => {
|
|||||||
if (expanded) collapseDescendants(row);
|
if (expanded) collapseDescendants(row);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const productSelect = document.getElementById('issue-plot-product');
|
||||||
|
const trainSelect = document.getElementById('issue-plot-train');
|
||||||
|
const canvas = document.getElementById('issue-plot-canvas');
|
||||||
|
const tooltip = document.getElementById('issue-plot-tooltip');
|
||||||
|
const summary = document.getElementById('issue-plot-summary');
|
||||||
|
const context = canvas?.getContext('2d');
|
||||||
|
const productCache = new Map();
|
||||||
|
let currentProduct = null;
|
||||||
|
let renderedPoints = [];
|
||||||
|
let hoveredPoint = null;
|
||||||
|
|
||||||
|
function releaseTrain(version) {
|
||||||
|
return version.match(/^\d+\.\d+/)?.[0] ?? version;
|
||||||
|
}
|
||||||
|
|
||||||
|
function fillSelect(select, options) {
|
||||||
|
select.replaceChildren(...options.map(value => {
|
||||||
|
const option = document.createElement('option');
|
||||||
|
option.value = value;
|
||||||
|
option.textContent = value;
|
||||||
|
return option;
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
|
async function loadProduct(filename) {
|
||||||
|
if (!productCache.has(filename)) {
|
||||||
|
productCache.set(filename, fetch(`data/statistics/${filename}`).then(response => {
|
||||||
|
if (!response.ok) throw new Error(`Unable to load graph data (${response.status})`);
|
||||||
|
return response.json();
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
return productCache.get(filename);
|
||||||
|
}
|
||||||
|
|
||||||
|
function plotColors() {
|
||||||
|
const styles = getComputedStyle(document.documentElement);
|
||||||
|
return {
|
||||||
|
text: styles.getPropertyValue('--text-muted').trim() || '#59636e',
|
||||||
|
grid: styles.getPropertyValue('--border').trim() || '#d0d7de',
|
||||||
|
point: styles.getPropertyValue('--link').trim() || '#0969da',
|
||||||
|
hover: styles.getPropertyValue('--focus').trim() || '#bf8700'
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatIssueNumber(value) {
|
||||||
|
return Number(value).toLocaleString('en-US');
|
||||||
|
}
|
||||||
|
|
||||||
|
function drawPlot() {
|
||||||
|
if (!context || !currentProduct) return;
|
||||||
|
const selectedTrain = trainSelect.value;
|
||||||
|
const releaseIndexes = currentProduct.releases
|
||||||
|
.map((version, index) => ({ version, index }))
|
||||||
|
.filter(item => releaseTrain(item.version) === selectedTrain);
|
||||||
|
const indexPositions = new Map(releaseIndexes.map((item, position) => [item.index, position]));
|
||||||
|
const points = currentProduct.points.filter(point => indexPositions.has(point[0]));
|
||||||
|
|
||||||
|
const bounds = canvas.getBoundingClientRect();
|
||||||
|
const ratio = window.devicePixelRatio || 1;
|
||||||
|
canvas.width = Math.max(1, Math.round(bounds.width * ratio));
|
||||||
|
canvas.height = Math.max(1, Math.round(bounds.height * ratio));
|
||||||
|
context.setTransform(ratio, 0, 0, ratio, 0, 0);
|
||||||
|
|
||||||
|
const width = bounds.width;
|
||||||
|
const height = bounds.height;
|
||||||
|
const margin = { top: 22, right: 18, bottom: 92, left: 76 };
|
||||||
|
const plotWidth = Math.max(1, width - margin.left - margin.right);
|
||||||
|
const plotHeight = Math.max(1, height - margin.top - margin.bottom);
|
||||||
|
const colors = plotColors();
|
||||||
|
const values = points.map(point => point[1]);
|
||||||
|
if (values.length === 0) {
|
||||||
|
context.clearRect(0, 0, bounds.width, bounds.height);
|
||||||
|
context.fillStyle = colors.text;
|
||||||
|
context.font = '14px system-ui, sans-serif';
|
||||||
|
context.textAlign = 'center';
|
||||||
|
context.fillText('No addressed Issue-IDs in this release train.', bounds.width / 2, bounds.height / 2);
|
||||||
|
renderedPoints = [];
|
||||||
|
summary.textContent = `0 addressed issues across ${releaseIndexes.length.toLocaleString('en-US')} releases.`;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const minimum = Math.min(...values);
|
||||||
|
const maximum = Math.max(...values);
|
||||||
|
const range = Math.max(1, maximum - minimum);
|
||||||
|
const yMin = Math.max(0, minimum - range * 0.04);
|
||||||
|
const yMax = maximum + range * 0.04;
|
||||||
|
const xFor = position => margin.left + (
|
||||||
|
releaseIndexes.length === 1 ? plotWidth / 2 : position * plotWidth / (releaseIndexes.length - 1)
|
||||||
|
);
|
||||||
|
const yFor = value => margin.top + (yMax - value) / (yMax - yMin) * plotHeight;
|
||||||
|
|
||||||
|
context.clearRect(0, 0, width, height);
|
||||||
|
context.font = '12px system-ui, sans-serif';
|
||||||
|
context.fillStyle = colors.text;
|
||||||
|
context.strokeStyle = colors.grid;
|
||||||
|
context.lineWidth = 1;
|
||||||
|
|
||||||
|
for (let tick = 0; tick <= 5; tick++) {
|
||||||
|
const y = margin.top + tick * plotHeight / 5;
|
||||||
|
const value = yMax - tick * (yMax - yMin) / 5;
|
||||||
|
context.beginPath();
|
||||||
|
context.moveTo(margin.left, y);
|
||||||
|
context.lineTo(width - margin.right, y);
|
||||||
|
context.stroke();
|
||||||
|
context.textAlign = 'right';
|
||||||
|
context.textBaseline = 'middle';
|
||||||
|
context.fillText(formatIssueNumber(Math.round(value)), margin.left - 9, y);
|
||||||
|
}
|
||||||
|
|
||||||
|
context.save();
|
||||||
|
context.translate(16, margin.top + plotHeight / 2);
|
||||||
|
context.rotate(-Math.PI / 2);
|
||||||
|
context.textAlign = 'center';
|
||||||
|
context.fillText('Issue-ID number', 0, 0);
|
||||||
|
context.restore();
|
||||||
|
|
||||||
|
releaseIndexes.forEach((item, position) => {
|
||||||
|
const x = xFor(position);
|
||||||
|
context.save();
|
||||||
|
context.translate(x, height - margin.bottom + 10);
|
||||||
|
context.rotate(-Math.PI / 3);
|
||||||
|
context.textAlign = 'right';
|
||||||
|
context.textBaseline = 'middle';
|
||||||
|
context.fillText(item.version, 0, 0);
|
||||||
|
context.restore();
|
||||||
|
});
|
||||||
|
|
||||||
|
const overlaps = new Map();
|
||||||
|
renderedPoints = points.map(point => {
|
||||||
|
const position = indexPositions.get(point[0]);
|
||||||
|
const overlapKey = `${point[0]}:${point[1]}`;
|
||||||
|
const overlap = overlaps.get(overlapKey) ?? 0;
|
||||||
|
overlaps.set(overlapKey, overlap + 1);
|
||||||
|
return {
|
||||||
|
x: xFor(position) + (overlap % 3 - 1) * 4,
|
||||||
|
y: yFor(point[1]) - Math.floor(overlap / 3) * 4,
|
||||||
|
id: point[2],
|
||||||
|
issueNumber: point[1],
|
||||||
|
release: currentProduct.releases[point[0]]
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
renderedPoints.forEach(point => {
|
||||||
|
context.beginPath();
|
||||||
|
const hovered = point.id === hoveredPoint?.id && point.release === hoveredPoint?.release;
|
||||||
|
context.arc(point.x, point.y, hovered ? 5 : 3, 0, Math.PI * 2);
|
||||||
|
context.fillStyle = hovered ? colors.hover : colors.point;
|
||||||
|
context.globalAlpha = hovered ? 1 : 0.72;
|
||||||
|
context.fill();
|
||||||
|
});
|
||||||
|
context.globalAlpha = 1;
|
||||||
|
summary.textContent = `${points.length.toLocaleString('en-US')} addressed issue${points.length === 1 ? '' : 's'} across ${releaseIndexes.length.toLocaleString('en-US')} release${releaseIndexes.length === 1 ? '' : 's'}.`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function pointNear(event) {
|
||||||
|
const bounds = canvas.getBoundingClientRect();
|
||||||
|
const x = event.clientX - bounds.left;
|
||||||
|
const y = event.clientY - bounds.top;
|
||||||
|
return renderedPoints.reduce((nearest, point) => {
|
||||||
|
const distance = Math.hypot(point.x - x, point.y - y);
|
||||||
|
return distance <= 9 && (!nearest || distance < nearest.distance) ? { point, distance } : nearest;
|
||||||
|
}, null)?.point ?? null;
|
||||||
|
}
|
||||||
|
|
||||||
|
canvas?.addEventListener('pointermove', event => {
|
||||||
|
const point = pointNear(event);
|
||||||
|
if (point === hoveredPoint) return;
|
||||||
|
hoveredPoint = point;
|
||||||
|
canvas.style.cursor = point ? 'pointer' : 'default';
|
||||||
|
if (point) {
|
||||||
|
tooltip.textContent = `${point.id} · ${point.release}`;
|
||||||
|
tooltip.hidden = false;
|
||||||
|
const frame = canvas.parentElement.getBoundingClientRect();
|
||||||
|
tooltip.style.left = `${Math.min(event.clientX - frame.left + 12, frame.width - tooltip.offsetWidth - 8)}px`;
|
||||||
|
tooltip.style.top = `${Math.max(8, event.clientY - frame.top - tooltip.offsetHeight - 10)}px`;
|
||||||
|
} else {
|
||||||
|
tooltip.hidden = true;
|
||||||
|
}
|
||||||
|
drawPlot();
|
||||||
|
});
|
||||||
|
|
||||||
|
canvas?.addEventListener('pointerleave', () => {
|
||||||
|
hoveredPoint = null;
|
||||||
|
tooltip.hidden = true;
|
||||||
|
drawPlot();
|
||||||
|
});
|
||||||
|
|
||||||
|
canvas?.addEventListener('click', event => {
|
||||||
|
const point = pointNear(event);
|
||||||
|
if (point) window.location.href = `index.html?issue=${encodeURIComponent(point.id)}`;
|
||||||
|
});
|
||||||
|
|
||||||
|
trainSelect?.addEventListener('change', () => {
|
||||||
|
hoveredPoint = null;
|
||||||
|
tooltip.hidden = true;
|
||||||
|
drawPlot();
|
||||||
|
});
|
||||||
|
|
||||||
|
productSelect?.addEventListener('change', async () => {
|
||||||
|
try {
|
||||||
|
currentProduct = await loadProduct(productSelect.value);
|
||||||
|
fillSelect(trainSelect, Array.from(new Set(currentProduct.releases.map(releaseTrain))).reverse());
|
||||||
|
drawPlot();
|
||||||
|
} catch (error) {
|
||||||
|
summary.textContent = error.message;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
if (canvas) {
|
||||||
|
new ResizeObserver(drawPlot).observe(canvas);
|
||||||
|
fetch('data/statistics/products.json')
|
||||||
|
.then(response => {
|
||||||
|
if (!response.ok) throw new Error(`Unable to load graph products (${response.status})`);
|
||||||
|
return response.json();
|
||||||
|
})
|
||||||
|
.then(products => {
|
||||||
|
productSelect.replaceChildren(...products.map(([product, filename]) => {
|
||||||
|
const option = document.createElement('option');
|
||||||
|
option.value = filename;
|
||||||
|
option.textContent = product;
|
||||||
|
return option;
|
||||||
|
}));
|
||||||
|
productSelect.dispatchEvent(new Event('change'));
|
||||||
|
})
|
||||||
|
.catch(error => {
|
||||||
|
summary.textContent = error.message;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|||||||
@@ -51,6 +51,11 @@ export function applyIssueSearchFilter() {
|
|||||||
|
|
||||||
function initializeIssueSearch() {
|
function initializeIssueSearch() {
|
||||||
const searchInput = document.getElementById('issue-search');
|
const searchInput = document.getElementById('issue-search');
|
||||||
|
const initialQuery = new URLSearchParams(window.location.search).get('issue');
|
||||||
|
if (initialQuery) {
|
||||||
|
searchInput.value = initialQuery;
|
||||||
|
issueSearchTerms = initialQuery.trim().toLowerCase().split(/\s+/).filter(Boolean);
|
||||||
|
}
|
||||||
searchInput.addEventListener('input', () => {
|
searchInput.addEventListener('input', () => {
|
||||||
issueSearchTerms = searchInput.value.trim().toLowerCase().split(/\s+/).filter(Boolean);
|
issueSearchTerms = searchInput.value.trim().toLowerCase().split(/\s+/).filter(Boolean);
|
||||||
applyIssueSearchFilter();
|
applyIssueSearchFilter();
|
||||||
|
|||||||
+22
-1
@@ -21,7 +21,28 @@
|
|||||||
</nav>
|
</nav>
|
||||||
</header>
|
</header>
|
||||||
<main class="statistics-container">
|
<main class="statistics-container">
|
||||||
<p class="statistics-intro">Unique releases represented in the issue data. Major and minor rows include all releases beneath them; patch rows count hotfix releases.</p>
|
<p class="statistics-intro">Explore addressed Issue-IDs by release, followed by counts of the releases represented in the issue data.</p>
|
||||||
|
<section class="issue-plot" aria-labelledby="issue-plot-heading">
|
||||||
|
<div class="issue-plot-heading">
|
||||||
|
<div>
|
||||||
|
<h2 id="issue-plot-heading">Addressed issues by release</h2>
|
||||||
|
<p>Each dot is an Issue-ID. Hover for details or select a dot to find that issue.</p>
|
||||||
|
</div>
|
||||||
|
<div class="issue-plot-controls">
|
||||||
|
<label>Product
|
||||||
|
<select id="issue-plot-product"></select>
|
||||||
|
</label>
|
||||||
|
<label>Release train
|
||||||
|
<select id="issue-plot-train"></select>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="issue-plot-frame">
|
||||||
|
<canvas id="issue-plot-canvas" role="img" aria-label="Scatter plot of addressed Issue-IDs by release"></canvas>
|
||||||
|
<div id="issue-plot-tooltip" class="issue-plot-tooltip" role="status" hidden></div>
|
||||||
|
</div>
|
||||||
|
<p id="issue-plot-summary" class="issue-plot-summary" aria-live="polite"></p>
|
||||||
|
</section>
|
||||||
<section class="stats-product">
|
<section class="stats-product">
|
||||||
<h2>GlobalProtect <span class="stats-product-total">50 releases</span></h2>
|
<h2>GlobalProtect <span class="stats-product-total">50 releases</span></h2>
|
||||||
<div class="stats-table-wrap">
|
<div class="stats-table-wrap">
|
||||||
|
|||||||
@@ -279,6 +279,87 @@ li:hover {
|
|||||||
line-height: 1.5;
|
line-height: 1.5;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.issue-plot {
|
||||||
|
margin: 0 0 42px;
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
border-radius: 6px;
|
||||||
|
padding: 20px;
|
||||||
|
background: var(--surface);
|
||||||
|
}
|
||||||
|
|
||||||
|
.issue-plot-heading {
|
||||||
|
display: flex;
|
||||||
|
align-items: flex-end;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: 24px;
|
||||||
|
margin-bottom: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.issue-plot-heading h2,
|
||||||
|
.issue-plot-heading p,
|
||||||
|
.issue-plot-summary {
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.issue-plot-heading p,
|
||||||
|
.issue-plot-summary {
|
||||||
|
color: var(--text-muted);
|
||||||
|
font-size: 0.9rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.issue-plot-controls {
|
||||||
|
display: flex;
|
||||||
|
flex: 0 0 auto;
|
||||||
|
gap: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.issue-plot-controls label {
|
||||||
|
color: var(--text-muted);
|
||||||
|
font-size: 0.78rem;
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
|
||||||
|
.issue-plot-controls select {
|
||||||
|
display: block;
|
||||||
|
min-width: 150px;
|
||||||
|
margin-top: 4px;
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
border-radius: 4px;
|
||||||
|
padding: 7px 28px 7px 9px;
|
||||||
|
background: var(--surface);
|
||||||
|
color: var(--text);
|
||||||
|
font: inherit;
|
||||||
|
}
|
||||||
|
|
||||||
|
.issue-plot-frame {
|
||||||
|
position: relative;
|
||||||
|
min-height: 460px;
|
||||||
|
}
|
||||||
|
|
||||||
|
#issue-plot-canvas {
|
||||||
|
display: block;
|
||||||
|
width: 100%;
|
||||||
|
height: 460px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.issue-plot-tooltip {
|
||||||
|
position: absolute;
|
||||||
|
z-index: 1;
|
||||||
|
pointer-events: none;
|
||||||
|
border-radius: 4px;
|
||||||
|
padding: 6px 9px;
|
||||||
|
background: var(--header);
|
||||||
|
color: var(--header-text);
|
||||||
|
font-size: 0.82rem;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.issue-plot-summary {
|
||||||
|
min-height: 1.3em;
|
||||||
|
margin-top: 8px;
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
|
||||||
.stats-product {
|
.stats-product {
|
||||||
margin-top: 32px;
|
margin-top: 32px;
|
||||||
}
|
}
|
||||||
@@ -418,6 +499,21 @@ li:hover {
|
|||||||
gap: 3px;
|
gap: 3px;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.issue-plot {
|
||||||
|
padding: 14px 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.issue-plot-heading,
|
||||||
|
.issue-plot-controls {
|
||||||
|
align-items: stretch;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.issue-plot-controls select {
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
.stats-table th,
|
.stats-table th,
|
||||||
.stats-table td {
|
.stats-table td {
|
||||||
padding: 8px 10px;
|
padding: 8px 10px;
|
||||||
|
|||||||
Reference in New Issue
Block a user