Files
aaron.axvig cb6ce0b3c5
Test and deploy / deploy (push) Successful in 30s
Add option to show known issues scatterplot
2026-07-29 13:12:46 -05:00

321 lines
12 KiB
JavaScript

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);
});
});
const productSelect = document.getElementById('issue-plot-product');
const trainSelect = document.getElementById('issue-plot-train');
const typeInputs = Array.from(document.querySelectorAll('input[name="issue-plot-type"]'));
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;
const initialParams = new URLSearchParams(window.location.search);
const initialProduct = initialParams.get('product') || 'PAN-OS';
const initialTrain = initialParams.get('train') || '11.1';
const initialType = initialParams.get('type') === 'known' ? 'known' : 'addressed';
let restoreInitialSelection = true;
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;
}));
}
function selectedIssueType() {
return typeInputs.find(input => input.checked)?.value ?? 'addressed';
}
function currentSeries() {
return currentProduct?.[selectedIssueType()] ?? { releases: [], points: [] };
}
function refreshTrainOptions(preferredTrain = trainSelect.value) {
const trains = Array.from(new Set(currentSeries().releases.map(releaseTrain))).reverse();
fillSelect(trainSelect, trains);
if (trains.includes(preferredTrain)) trainSelect.value = preferredTrain;
}
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);
url.searchParams.set('type', selectedIssueType());
history.replaceState(null, '', url);
}
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',
base: styles.getPropertyValue('--plot-base').trim() || '#2e8b57',
hotfix: styles.getPropertyValue('--plot-hotfix').trim() || '#2c7be5',
resolved: styles.getPropertyValue('--plot-resolved').trim() || '#8b5cf6',
hover: styles.getPropertyValue('--plot-hover').trim() || '#d97706'
};
}
function formatIssueNumber(value) {
return Number(value).toLocaleString('en-US');
}
function drawPlot() {
if (!context || !currentProduct) return;
const issueType = selectedIssueType();
const series = currentSeries();
const selectedTrain = trainSelect.value;
const releaseIndexes = series.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 = series.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 ${issueType} Issue-IDs in this release train.`, bounds.width / 2, bounds.height / 2);
renderedPoints = [];
summary.textContent = `0 ${issueType} 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 = 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: series.releases[point[0]],
resolved: Boolean(point[3])
};
});
renderedPoints.forEach(point => {
context.beginPath();
const hovered = point.issueNumber === hoveredPoint?.issueNumber;
context.arc(point.x, point.y, hovered ? 5 : 3, 0, Math.PI * 2);
context.fillStyle = hovered
? colors.hover
: (point.resolved
? colors.resolved
: (/-h\d+(?:\D|$)/i.test(point.release) ? colors.hotfix : colors.base));
context.globalAlpha = hovered ? 1 : 0.72;
context.fill();
});
context.globalAlpha = 1;
summary.textContent = `${points.length.toLocaleString('en-US')} ${issueType} 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}${point.resolved ? ' · resolved' : ''}`;
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) {
const params = new URLSearchParams({
issue: point.id,
product: currentProduct.product,
release: point.release,
type: selectedIssueType()
});
window.location.href = `index.html?${params}`;
}
});
trainSelect?.addEventListener('change', () => {
hoveredPoint = null;
tooltip.hidden = true;
updatePlotQuery();
drawPlot();
});
typeInputs.forEach(input => {
input.checked = input.value === initialType;
input.addEventListener('change', () => {
if (!input.checked || !currentProduct) return;
hoveredPoint = null;
tooltip.hidden = true;
refreshTrainOptions();
updatePlotQuery();
drawPlot();
});
});
productSelect?.addEventListener('change', async () => {
try {
currentProduct = await loadProduct(productSelect.value);
refreshTrainOptions(restoreInitialSelection ? initialTrain : '');
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;
}
});
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;
}));
const requestedProduct = products.find(([product]) => product === initialProduct);
if (requestedProduct) productSelect.value = requestedProduct[1];
productSelect.dispatchEvent(new Event('change'));
})
.catch(error => {
summary.textContent = error.message;
});
}