diff --git a/analytics/analytics_package/analytics/static_site/template/index.html b/analytics/analytics_package/analytics/static_site/template/index.html
index fd3541a86..14dde9257 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; }
}
@@ -362,10 +364,33 @@
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 (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';
+ // 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();
+ 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}`;
+ 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 = {
@@ -384,7 +409,7 @@
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 +421,7 @@
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 +431,7 @@
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 +443,13 @@
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 +908,41 @@