diff --git a/scripts/generate-release-statistics.mjs b/scripts/generate-release-statistics.mjs index beab311..c5c69c1 100644 --- a/scripts/generate-release-statistics.mjs +++ b/scripts/generate-release-statistics.mjs @@ -196,7 +196,7 @@ export function renderStatisticsPage(statistics) {
-

Explore addressed Issue-IDs by release, followed by counts of the releases represented in the issue data.

+

Explore addressed Issue-IDs by product and release.

@@ -218,7 +218,6 @@ export function renderStatisticsPage(statistics) {

-${statistics.map(renderProduct).join('\n')}
diff --git a/test/checkbox-tree.test.mjs b/test/checkbox-tree.test.mjs index 27f8ba8..cdb2768 100644 --- a/test/checkbox-tree.test.mjs +++ b/test/checkbox-tree.test.mjs @@ -9,6 +9,7 @@ globalThis.Element = dom.window.Element; globalThis.localStorage = dom.window.localStorage; const { CheckboxTree } = await import('../web/vendor/checkbox-tree/checkbox-tree.js'); +const { findAddressedReleaseNode } = await import('../web/js/tree.js'); function createTree(options = {}) { const container = document.createElement('div'); @@ -103,3 +104,14 @@ test('rejects duplicate node ids', () => { /Duplicate CheckboxTree node id/ ); }); + +test('finds an addressed release leaf by product and exact filename', () => { + const nodes = [ + { id: 'known', metadata: { path: ['PAN-OS'], addressed: [] } }, + { id: 'release', metadata: { path: ['PAN-OS', '11', '11.1'], addressed: ['11.1.13-h3.md'] } }, + { id: 'other', metadata: { path: ['GlobalProtect'], addressed: ['11.1.13-h3.md'] } } + ]; + + assert.equal(findAddressedReleaseNode(nodes, 'PAN-OS', '11.1.13-h3')?.id, 'release'); + assert.equal(findAddressedReleaseNode(nodes, 'PAN-OS', '11.1.13'), undefined); +}); diff --git a/test/release-statistics.test.mjs b/test/release-statistics.test.mjs index 18ecf5f..c742a58 100644 --- a/test/release-statistics.test.mjs +++ b/test/release-statistics.test.mjs @@ -56,26 +56,12 @@ test('counts unique releases at major and minor levels and hotfixes at patch lev ); }); -test('renders the nested levels into a static HTML page', () => { +test('renders the graph without displaying the legacy release tables', () => { 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, /id="issue-plot-canvas"/); - 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 console.error('Error loading products:', error)); diff --git a/web/js/statistics.js b/web/js/statistics.js index 42e7fb3..df8d153 100644 --- a/web/js/statistics.js +++ b/web/js/statistics.js @@ -34,6 +34,10 @@ const productCache = new Map(); let currentProduct = null; let renderedPoints = []; let hoveredPoint = null; +const initialParams = new URLSearchParams(window.location.search); +const initialProduct = initialParams.get('product') || 'PAN-OS'; +const initialTrain = initialParams.get('train') || '11.1'; +let restoreInitialSelection = true; function releaseTrain(version) { return version.match(/^\d+\.\d+/)?.[0] ?? version; @@ -48,6 +52,14 @@ function fillSelect(select, options) { })); } +function updatePlotQuery() { + if (!currentProduct || !trainSelect.value) return; + const url = new URL(window.location.href); + url.searchParams.set('product', currentProduct.product); + url.searchParams.set('train', trainSelect.value); + history.replaceState(null, '', url); +} + async function loadProduct(filename) { if (!productCache.has(filename)) { productCache.set(filename, fetch(`data/statistics/${filename}`).then(response => { @@ -167,7 +179,7 @@ function drawPlot() { renderedPoints.forEach(point => { context.beginPath(); - const hovered = point.id === hoveredPoint?.id && point.release === hoveredPoint?.release; + const hovered = point.issueNumber === hoveredPoint?.issueNumber; 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; @@ -212,12 +224,20 @@ canvas?.addEventListener('pointerleave', () => { canvas?.addEventListener('click', event => { const point = pointNear(event); - if (point) window.location.href = `index.html?issue=${encodeURIComponent(point.id)}`; + if (point) { + const params = new URLSearchParams({ + issue: point.id, + product: currentProduct.product, + release: point.release + }); + window.location.href = `index.html?${params}`; + } }); trainSelect?.addEventListener('change', () => { hoveredPoint = null; tooltip.hidden = true; + updatePlotQuery(); drawPlot(); }); @@ -225,6 +245,12 @@ productSelect?.addEventListener('change', async () => { try { currentProduct = await loadProduct(productSelect.value); fillSelect(trainSelect, Array.from(new Set(currentProduct.releases.map(releaseTrain))).reverse()); + if (restoreInitialSelection && initialProduct === currentProduct.product && initialTrain && + Array.from(trainSelect.options).some(option => option.value === initialTrain)) { + trainSelect.value = initialTrain; + } + restoreInitialSelection = false; + updatePlotQuery(); drawPlot(); } catch (error) { summary.textContent = error.message; @@ -245,6 +271,8 @@ if (canvas) { option.textContent = product; return option; })); + const requestedProduct = products.find(([product]) => product === initialProduct); + if (requestedProduct) productSelect.value = requestedProduct[1]; productSelect.dispatchEvent(new Event('change')); }) .catch(error => { diff --git a/web/js/tree.js b/web/js/tree.js index 9b8d0b8..0f23e1a 100644 --- a/web/js/tree.js +++ b/web/js/tree.js @@ -62,6 +62,24 @@ export function getCheckedFileRefs() { }; } +export function selectAddressedRelease(product, release) { + if (!productTree || !product || !release) return false; + const node = findAddressedReleaseNode(productTree.nodesById.values(), product, release); + if (!node) return false; + + productTree.setSelectedIds([node.id]); + pushTreeState(); + return true; +} + +export function findAddressedReleaseNode(nodes, product, release) { + const filename = `${release}.md`; + return Array.from(nodes).find(candidate => + candidate.metadata?.path?.[0] === product && + candidate.metadata?.addressed?.includes(filename) + ); +} + // State restoration now happens when setData renders the generic tree. export function restoreTreeState() {} diff --git a/web/js/ui.js b/web/js/ui.js index 691d58d..a05fc1c 100644 --- a/web/js/ui.js +++ b/web/js/ui.js @@ -65,6 +65,10 @@ function initializeIssueSearch() { function initializeIssueTypeFilters(onSelectionChange) { const addressedFilter = document.getElementById('filter-addressed'); const knownFilter = document.getElementById('filter-known'); + if (new URLSearchParams(window.location.search).has('release')) { + issueTypeFilters.addressed = true; + saveTypeFilters(); + } // Restore checkbox state from persisted filters addressedFilter.checked = issueTypeFilters.addressed; diff --git a/web/statistics.html b/web/statistics.html index 311b9f9..44d4571 100644 --- a/web/statistics.html +++ b/web/statistics.html @@ -21,7 +21,7 @@
-

Explore addressed Issue-IDs by release, followed by counts of the releases represented in the issue data.

+

Explore addressed Issue-IDs by product and release.

@@ -43,331 +43,6 @@

-
-

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
-
-