1/** 2 * Environment panels: geography (+ the world map), platform/device breakdown, and the 3 * live realtime panel. 4 * 5 * Mixin: Object.assign'd onto the `AnalyticsDashboard` singleton in ./index.js, so `this` 6 * is that singleton exactly as before the split. 7 */ 8import { api } from '../../data/api.js'; 9import { logger } from '../../core/logger.js'; 10import { t } from '../../locales.js'; 11import { REALTIME_MS } from './constants.js'; 12import { 13 el, 14 esc, 15 fmtNum, 16 fmtDuration, 17 featureLabel, 18 osLabel, 19 countryFlag, 20 countryName, 21 isAuthFailure, 22} from './format.js'; 23import { 24 loadingHtml, 25 emptyHtml, 26 adminOnlyHtml, 27 errorHtml, 28 kpiCard, 29 card, 30 barList, 31 worldMapSvg, 32 donutChart, 33} from './charts.js'; 34 35const log = logger.child('analytics-dashboard'); 36 37export const analyticsPanelsTech = { 38 // ---- Geography ------------------------------------------------------------------------ 39 40 /** @param {HTMLElement} panel @param {number} token */ 41 async _renderGeo(panel, token) { 42 panel.innerHTML = loadingHtml(); 43 const r = await api.analytics.geo(this._range); 44 if (!this._fresh(token)) return; 45 if (isAuthFailure(r)) return void (panel.innerHTML = adminOnlyHtml()); 46 if (!r || r.success === false) return void (panel.innerHTML = errorHtml(r)); 47 48 const countries = /** @type {any[]} */ (Array.isArray(r.countries) ? r.countries : []); 49 if (!countries.length) { 50 panel.innerHTML = emptyHtml('fa-earth-americas', t('dashNoGeoData')); 51 return; 52 } 53 const cities = /** @type {any[]} */ (Array.isArray(r.cities) ? r.cities : []); 54 const maxSessions = Math.max(1, ...countries.map((c) => c.sessions || 0)); 55 const rows = countries 56 .map( 57 (c) => 58 `<tr><td class="dash-td-country">${countryFlag(c.country)} ${countryName(c.country)}</td>` + 59 `<td class="dash-td-bar"><span class="dash-bar-track"><span class="dash-bar-fill" style="width:${Math.max(2, Math.round(((c.sessions || 0) / maxSessions) * 100))}%"></span></span></td>` + 60 `<td class="dash-td-num">${fmtNum(c.sessions)}</td>` + 61 `<td class="dash-td-num">${fmtDuration(c.avgSessionLengthSec)}</td></tr>` 62 ) 63 .join(''); 64 const countryTable = card( 65 'dashPanelSessionsByCountry', 66 'fa-earth-americas', 67 `<div class="dash-table-wrap"><table class="dash-table"><thead><tr><th>${esc(t('dashThCountry'))}</th><th></th><th class="dash-td-num">${esc(t('dashThSessions'))}</th><th class="dash-td-num">${esc(t('dashThAvgLength'))}</th></tr></thead><tbody>${rows}</tbody></table></div>` 68 ); 69 70 // City drill-down (each already tagged with its country by the backend). 71 let cityTable = ''; 72 if (cities.length) { 73 const maxCity = Math.max(1, ...cities.map((c) => c.sessions || 0)); 74 const cityRows = cities 75 .map( 76 (c) => 77 `<tr><td>${esc(c.city)}</td>` + 78 `<td class="dash-td-country">${countryFlag(c.country)} ${countryName(c.country)}</td>` + 79 `<td class="dash-td-bar"><span class="dash-bar-track"><span class="dash-bar-fill" style="width:${Math.max(2, Math.round(((c.sessions || 0) / maxCity) * 100))}%"></span></span></td>` + 80 `<td class="dash-td-num">${fmtNum(c.sessions)}</td>` + 81 `<td class="dash-td-num">${fmtDuration(c.avgSessionLengthSec)}</td></tr>` 82 ) 83 .join(''); 84 cityTable = card( 85 'dashPanelTopCities', 86 'fa-city', 87 `<div class="dash-table-wrap"><table class="dash-table"><thead><tr><th>${esc(t('dashThCity'))}</th><th>${esc(t('dashThCountry'))}</th><th></th><th class="dash-td-num">${esc(t('dashThSessions'))}</th><th class="dash-td-num">${esc(t('dashThAvgLength'))}</th></tr></thead><tbody>${cityRows}</tbody></table></div>` 88 ); 89 } 90 91 panel.innerHTML = 92 card( 93 'dashPanelWorldMap', 94 'fa-earth-americas', 95 `<div class="dash-worldmap" id="dash-worldmap">${loadingHtml(t('dashLoadingMap'))}</div>` 96 ) + 97 countryTable + 98 cityTable + 99 `<p class="dash-note"><i class="fas fa-shield-halved" aria-hidden="true"></i> ${esc(t('dashGeoPrivacyNote', { k: 20 }))}</p>`; 100 // The map geometry (~120KB) is lazy-loaded only when the Geography tab is viewed. 101 this._renderWorldMap(countries, token); 102 }, 103 /** 104 * Fill the world-map mount with a choropleth shaded by sessions. Loads the generated 105 * geometry asset on demand. @param {any[]} countries @param {number} token 106 */ 107 async _renderWorldMap(countries, token) { 108 const mount = el('dash-worldmap'); 109 if (!mount) return; 110 let paths; 111 let viewBox; 112 try { 113 const mod = await import('../../data/world-geo.js'); 114 paths = mod.WORLD_PATHS;
115 viewBox = mod.WORLD_VIEWBOX; 116 } catch (err) { 117 log.warn('world-map asset failed to load', err); 118 mount.innerHTML = emptyHtml('fa-earth-americas', t('dashMapUnavailable')); 119 return; 120 } 121 // Bail if the user navigated away while the asset was loading. 122 if (token !== this._token || this._tab !== 'geo') return; 123 mount.innerHTML = worldMapSvg(countries, paths, viewBox); 124 }, 125 // ---- Platform & tech ------------------------------------------------------------------ 126 127 /** @param {HTMLElement} panel @param {number} token */ 128 async _renderPlatform(panel, token) { 129 panel.innerHTML = loadingHtml(); 130 const r = await api.analytics.platform(this._range); 131 if (!this._fresh(token)) return; 132 if (isAuthFailure(r)) return void (panel.innerHTML = adminOnlyHtml()); 133 if (!r || r.success === false) return void (panel.innerHTML = errorHtml(r)); 134 135 const platforms = /** @type {any[]} */ (Array.isArray(r.platforms) ? r.platforms : []); 136 const devices = /** @type {any[]} */ (Array.isArray(r.devices) ? r.devices : []); 137 const browsers = /** @type {any[]} */ (Array.isArray(r.browsers) ? r.browsers : []); 138 const operatingSystems = /** @type {any[]} */ ( 139 Array.isArray(r.operatingSystems) ? r.operatingSystems : [] 140 ); 141 const languages = /** @type {any[]} */ (Array.isArray(r.languages) ? r.languages : []); 142 143 const donut = card( 144 'dashPanelPlatform', 145 'fa-chart-pie', 146 donutChart( 147 platforms.map((x) => ({ label: esc(x.platform), value: x.sessions || 0 })), 148 t('dashDonutSessions') 149 ) 150 ); 151 /** Avg-session-length sub-label per slice (present on all Platform breakdowns now). */ 152 const withLen = (/** @type {string} */ label, /** @type {any} */ x) => ({ 153 label, 154 value: x.sessions || 0, 155 sub: 156 typeof x.avgSessionLengthSec === 'number' ? fmtDuration(x.avgSessionLengthSec) : '', 157 }); 158 const osCard = card( 159 'dashPanelOperatingSystem', 160 'fa-computer', 161 barList(operatingSystems.map((x) => withLen(osLabel(x.os), x))) 162 ); 163 const deviceCard = card( 164 'dashPanelDeviceCategory', 165 'fa-mobile-screen', 166 barList(devices.map((x) => withLen(esc(x.device), x))) 167 ); 168 const browserCard = card( 169 'dashPanelBrowser', 170 'fa-globe', 171 barList(browsers.map((x) => withLen(esc(x.browser), x))) 172 ); 173 const langCard = card( 174 'dashPanelLanguage', 175 'fa-language', 176 barList(languages.map((x) => withLen(esc(x.language), x))) 177 ); 178 panel.innerHTML = 179 '<div class="dash-grid-2">' + 180 donut + 181 osCard + 182 deviceCard + 183 browserCard + 184 langCard + 185 '</div>' + 186 `<p class="dash-note"><i class="fas fa-circle-info" aria-hidden="true"></i> ${esc(t('dashPlatformOsNote'))}</p>`; 187 }, 188 // ---- Real-time ------------------------------------------------------------------------ 189 190 /** @param {HTMLElement} panel @param {number} token @param {boolean} showLoading */ 191 async _renderRealtime(panel, token, showLoading) { 192 if (showLoading) panel.innerHTML = loadingHtml(t('dashLoadingLivePulse')); 193 const r = await api.analytics.realtime(); 194 // Realtime polls, so only bail on a truly stale token (tab switched away). 195 if (token !== this._token || this._tab !== 'realtime') return; 196 if (isAuthFailure(r)) return void (panel.innerHTML = adminOnlyHtml()); 197 if (!r || r.success === false) return void (panel.innerHTML = errorHtml(r)); 198 const topFeatures = /** @type {any[]} */ ( 199 Array.isArray(r.topFeatures) ? r.topFeatures : [] 200 ); 201 const topCard = topFeatures.length 202 ? card( 203 'dashPanelTopFeaturesNow', 204 'fa-fire', 205 barList( 206 topFeatures.map((f) => ({ 207 label: esc(featureLabel(f.feature)), 208 value: f.events || 0, 209 valueText: t('dashEvents', { n: fmtNum(f.events) }), 210 })) 211 ) 212 ) 213 : ''; 214 panel.innerHTML = 215 '<div class="kpi-grid kpi-grid-live">' + 216 kpiCard( 217 'dashKpiActiveNow', 218 `${fmtNum(r.activeNow)}
218 <i class="fas fa-circle dash-live-dot" aria-hidden="true"></i>`, 219 t('dashFootLast5min') 220 ) + 221 kpiCard('dashKpiEventsPerMin', fmtNum(r.eventsPerMin), t('dashFootLast5min')) + 222 '</div>' + 223 topCard + 224 `<p class="dash-note"><i class="fas fa-bolt" aria-hidden="true"></i> ${esc(t('dashRealtimeNote', { n: REALTIME_MS / 1000 }))}</p>`; 225 }, 226};
Line numbers count LF bytes from the start of the resource, as the search results do. Vendor segments are library code the classifier recognised; they are stored but not indexed. Bytes are shown as Latin1 characters, one per byte.