stats.html (6791B)
1 {% extends "base.html" %} 2 {% set page_title = 'Stats' %} 3 {% set page_description = 'What is on the CDN: file counts, sizes and types.' %} 4 {% block page_class %}page-stats{% endblock %} 5 6 {% block content %} 7 <h1>CDN stats</h1> 8 <p class="muted" id="sub">Loading…</p> 9 <div id="out"></div> 10 {% endblock %} 11 12 {% block scripts %} 13 <script> 14 (function () { 15 var C = ['--c0','--c1','--c2','--c3','--c4','--c5','--c6','--c8'], OTHER = 'var(--c7)'; 16 var $ = function (id) { return document.getElementById(id); }; 17 var esc = function (s) { return String(s == null ? '' : s).replace(/[&<>"']/g, function (c) { return {'&':'&','<':'<','>':'>','"':'"',"'":'''}[c]; }); }; 18 var nf = new Intl.NumberFormat(); 19 function B(n) { var u = ['B','KB','MB','GB','TB'], i = 0; while (n >= 1024 && i < 4) { n /= 1024; i++; } return (i ? n.toFixed(n < 10 ? 1 : 0) : n) + ' ' + u[i]; } 20 function pct(a, b) { return b ? Math.round(a / b * 100) : 0; } 21 function color(i, key) { return key === 'other' ? OTHER : 'var(' + C[i % C.length] + ')'; } 22 23 function donut(cats, mode, t) { 24 var total = mode === 'size' ? t.bytes : t.files, acc = 0, segs = ''; 25 cats.forEach(function (c, i) { 26 var v = mode === 'size' ? c.bytes : c.files, p = total ? v / total * 100 : 0; 27 if (p > 0) segs += '<circle cx="21" cy="21" r="15.9155" stroke="' + color(i, c.key) + '" stroke-dasharray="' + p.toFixed(3) + ' ' + (100 - p).toFixed(3) + '" stroke-dashoffset="' + (-acc).toFixed(3) + '"/>'; 28 acc += p; 29 }); 30 var legend = cats.map(function (c, i) { 31 var v = mode === 'size' ? c.bytes : c.files; 32 return '<li><i style="background:' + color(i, c.key) + '"></i>' + esc(c.label) + '<span class="n">' + (mode === 'size' ? B(v) : nf.format(v)) + ' · ' + pct(v, total) + '%</span></li>'; 33 }).join(''); 34 return '<div class="donut-wrap"><div class="donut" role="img" aria-label="File types by ' + (mode === 'size' ? 'size' : 'count') + '"><svg viewBox="0 0 42 42">' + 35 '<circle cx="21" cy="21" r="15.9155" stroke="var(--surface-3)"/>' + segs + '</svg><div class="hole"><b>' + (mode === 'size' ? B(total) : nf.format(total)) + 36 '</b><span class="muted">' + (mode === 'size' ? 'total' : 'files') + '</span></div></div><ul class="legend">' + legend + '</ul></div>'; 37 } 38 function bars(rows, label, val, num, sub) { 39 var max = Math.max.apply(null, rows.map(num).concat(1)); 40 return '<div class="bars">' + rows.map(function (r) { 41 return '<div class="bar"><div class="top"><span>' + esc(label(r)) + '</span><span>' + val(r) + '</span></div><div class="track"><i style="width:' + Math.max(1, num(r) / max * 100).toFixed(1) + '%"></i></div></div>'; 42 }).join('') + '</div>'; 43 } 44 function cols(rows, label, num, fmt, tip) { 45 var max = Math.max.apply(null, rows.map(num).concat(1)); 46 return '<div class="cols">' + rows.map(function (r) { 47 return '<div class="col" title="' + esc(tip(r)) + '"><span class="v">' + fmt(num(r)) + '</span><i style="height:' + Math.max(1, num(r) / max * 100).toFixed(1) + '%"></i></div>'; 48 }).join('') + '</div><div class="labels">' + rows.map(function (r) { return '<span>' + esc(label(r)) + '</span>'; }).join('') + '</div>'; 49 } 50 function short(n) { return n >= 10000 ? (n / 1000).toFixed(0) + 'k' : n >= 1000 ? (n / 1000).toFixed(1) + 'k' : String(n); } 51 function mon(m) { var p = m.split('-'); return new Date(Date.UTC(+p[0], +p[1] - 1, 1)).toLocaleString(undefined, { month: 'short', timeZone: 'UTC' }) + (p[1] === '01' ? ' ’' + p[0].slice(2) : ''); } 52 53 function render(d) { 54 if (d.empty) { $('sub').textContent = ''; $('out').innerHTML = '<div class="note">No stats yet - the file list could not be read, or the CDN is empty.<br><button onclick="location.reload()">Try again</button></div>'; return; } 55 var t = d.totals, mode = 'files'; 56 $('sub').textContent = 'Updated ' + new Date(d.generated * 1000).toLocaleString([], { dateStyle: 'medium', timeStyle: 'short' }) + ' · refreshed every few minutes'; 57 var h = '<div class="tiles">' + 58 [[nf.format(t.files), 'files'], [B(t.bytes), 'total size'], [B(t.median), 'median file'], [nf.format(t.types), 'file types']].map(function (x) { return '<div class="tile"><b>' + x[0] + '</b><span>' + x[1] + '</span></div>'; }).join('') + 59 '</div><div class="grid">' + 60 '<section class="card span2"><header><h2>What\'s on the CDN</h2><div class="seg" id="seg"><button aria-pressed="true" data-m="files">Files</button><button aria-pressed="false" data-m="size">Size</button></div></header><div id="donut"></div></section>' + 61 '<section class="card"><header><h2>Top file types</h2></header>' + bars(d.extensions, function (r) { return r.ext === '(none)' ? '(no extension)' : '.' + r.ext; }, function (r) { return nf.format(r.files) + ' · ' + B(r.bytes); }, function (r) { return r.files; }) + 62 (d.extensions_more ? '<p class="muted" style="margin:12px 0 0">+ ' + nf.format(d.extensions_more) + ' more files in rarer types</p>' : '') + '</section>' + 63 '<section class="card"><header><h2>File sizes</h2></header>' + cols(d.sizes, function (r) { return r.label; }, function (r) { return r.files; }, short, function (r) { return r.label + ': ' + nf.format(r.files) + ' files'; }) + '</section>' + 64 '<section class="card"><header><h2>Biggest folders</h2></header>' + bars(d.folders, function (r) { return r.name; }, function (r) { return B(r.bytes) + ' · ' + nf.format(r.files) + ' files'; }, function (r) { return r.bytes; }) + '</section>' + 65 (d.months.length > 1 ? '<section class="card"><header><h2>Files added per month</h2></header>' + cols(d.months, function (r) { return mon(r.m); }, function (r) { return r.files; }, short, function (r) { return r.m + ': ' + nf.format(r.files) + ' files, ' + B(r.bytes); }) + '</section>' : '') + 66 '<section class="card span2"><header><h2>Largest files</h2></header><ul class="files">' + d.largest.map(function (f) { return '<li><a href="/' + encodeURI(f.path) + '" title="' + esc(f.path) + '">' + esc(f.name) + '</a><span>' + B(f.size) + '</span></li>'; }).join('') + '</ul></section></div>'; 67 $('out').innerHTML = h; 68 function draw() { $('donut').innerHTML = donut(d.categories, mode, t); } 69 draw(); 70 $('seg').addEventListener('click', function (e) { 71 var b = e.target.closest('button'); if (!b) return; mode = b.dataset.m; 72 [].forEach.call($('seg').children, function (x) { x.setAttribute('aria-pressed', x === b); }); draw(); 73 }); 74 } 75 fetch('/api/stats').then(function (r) { if (!r.ok) throw 0; return r.json(); }).then(render) 76 .catch(function () { $('sub').textContent = ''; $('out').innerHTML = '<div class="note">Could not load the stats.<br><button onclick="location.reload()">Retry</button></div>'; }); 77 })(); 78 </script> 79 {% endblock %}