a cache for slack profile pictures and emojis
Something went wrong. Try again.
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766767768769770771772773774775776777778779780781782783784785786787788789790791792793794795796797798799800801802803804805806807808809810811812813814815816817818819820821822823824825826827828829830831832833834835836837838839840841842843844845846847848849850851852853854855856857858859860861862863864865866867868869870871872873874875876877878879880881882883884885886887888889890891892893894895896897898899900901902903904905906907908909910911912913914915916917918919920921922923924925926927928929930931932933934935936937938939940941942943944945946947948949950<!doctype html><html lang="en">
<head> <meta charset="UTF-8" /> <meta name="viewport" content="width=device-width, initial-scale=1.0" /> <title>Cachet Analytics Dashboard</title> <meta name="description" content="Analytics dashboard for Cachet - a Slack avatar and emoji caching proxy" /> <meta property="og:title" content="Cachet Analytics Dashboard" /> <meta property="og:description" content="Analytics dashboard for Cachet - a Slack avatar and emoji caching proxy" /> <meta property="og:image" content="https://l4.dunkirk.sh/i/FYiFpVbkvHMs.webp" /> <meta property="og:type" content="website" /> <meta name="twitter:card" content="summary_large_image" /> <meta name="twitter:title" content="Cachet Analytics Dashboard" /> <meta name="twitter:description" content="Analytics dashboard for Cachet - a Slack avatar and emoji caching proxy" /> <meta name="twitter:image" content="https://l4.dunkirk.sh/i/FYiFpVbkvHMs.webp" /> <script src="https://cdn.jsdelivr.net/npm/uplot@1.6.30/dist/uPlot.iife.min.js"></script> <link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/uplot@1.6.30/dist/uPlot.min.css"> <style> * { margin: 0; padding: 0; box-sizing: border-box; }
body { font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif; background: #0d1117; color: #c9d1d9; line-height: 1.6; }
.header { background: #161b22; padding: 1rem 2rem; border-bottom: 1px solid #30363d; margin-bottom: 2rem; display: flex; justify-content: space-between; align-items: center; flex-wrap: wrap; gap: 1rem; }
.header-left h1 { color: #f0f6fc; font-size: 1.5rem; font-weight: 600; margin-bottom: 0.25rem; }
.header-links { display: flex; gap: 1rem; }
.header-links a { color: #58a6ff; text-decoration: none; font-size: 0.875rem; }
.header-links a:hover { text-decoration: underline; }
.header-right { display: flex; gap: 0.5rem; border: none; padding: 0; margin: 0; min-width: 0; }
.chart-header { display: flex; justify-content: space-between; align-items: center; margin-bottom: 1rem; }
.chart-legend { display: flex; gap: 1rem; font-size: 0.8125rem; color: #8b949e; }
.legend-item { display: flex; align-items: center; gap: 0.375rem; }
.legend-dot { width: 10px; height: 10px; border-radius: 2px; }
.dashboard { max-width: 1200px; margin: 0 auto; padding: 0 2rem 2rem; }
.time-btn { padding: 0.375rem 0.75rem; border: 1px solid #30363d; border-radius: 6px; background: #21262d; color: #c9d1d9; cursor: pointer; font-size: 0.8125rem; transition: all 0.15s ease; }
.time-btn:hover { background: #30363d; border-color: #8b949e; }
.time-btn.active { background: #238636; border-color: #238636; color: #fff; }
.stats-grid { display: grid; grid-template-columns: repeat(auto-fit, minmax(200px, 1fr)); gap: 1rem; margin-bottom: 2rem; }
.stat-card { background: #161b22; padding: 1.5rem; border-radius: 8px; border: 1px solid #30363d; }
.stat-number { font-size: 2rem; font-weight: 700; color: #f0f6fc; line-height: 1; margin-bottom: 0.25rem; }
.stat-label { color: #8b949e; font-size: 0.75rem; text-transform: uppercase; letter-spacing: 0.05em; }
.chart-container { background: #161b22; border-radius: 8px; border: 1px solid #30363d; padding: 1.5rem; margin-bottom: 2rem; position: relative; }
.chart-title { color: #f0f6fc; font-size: 1rem; font-weight: 600; margin-bottom: 1rem; }
.chart-hint { color: #8b949e; font-size: 0.75rem; margin-top: 0.5rem; }
#traffic-chart { width: 100%; }
.loading-overlay { position: absolute; top: 0; left: 0; right: 0; bottom: 0; background: rgba(13, 17, 23, 0.8); display: flex; align-items: center; justify-content: center; border-radius: 8px; z-index: 10; }
.loading-overlay.hidden { display: none; }
.loading-text { color: #8b949e; font-size: 0.875rem; }
.user-agents-section { background: #161b22; border-radius: 8px; border: 1px solid #30363d; padding: 1.5rem; }
.section-header { display: flex; justify-content: space-between; align-items: center; margin-bottom: 1rem; }
.section-title { color: #f0f6fc; font-size: 1rem; font-weight: 600; }
.section-subtitle { color: #8b949e; font-size: 0.75rem; text-transform: uppercase; letter-spacing: 0.05em; margin-bottom: 0.5rem; margin-top: 1rem; }
.last-updated { color: #6e7681; font-size: 0.75rem; }
.ua-summary { display: flex; flex-wrap: wrap; gap: 0.5rem; margin-bottom: 1rem; }
.ua-tag { display: inline-flex; align-items: center; gap: 0.375rem; padding: 0.25rem 0.5rem; background: #21262d; border-radius: 4px; font-size: 0.75rem; color: #c9d1d9; }
.ua-tag-count { color: #8b949e; }
.search-input { width: 100%; padding: 0.625rem 0.875rem; border: 1px solid #30363d; border-radius: 6px; background: #0d1117; color: #c9d1d9; font-size: 0.875rem; margin-bottom: 1rem; }
.search-input:focus { outline: none; border-color: #58a6ff; }
.search-input::placeholder { color: #6e7681; }
.ua-list { max-height: 400px; overflow-y: auto; }
.ua-item { display: flex; justify-content: space-between; align-items: center; padding: 0.75rem 0; border-bottom: 1px solid #21262d; }
.ua-item:last-child { border-bottom: none; }
.ua-rank { width: 2rem; font-weight: 600; color: #8b949e; }
.ua-rank.top-1 { color: #ffd700; } .ua-rank.top-2 { color: #c0c0c0; } .ua-rank.top-3 { color: #cd7f32; }
.ua-name { flex: 1; font-size: 0.875rem; color: #c9d1d9; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; margin-right: 1rem; }
.ua-count { font-weight: 600; color: #f0f6fc; font-size: 0.875rem; }
.uplot { margin: 0 auto; }
.u-legend { display: none; }
.u-select { background: rgba(35, 134, 54, 0.2); border: 1px solid rgba(35, 134, 54, 0.5); }
@media (max-width: 768px) { .dashboard { padding: 0 1rem 1rem; }
.stats-grid { grid-template-columns: repeat(2, 1fr); }
.stat-number { font-size: 1.5rem; } } </style></head>
<body> <div class="header"> <div class="header-left"> <h1>📊 Cachet Analytics</h1> <div class="header-links"> <a href="/swagger">API Docs</a> <a href="/health">Health</a> <a href="https://tangled.sh/dunkirk.sh/cachet" target="_blank">Source</a> </div> </div> <fieldset class="header-right" aria-label="Time range selector"> <button type="button" class="time-btn" data-days="1">24h</button> <button type="button" class="time-btn active" data-days="7">7d</button> <button type="button" class="time-btn" data-days="30">30d</button> <button type="button" class="time-btn" data-days="90">90d</button> <button type="button" class="time-btn" data-days="365">1y</button> </fieldset> </div>
<div class="dashboard">
<div class="stats-grid"> <div class="stat-card"> <div class="stat-number" id="totalRequests">-</div> <div class="stat-label">Total Requests</div> </div> <div class="stat-card"> <div class="stat-number" id="avgResponseTime">-</div> <div class="stat-label">Avg Response</div> </div> <div class="stat-card"> <div class="stat-number" id="uptime">-</div> <div class="stat-label">Uptime (90d)</div> </div> <div class="stat-card"> <div class="stat-number" id="uniqueAgents">-</div> <div class="stat-label">User Agents</div> </div> </div>
<div class="chart-container"> <div class="chart-header"> <div class="chart-title">Traffic Over Time</div> <div class="chart-legend"> <span class="legend-item"><span class="legend-dot" style="background:#238636"></span>Requests</span> <span class="legend-item"><span class="legend-dot" style="background:rgba(249, 115, 22, 0.5)"></span>Latency</span> </div> </div> <div id="traffic-chart" role="img" aria-label="Traffic chart"></div> <div class="chart-hint">Drag to zoom • Double-click to reset</div> <div class="loading-overlay hidden" id="chartLoading"> <span class="loading-text">Loading...</span> </div> </div>
<div class="user-agents-section"> <div class="section-header"> <div class="section-title">Traffic Sources</div> <div class="last-updated" id="lastUpdated"></div> </div> <div class="ua-summary" id="uaSummary"></div> <div class="section-subtitle">Referers & Non-Browser Clients</div> <input type="text" class="search-input" id="uaSearch" placeholder="Search user agents..."> <div class="ua-list" id="uaList"></div> </div> </div>
<script> // State let currentDays = 7; let currentZoom = null; // { start, end } when zoomed let chart = null; let dblClickHandler = null; let _currentTrafficData = []; let allUserAgents = []; let abortController = null;
// URL state management function getStateFromURL() { const params = new URLSearchParams(window.location.search); const days = parseInt(params.get('days'), 10); const start = parseInt(params.get('start'), 10); const end = parseInt(params.get('end'), 10); return { days: days && [1, 7, 30, 90, 365].includes(days) ? days : 7, zoom: start && end ? { start, end } : null }; }
function updateURL() { const params = new URLSearchParams(); params.set('days', currentDays); if (currentZoom) { params.set('start', currentZoom.start); params.set('end', currentZoom.end); } const newURL = `${window.location.pathname}?${params.toString()}`; history.replaceState(null, '', newURL); }
// Utilities function formatNumber(n) { if (n >= 1_000_000) return `${(n / 1_000_000).toFixed(1)}M`; if (n >= 1_000) return `${(n / 1_000).toFixed(1)}K`; return n.toLocaleString(); }
function escapeHtml(value) { return String(value ?? '').replace(/[&<>"']/g, c => ( { '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' }[c] )); }
function formatMs(ms) { if (ms === null || ms === undefined) return '-'; if (ms < 0.01) return '0ms'; if (ms < 1) return `${ms.toFixed(2)}ms`; if (ms < 100) return `${ms.toFixed(1)}ms`; if (ms < 1000) return `${Math.round(ms)}ms`; return `${(ms / 1000).toFixed(2)}s`; }
function _formatUptime(seconds) { if (seconds === null || seconds === undefined) return '-'; const days = Math.floor(seconds / 86400); const hours = Math.floor((seconds % 86400) / 3600); const minutes = Math.floor((seconds % 3600) / 60); if (days > 0) return `${days}d ${hours}h`; if (hours > 0) return `${hours}h ${minutes}m`; return `${minutes}m`; }
function classifyUserAgent(ua) { if (!ua) return { browser: 'Unknown', os: 'Unknown', type: 'unknown' }; // Real browsers have Mozilla/5.0 and specific browser/engine identifiers const isBrowser = ua.includes('Mozilla/5.0') && ( ua.includes('Chrome/') || ua.includes('Firefox/') || ua.includes('Safari/') || ua.includes('Edg/') || ua.includes('OPR/') || ua.includes('AppleWebKit/') ); if (!isBrowser) { return { browser: 'Other', os: '-', type: 'other' }; }
const os = ua.includes('Macintosh') ? 'macOS' : ua.includes('Windows') ? 'Windows' : ua.includes('Linux') && !ua.includes('Android') ? 'Linux' : ua.includes('iPhone') || ua.includes('iPad') ? 'iOS' : ua.includes('Android') ? 'Android' : 'Other';
let browser = 'Other'; if (ua.includes('Edg/')) browser = 'Edge'; else if (ua.includes('OPR/') || ua.includes('Opera')) browser = 'Opera'; else if (ua.includes('Chrome/') && !ua.includes('Edg/')) browser = 'Chrome'; else if (ua.includes('Firefox/')) browser = 'Firefox'; else if (ua.includes('Safari/') && !ua.includes('Chrome')) browser = 'Safari';
return { browser, os, type: 'browser' }; }
function parseUserAgent(ua) { const { browser, os, type } = classifyUserAgent(ua); if (type !== 'browser') return browser; if (browser !== 'Other' && os !== 'Other') return `${browser} (${os})`; if (browser !== 'Other') return browser; if (!ua) return 'Unknown'; return ua.length > 60 ? `${ua.substring(0, 57)}...` : ua; }
function computeUASummary(agents) { const browsers = {}; const oses = {}; const types = { browser: 0, bot: 0, cli: 0, social: 0, unknown: 0 }; for (const agent of agents) { const hits = agent.hits || agent.count || 1; const { browser, os, type } = classifyUserAgent(agent.userAgent); browsers[browser] = (browsers[browser] || 0) + hits; if (os !== '-') oses[os] = (oses[os] || 0) + hits; types[type] = (types[type] || 0) + hits; } return { browsers: Object.entries(browsers).sort((a, b) => b[1] - a[1]).slice(0, 5), oses: Object.entries(oses).sort((a, b) => b[1] - a[1]).slice(0, 4), types }; }
function updateLastUpdated() { const el = document.getElementById('lastUpdated'); const now = new Date(); el.textContent = `Updated ${now.toLocaleTimeString()}`; }
// Chart function initChart(data) { const container = document.getElementById('traffic-chart'); const width = container.clientWidth;
if (chart) { chart.destroy(); }
if (!data || data.length === 0) { container.innerHTML = '<div style="color: #8b949e; text-align: center; padding: 2rem;">No data available</div>'; return; }
// Transform data for uPlot - both series const timestamps = data.map(d => d.bucket); const hits = data.map(d => d.hits); const latency = data.map(d => d.avgLatency ?? 0);
// Minimum zoom based on current time range (or 2 hours for zoomed state) const dataSpan = timestamps.length > 1 ? timestamps[timestamps.length - 1] - timestamps[0] : 86400; const minSpan = Math.min(dataSpan, 2 * 3600); // At least 2 hours, but not more than data range
const opts = { width: width, height: 280, cursor: { drag: { x: true, y: false }, points: { size: 6, fill: (u, seriesIdx) => u.series[seriesIdx].stroke(), stroke: (u, seriesIdx) => u.series[seriesIdx].stroke(), } }, select: { show: true, over: true, }, scales: { x: { time: true, range: (_u, dataMin, dataMax) => { let min = dataMin; let max = dataMax; const span = max - min; if (span < minSpan) { const center = (min + max) / 2; min = center - minSpan / 2; max = center + minSpan / 2; } return [min, max]; } }, y: { auto: true, range: (_u, _min, max) => [0, max * 1.1] }, latency: { auto: true, range: (_u, _min, max) => [0, max * 1.1] } }, axes: [ { stroke: '#8b949e', grid: { stroke: '#21262d', width: 1 }, ticks: { stroke: '#30363d', width: 1 }, font: '11px system-ui', }, { scale: 'y', stroke: '#238636', grid: { stroke: '#21262d', width: 1 }, ticks: { stroke: '#30363d', width: 1 }, font: '11px system-ui', values: (_u, vals) => vals.map(v => formatNumber(v)), }, { side: 1, scale: 'latency', stroke: 'rgba(249, 115, 22, 0.7)', grid: { show: false }, ticks: { stroke: '#30363d', width: 1 }, font: '11px system-ui', values: (_u, vals) => vals.map(v => formatMs(v)), } ], series: [ {}, { label: 'Requests', scale: 'y', stroke: '#238636', width: 2, fill: 'rgba(35, 134, 54, 0.1)', }, { label: 'Latency', scale: 'latency', stroke: 'rgba(249, 115, 22, 0.5)', width: 1, fill: 'rgba(249, 115, 22, 0.1)', } ], hooks: { setSelect: [ (u) => { if (u.select.width > 10) { const min = u.posToVal(u.select.left, 'x'); const max = u.posToVal(u.select.left + u.select.width, 'x'); handleZoom(min, max); } u.setSelect({ left: 0, width: 0, top: 0, height: 0 }, false); } ] } };
chart = new uPlot(opts, [timestamps, hits, latency], container);
// Double-click to reset zoom (remove old handler first) if (dblClickHandler) { container.removeEventListener('dblclick', dblClickHandler); } dblClickHandler = () => { currentZoom = null; updateURL(); loadData(); }; container.addEventListener('dblclick', dblClickHandler); }
function handleZoom(minTime, maxTime) { const minSpan = 2 * 3600; // 2 hours minimum zoom const span = maxTime - minTime; // Enforce minimum zoom if (span < minSpan) { const center = (minTime + maxTime) / 2; minTime = Math.floor(center - minSpan / 2); maxTime = Math.floor(center + minSpan / 2); }
currentZoom = { start: Math.floor(minTime), end: Math.floor(maxTime) }; updateURL();
showLoading(true); fetchTrafficData(minTime, maxTime).then(data => { if (data !== null) { _currentTrafficData = data; initChart(data); } showLoading(false); }); }
function showLoading(show) { document.getElementById('chartLoading').classList.toggle('hidden', !show); }
// Data fetching let fetchId = 0; async function fetchTrafficData(startTime, endTime) { const thisFetchId = ++fetchId; if (abortController) { abortController.abort(); } abortController = new AbortController();
try { let url = '/api/stats/traffic'; if (startTime && endTime) { url += `?start=${Math.floor(startTime)}&end=${Math.floor(endTime)}`; } else { url += `?days=${currentDays}`; }
const res = await fetch(url, { signal: abortController.signal }); if (!res.ok) throw new Error('Failed to fetch traffic data'); const data = await res.json(); // Only return data if this is still the latest fetch if (thisFetchId !== fetchId) return null; return data; } catch (e) { if (e.name !== 'AbortError') { console.error('Error fetching traffic:', e); } return null; } }
async function loadData() { showLoading(true);
try { // Fetch stats and traffic in parallel const [statsRes, trafficData, uaRes, refRes] = await Promise.all([ fetch(`/api/stats/essential?days=${currentDays}`), fetchTrafficData(), fetch('/api/stats/useragents'), fetch('/api/stats/referers') ]);
// If traffic fetch was superseded, don't update if (trafficData === null) { showLoading(false); return; }
const stats = await statsRes.json(); const uaData = await uaRes.json(); const referers = await refRes.json();
// Update stats cards document.getElementById('totalRequests').textContent = formatNumber(stats.totalRequests || 0); document.getElementById('avgResponseTime').textContent = formatMs(stats.averageResponseTime); document.getElementById('uptime').textContent = stats.uptime ? `${stats.uptime.toFixed(4)}%` : '-'; document.getElementById('uniqueAgents').textContent = formatNumber(uaData.totalCount || 0);
// Update chart _currentTrafficData = trafficData; initChart(trafficData);
// Update user agents and referers allUserAgents = uaData.userAgents; renderTrafficSources(uaData.userAgents, referers); updateLastUpdated();
} catch (e) { console.error('Error loading data:', e); } finally { showLoading(false); } }
// Traffic sources let currentReferers = []; function renderTrafficSources(agents, referers, showSummary = true) { const list = document.getElementById('uaList'); const summaryEl = document.getElementById('uaSummary'); if (referers) { currentReferers = referers; } // Render summary (browsers and OSes only) if (showSummary) { const summary = computeUASummary(agents || []); const tags = [ ...summary.browsers.map(([name, count]) => `<span class="ua-tag">${name} <span class="ua-tag-count">${formatNumber(count)}</span></span>` ), ...summary.oses.map(([name, count]) => `<span class="ua-tag">${name} <span class="ua-tag-count">${formatNumber(count)}</span></span>` ) ]; summaryEl.innerHTML = tags.join(''); }
// Filter out standard browsers, keep bots/CLI/social/other const nonBrowserAgents = (agents || []).filter(ua => { const { type } = classifyUserAgent(ua.userAgent); return type !== 'browser'; }).map(ua => ({ type: 'ua', name: ua.userAgent?.length > 60 ? `${ua.userAgent.substring(0, 57)}...` : (ua.userAgent || 'Unknown'), full: ua.userAgent, hits: ua.hits || ua.count || 0 }));
// Add referers as entries const refererEntries = currentReferers.map(ref => ({ type: 'referer', name: ref.refererHost, full: ref.refererHost, hits: ref.hits }));
// Merge and sort by hits const combined = [...nonBrowserAgents, ...refererEntries] .sort((a, b) => b.hits - a.hits) .slice(0, 50);
if (combined.length === 0) { list.innerHTML = '<div style="color: #8b949e; padding: 1rem 0;">No traffic sources tracked yet</div>'; return; }
list.innerHTML = combined.map((item, i) => { const rankClass = i < 3 ? `top-${i + 1}` : ''; return ` <div class="ua-item"> <span class="ua-rank ${rankClass}">${i + 1}</span> <span class="ua-name" title="${escapeHtml(item.full)}">${escapeHtml(item.name)}</span> <span class="ua-count">${formatNumber(item.hits)}</span> </div> `; }).join(''); } // Backwards compat alias function renderUserAgents(agents, showSummary = true) { renderTrafficSources(agents, null, showSummary); }
// Event listeners document.querySelectorAll('.time-btn').forEach(btn => { btn.addEventListener('click', () => { document.querySelectorAll('.time-btn').forEach(b => { b.classList.remove('active'); }); btn.classList.add('active'); currentDays = parseInt(btn.dataset.days, 10); currentZoom = null; // Reset zoom when changing time range updateURL(); loadData(); }); });
document.getElementById('uaSearch').addEventListener('input', (e) => { const term = e.target.value.toLowerCase(); if (!term) { renderUserAgents(allUserAgents, true); return; } const filtered = allUserAgents.filter(ua => ua.userAgent.toLowerCase().includes(term) || parseUserAgent(ua.userAgent).toLowerCase().includes(term) ); renderUserAgents(filtered, false); });
// Handle resize let resizeTimeout; window.addEventListener('resize', () => { clearTimeout(resizeTimeout); resizeTimeout = setTimeout(() => { if (chart) { const container = document.getElementById('traffic-chart'); chart.setSize({ width: container.clientWidth, height: 280 }); } }, 100); });
// Initialize document.addEventListener('DOMContentLoaded', () => { const state = getStateFromURL(); currentDays = state.days; currentZoom = state.zoom; // Update active button document.querySelectorAll('.time-btn').forEach(b => { b.classList.toggle('active', parseInt(b.dataset.days, 10) === currentDays); }); // Load with zoom if present if (currentZoom) { showLoading(true); Promise.all([ fetch(`/api/stats/essential?days=${currentDays}`), fetchTrafficData(currentZoom.start, currentZoom.end), fetch('/api/stats/useragents'), fetch('/api/stats/referers') ]).then(async ([statsRes, trafficData, uaRes, refRes]) => { if (trafficData === null) { showLoading(false); return; } const stats = await statsRes.json(); const uaData = await uaRes.json(); const referers = await refRes.json(); document.getElementById('totalRequests').textContent = formatNumber(stats.totalRequests || 0); document.getElementById('avgResponseTime').textContent = formatMs(stats.averageResponseTime); document.getElementById('uptime').textContent = stats.uptime ? `${stats.uptime.toFixed(4)}%` : '-'; document.getElementById('uniqueAgents').textContent = formatNumber(uaData.totalCount || 0); _currentTrafficData = trafficData; initChart(trafficData); allUserAgents = uaData.userAgents; renderTrafficSources(uaData.userAgents, referers); updateLastUpdated(); showLoading(false); }); } else { loadData(); } }); </script></body>
</html>