From e287afe959ae92f3d19b41436cf6694cbf284763 Mon Sep 17 00:00:00 2001 From: Fran McDade <18710366+frano-m@users.noreply.github.com> Date: Tue, 18 Aug 2026 12:48:10 +1000 Subject: [PATCH 1/3] fix: generalize access-request service classification and stat cards in analytics template (#4922) Co-Authored-By: Claude Fable 5 --- .../analytics/static_site/template/index.html | 88 +++++++++++-------- 1 file changed, 49 insertions(+), 39 deletions(-) diff --git a/analytics/analytics_package/analytics/static_site/template/index.html b/analytics/analytics_package/analytics/static_site/template/index.html index fd3541a86..0b2655ae6 100644 --- a/analytics/analytics_package/analytics/static_site/template/index.html +++ b/analytics/analytics_package/analytics/static_site/template/index.html @@ -149,6 +149,8 @@ @media (max-width: 767px) { .fui-card[class*="fui-grid-item"] { grid-column: 1 / -1; } + /* !important: beats the inline grid-template-columns set per-render */ + #access-requests-stats { grid-template-columns: 1fr !important; } } @@ -363,9 +365,20 @@

Filter Selections

let entityPath = '/datasets'; function serviceName(url) { - if (url.includes('duos.org')) return 'DUOS'; - if (url.includes('dbgap.ncbi.nlm.nih.gov')) return 'dbGaP'; - return url; + if (typeof url !== 'string' || !url) return 'Other'; + // Match case-insensitively — the Python generator filters click URLs + // with case=False (fetch.py), so classification must be no stricter. + const normalizedUrl = url.toLowerCase(); + if (normalizedUrl.includes('duos.org')) return 'DUOS'; + if (normalizedUrl.includes('dbgap.ncbi.nlm.nih.gov')) return 'dbGaP'; + try { + // Tolerate schemeless URLs, and strip "www." so host variants + // of one service land in the same bucket. + const withScheme = normalizedUrl.includes('://') ? normalizedUrl : `https://${normalizedUrl}`; + return new URL(withScheme).hostname.replace(/^www\./, '') || 'Other'; + } catch (e) { + return 'Other'; + } } let chartColors = { @@ -384,7 +397,7 @@

Filter Selections

async function loadData() { try { - const [configRes, trafficRes, pageviewsRes, outboundRes, filtersRes, downloadsRes, eventsRes, metaRes, searchRes, fileDownloadEventsRes, eventChartsRes] = await Promise.all([ + const [configRes, trafficRes, pageviewsRes, outboundRes, filtersRes, downloadsRes, eventsRes, metaRes, searchRes, fileDownloadEventsRes, eventChartsRes, accessRequestsRes] = await Promise.all([ fetch('data/config.json'), fetch('data/monthly_traffic.json'), fetch('data/pageviews.json'), @@ -396,6 +409,7 @@

Filter Selections

fetch('data/search_queries.json').catch(() => null), fetch('data/file_download_events.json').catch(() => null), fetch('data/event_charts.json').catch(() => null), + fetch('data/access_requests.json').catch(() => null), ]); const [config, traffic, pageviews, outbound, filters, downloads, events, meta] = await Promise.all([ @@ -405,6 +419,7 @@

Filter Selections

const searchQueries = searchRes ? await searchRes.json().catch(() => null) : null; const fileDownloadEvents = fileDownloadEventsRes ? await fileDownloadEventsRes.json().catch(() => null) : null; const eventCharts = eventChartsRes && eventChartsRes.ok ? await eventChartsRes.json().catch(() => null) : null; + const accessRequests = accessRequestsRes && accessRequestsRes.ok ? await accessRequestsRes.json().catch(() => null) : null; applyConfig(config); renderMeta(meta); @@ -416,7 +431,13 @@

Filter Selections

renderOutboundTable(outbound); renderFiltersTable(filters); renderFileDownloadsTable(downloads); - renderAccessRequestsTable(); + try { + // Access requests are optional/best-effort — a bad payload + // must not blank the rest of the report. + renderAccessRequestsTable(accessRequests); + } catch (accessRequestsError) { + console.error('Error rendering access requests:', accessRequestsError); + } renderSearchQueries(searchQueries); renderFileDownloadEvents(fileDownloadEvents); renderEventCounts(config, events, eventCharts); @@ -875,50 +896,39 @@

${escapeHtml(event.label)} document.getElementById('file-downloads-count').textContent = formatNumber(downloads.total); } - async function renderAccessRequestsTable() { - let data; - try { - const res = await fetch('data/access_requests.json'); - if (!res.ok) return; - data = await res.json(); - } catch (e) { - return; - } - if (!data || data.length === 0) return; + function renderAccessRequestsTable(accessRequests) { + if (!Array.isArray(accessRequests)) return; + const data = accessRequests + .filter(r => r && typeof r === 'object') + .map(r => ({ ...r, count: Number(r.count) || 0, service: serviceName(r.click_url) })); + if (data.length === 0) return; const card = document.getElementById('access-requests-card'); const heading = document.getElementById('access-requests-heading'); const tbody = document.querySelector('#access-requests-table tbody'); - const total = data.reduce((sum, r) => sum + (r.count || 0), 0); - const services = new Set(data.map(r => serviceName(r.click_url || ''))); - const showService = services.size > 1; + const countsByService = data.reduce((counts, r) => { + counts[r.service] = (counts[r.service] || 0) + r.count; + return counts; + }, {}); + const total = Object.values(countsByService).reduce((sum, count) => sum + count, 0); + const showService = Object.keys(countsByService).length > 1; const statsGrid = document.getElementById('access-requests-stats'); if (showService) { - const duosTotal = data.filter(r => serviceName(r.click_url || '') === 'DUOS').reduce((sum, r) => sum + (r.count || 0), 0); - const dbgapTotal = data.filter(r => serviceName(r.click_url || '') === 'dbGaP').reduce((sum, r) => sum + (r.count || 0), 0); + const statCards = Object.entries(countsByService) + .sort(([a], [b]) => a.localeCompare(b)) + .concat([['Total', total]]); statsGrid.style.display = ''; - statsGrid.innerHTML = ` -
+ statsGrid.style.gridTemplateColumns = `repeat(${statCards.length}, 1fr)`; + statsGrid.innerHTML = statCards.map(([label, count]) => ` +
-
${formatNumber(dbgapTotal)}
-
dbGaP
+
${formatNumber(count)}
+
${escapeHtml(label)}
-
-
-
${formatNumber(duosTotal)}
-
DUOS
-
-
-
-
-
${formatNumber(total)}
-
Total
-
-
- `; + `).join(''); } card.style.display = ''; @@ -941,12 +951,12 @@

${escapeHtml(event.label)} const datasetCell = link ? `${escapeHtml(label)}` : escapeHtml(label); - const serviceCell = showService ? `${escapeHtml(serviceName(row.click_url || ''))}` : ''; + const serviceCell = showService ? `${escapeHtml(row.service)}` : ''; return ` ${datasetCell} ${serviceCell} - ${formatNumber(row.count || 0)} + ${formatNumber(row.count)} `; }).join(''); From 8a17705d8bb37b2dbd611dca81e094d196bb0fb4 Mon Sep 17 00:00:00 2001 From: Fran McDade <18710366+frano-m@users.noreply.github.com> Date: Tue, 18 Aug 2026 12:54:08 +1000 Subject: [PATCH 2/3] fix: use null-prototype accumulator for access-request service counts (#4922) Co-Authored-By: Claude Fable 5 --- .../analytics/static_site/template/index.html | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/analytics/analytics_package/analytics/static_site/template/index.html b/analytics/analytics_package/analytics/static_site/template/index.html index 0b2655ae6..f79a91e6c 100644 --- a/analytics/analytics_package/analytics/static_site/template/index.html +++ b/analytics/analytics_package/analytics/static_site/template/index.html @@ -907,10 +907,12 @@

${escapeHtml(event.label)} const heading = document.getElementById('access-requests-heading'); const tbody = document.querySelector('#access-requests-table tbody'); + // Null prototype: a service named like an Object.prototype member + // (e.g. a "constructor" hostname) must not collide with inherited keys. const countsByService = data.reduce((counts, r) => { counts[r.service] = (counts[r.service] || 0) + r.count; return counts; - }, {}); + }, Object.create(null)); const total = Object.values(countsByService).reduce((sum, count) => sum + count, 0); const showService = Object.keys(countsByService).length > 1; From 4830ff3a691b0dd4bd8210e78aeb7e932b16d49e Mon Sep 17 00:00:00 2001 From: Fran McDade <18710366+frano-m@users.noreply.github.com> Date: Tue, 18 Aug 2026 15:14:41 +1000 Subject: [PATCH 3/3] fix: classify access-request services by parsed hostname (#4922) Co-Authored-By: Claude Fable 5 --- .../analytics/static_site/template/index.html | 22 ++++++++++++++----- 1 file changed, 17 insertions(+), 5 deletions(-) diff --git a/analytics/analytics_package/analytics/static_site/template/index.html b/analytics/analytics_package/analytics/static_site/template/index.html index f79a91e6c..14dde9257 100644 --- a/analytics/analytics_package/analytics/static_site/template/index.html +++ b/analytics/analytics_package/analytics/static_site/template/index.html @@ -364,21 +364,33 @@

Filter Selections

let entityLabel = 'Dataset'; let entityPath = '/datasets'; + // True when hostname is domain itself or one of its subdomains, so that + // "notduos.org.example.com" and "evil.com/?ref=duos.org" don't match. + function isHost(hostname, domain) { + return hostname === domain || hostname.endsWith(`.${domain}`); + } + function serviceName(url) { if (typeof url !== 'string' || !url) return 'Other'; - // Match case-insensitively — the Python generator filters click URLs - // with case=False (fetch.py), so classification must be no stricter. + // Classify on the parsed hostname, not the raw URL. Matching is + // case-insensitive — the Python generator filters click URLs with + // case=False (fetch.py), so classification must be no stricter. const normalizedUrl = url.toLowerCase(); - if (normalizedUrl.includes('duos.org')) return 'DUOS'; - if (normalizedUrl.includes('dbgap.ncbi.nlm.nih.gov')) return 'dbGaP'; + let hostname; try { // Tolerate schemeless URLs, and strip "www." so host variants // of one service land in the same bucket. const withScheme = normalizedUrl.includes('://') ? normalizedUrl : `https://${normalizedUrl}`; - return new URL(withScheme).hostname.replace(/^www\./, '') || 'Other'; + hostname = new URL(withScheme).hostname.replace(/^www\./, ''); } catch (e) { return 'Other'; } + // Require a dot so garbage ("not-a-url") buckets as Other rather + // than becoming its own service label. + if (!hostname.includes('.')) return 'Other'; + if (isHost(hostname, 'duos.org')) return 'DUOS'; + if (isHost(hostname, 'dbgap.ncbi.nlm.nih.gov')) return 'dbGaP'; + return hostname; } let chartColors = {