a cache for slack profile pictures and emojis
1<!doctype html>
2<html lang="en">
3 <head>
4 <meta charset="UTF-8" />
5 <meta name="viewport" content="width=device-width, initial-scale=1.0" />
6 <title>Cachet Analytics Dashboard</title>
7 <script src="https://cdn.jsdelivr.net/npm/chart.js"></script>
8 <style>
9 * {
10 margin: 0;
11 padding: 0;
12 box-sizing: border-box;
13 }
14
15 body {
16 font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto,
17 sans-serif;
18 background: #f5f5f5;
19 color: #333;
20 }
21
22 .header {
23 background: #fff;
24 padding: 1rem 2rem;
25 box-shadow: 0 2px 4px rgba(0, 0, 0, 0.1);
26 margin-bottom: 2rem;
27 display: flex;
28 justify-content: space-between;
29 align-items: center;
30 }
31
32 .header h1 {
33 color: #2c3e50;
34 }
35
36 .header-links a {
37 margin-left: 1rem;
38 color: #3498db;
39 text-decoration: none;
40 }
41
42 .header-links a:hover {
43 text-decoration: underline;
44 }
45
46 .controls {
47 margin-bottom: 2rem;
48 text-align: center;
49 }
50
51 .controls select,
52 .controls button {
53 padding: 0.5rem 1rem;
54 margin: 0 0.5rem;
55 border: 1px solid #ddd;
56 border-radius: 4px;
57 background: white;
58 cursor: pointer;
59 }
60
61 .controls button {
62 background: #3498db;
63 color: white;
64 border: none;
65 }
66
67 .controls button:hover {
68 background: #2980b9;
69 }
70
71 .dashboard {
72 max-width: 1200px;
73 margin: 0 auto;
74 padding: 0 2rem;
75 }
76
77 .stats-grid {
78 display: grid;
79 grid-template-columns: repeat(auto-fit, minmax(200px, 1fr));
80 gap: 1rem;
81 margin-bottom: 2rem;
82 }
83
84 .stat-card {
85 background: white;
86 padding: 1.5rem;
87 border-radius: 8px;
88 box-shadow: 0 2px 4px rgba(0, 0, 0, 0.1);
89 text-align: center;
90 }
91
92 .stat-number {
93 font-size: 2rem;
94 font-weight: bold;
95 color: #3498db;
96 }
97
98 .stat-label {
99 color: #666;
100 margin-top: 0.5rem;
101 }
102
103 .charts-grid {
104 display: grid;
105 grid-template-columns: repeat(auto-fit, minmax(400px, 1fr));
106 gap: 2rem;
107 margin-bottom: 2rem;
108 }
109
110 .chart-container {
111 background: white;
112 padding: 1.5rem;
113 border-radius: 8px;
114 box-shadow: 0 2px 4px rgba(0, 0, 0, 0.1);
115 }
116
117 .chart-title {
118 font-size: 1.2rem;
119 margin-bottom: 1rem;
120 color: #2c3e50;
121 }
122
123 .loading {
124 text-align: center;
125 padding: 2rem;
126 color: #666;
127 }
128
129 .error {
130 background: #e74c3c;
131 color: white;
132 padding: 1rem;
133 border-radius: 4px;
134 margin: 1rem 0;
135 }
136
137 .auto-refresh {
138 display: flex;
139 align-items: center;
140 gap: 0.5rem;
141 justify-content: center;
142 margin-top: 1rem;
143 }
144
145 .auto-refresh input[type="checkbox"] {
146 transform: scale(1.2);
147 }
148
149 @media (max-width: 768px) {
150 .charts-grid {
151 grid-template-columns: 1fr;
152 }
153
154 .dashboard {
155 padding: 0 1rem;
156 }
157
158 .header {
159 flex-direction: column;
160 gap: 1rem;
161 text-align: center;
162 }
163 }
164 </style>
165 </head>
166 <body>
167 <div class="header">
168 <h1>📊 Cachet Analytics Dashboard</h1>
169 <div class="header-links">
170 <a href="https://github.com/taciturnaxolotl/cachet">Github</a>
171 <a href="/swagger">API Docs</a>
172 <a href="/stats">Raw Stats</a>
173 </div>
174 </div>
175
176 <div class="dashboard">
177 <div class="controls">
178 <select id="daysSelect">
179 <option value="1">Last 24 hours</option>
180 <option value="7" selected>Last 7 days</option>
181 <option value="30">Last 30 days</option>
182 </select>
183 <button onclick="loadData()">Refresh</button>
184 <div class="auto-refresh">
185 <input type="checkbox" id="autoRefresh" />
186 <label for="autoRefresh">Auto-refresh (30s)</label>
187 </div>
188 </div>
189
190 <div id="loading" class="loading">Loading analytics data...</div>
191 <div id="error" class="error" style="display: none"></div>
192
193 <div id="content" style="display: none">
194 <div class="chart-container" style="margin-bottom: 2rem; height: 450px">
195 <div class="chart-title">Traffic Overview - All Routes Over Time</div>
196 <canvas
197 id="trafficOverviewChart"
198 style="padding-bottom: 2rem"
199 ></canvas>
200 </div>
201
202 <div class="stats-grid">
203 <div class="stat-card">
204 <div class="stat-number" id="totalRequests">-</div>
205 <div class="stat-label">Total Requests</div>
206 </div>
207 <div class="stat-card">
208 <div class="stat-number" id="avgResponseTime">-</div>
209 <div class="stat-label">Avg Response Time (ms)</div>
210 </div>
211 <div class="stat-card">
212 <div class="stat-number" id="p95ResponseTime">-</div>
213 <div class="stat-label">P95 Response Time (ms)</div>
214 </div>
215 <div class="stat-card">
216 <div class="stat-number" id="uniqueEndpoints">-</div>
217 <div class="stat-label">Unique Endpoints</div>
218 </div>
219 <div class="stat-card">
220 <div class="stat-number" id="errorRate">-</div>
221 <div class="stat-label">Error Rate (%)</div>
222 </div>
223 <div class="stat-card">
224 <div class="stat-number" id="fastRequests">-</div>
225 <div class="stat-label">Fast Requests (<100ms)</div>
226 </div>
227 <div class="stat-card">
228 <div class="stat-number" id="uptime">-</div>
229 <div class="stat-label">Uptime (%)</div>
230 </div>
231 <div class="stat-card">
232 <div class="stat-number" id="throughput">-</div>
233 <div class="stat-label">Throughput (req/hr)</div>
234 </div>
235 <div class="stat-card">
236 <div class="stat-number" id="apdex">-</div>
237 <div class="stat-label">APDEX Score</div>
238 </div>
239 <div class="stat-card">
240 <div class="stat-number" id="cacheHitRate">-</div>
241 <div class="stat-label">Cache Hit Rate (%)</div>
242 </div>
243 </div>
244
245 <div class="stats-grid">
246 <div class="stat-card">
247 <div class="stat-number" id="peakHour">-</div>
248 <div class="stat-label">Peak Hour</div>
249 </div>
250 <div class="stat-card">
251 <div class="stat-number" id="peakHourRequests">-</div>
252 <div class="stat-label">Peak Hour Requests</div>
253 </div>
254 <div class="stat-card">
255 <div class="stat-number" id="peakDay">-</div>
256 <div class="stat-label">Peak Day</div>
257 </div>
258 <div class="stat-card">
259 <div class="stat-number" id="peakDayRequests">-</div>
260 <div class="stat-label">Peak Day Requests</div>
261 </div>
262 <div class="stat-card">
263 <div class="stat-number" id="dashboardRequests">-</div>
264 <div class="stat-label">Dashboard Requests</div>
265 </div>
266 </div>
267
268 <div class="charts-grid">
269 <div class="chart-container">
270 <div class="chart-title">Requests Over Time</div>
271 <canvas id="timeChart"></canvas>
272 </div>
273
274 <div class="chart-container">
275 <div class="chart-title">Latency Over Time (Hourly)</div>
276 <canvas id="latencyTimeChart"></canvas>
277 </div>
278
279 <div class="chart-container">
280 <div class="chart-title">Response Time Distribution</div>
281 <canvas id="latencyDistributionChart"></canvas>
282 </div>
283
284 <div class="chart-container">
285 <div class="chart-title">Latency Percentiles</div>
286 <canvas id="percentilesChart"></canvas>
287 </div>
288
289 <div class="chart-container">
290 <div class="chart-title">Top Endpoints</div>
291 <canvas id="endpointChart"></canvas>
292 </div>
293
294 <div class="chart-container">
295 <div class="chart-title">Slowest Endpoints</div>
296 <canvas id="slowestEndpointsChart"></canvas>
297 </div>
298
299 <div class="chart-container">
300 <div class="chart-title">Status Codes</div>
301 <canvas id="statusChart"></canvas>
302 </div>
303
304 <div class="chart-container">
305 <div class="chart-title">Top User Agents</div>
306 <canvas id="userAgentChart"></canvas>
307 </div>
308 </div>
309 </div>
310 </div>
311
312 <script>
313 let charts = {};
314 let autoRefreshInterval;
315
316 async function loadData() {
317 const days = document.getElementById("daysSelect").value;
318 const loading = document.getElementById("loading");
319 const error = document.getElementById("error");
320 const content = document.getElementById("content");
321
322 loading.style.display = "block";
323 error.style.display = "none";
324 content.style.display = "none";
325
326 try {
327 const response = await fetch(`/stats?days=${days}`);
328 if (!response.ok) throw new Error(`HTTP ${response.status}`);
329
330 const data = await response.json();
331 updateDashboard(data);
332
333 loading.style.display = "none";
334 content.style.display = "block";
335 } catch (err) {
336 loading.style.display = "none";
337 error.style.display = "block";
338 error.textContent = `Failed to load data: ${err.message}`;
339 }
340 }
341
342 function updateDashboard(data) {
343 // Main metrics
344 document.getElementById("totalRequests").textContent =
345 data.totalRequests.toLocaleString();
346 document.getElementById("avgResponseTime").textContent =
347 data.averageResponseTime
348 ? Math.round(data.averageResponseTime)
349 : "N/A";
350 document.getElementById("p95ResponseTime").textContent = data
351 .latencyAnalytics.percentiles.p95
352 ? Math.round(data.latencyAnalytics.percentiles.p95)
353 : "N/A";
354 document.getElementById("uniqueEndpoints").textContent =
355 data.requestsByEndpoint.length;
356
357 const errorRequests = data.requestsByStatus
358 .filter((s) => s.status >= 400)
359 .reduce((sum, s) => sum + s.count, 0);
360 const errorRate =
361 data.totalRequests > 0
362 ? ((errorRequests / data.totalRequests) * 100).toFixed(1)
363 : "0.0";
364 document.getElementById("errorRate").textContent = errorRate;
365
366 // Calculate fast requests percentage
367 const fastRequestsData = data.latencyAnalytics.distribution
368 .filter((d) => d.range === "0-50ms" || d.range === "50-100ms")
369 .reduce((sum, d) => sum + d.percentage, 0);
370 document.getElementById("fastRequests").textContent =
371 fastRequestsData.toFixed(1) + "%";
372
373 // Performance metrics
374 document.getElementById("uptime").textContent =
375 data.performanceMetrics.uptime.toFixed(1);
376 document.getElementById("throughput").textContent = Math.round(
377 data.performanceMetrics.throughput,
378 );
379 document.getElementById("apdex").textContent =
380 data.performanceMetrics.apdex.toFixed(2);
381 document.getElementById("cacheHitRate").textContent =
382 data.performanceMetrics.cachehitRate.toFixed(1);
383
384 // Peak traffic
385 document.getElementById("peakHour").textContent =
386 data.peakTraffic.peakHour;
387 document.getElementById("peakHourRequests").textContent =
388 data.peakTraffic.peakRequests.toLocaleString();
389 document.getElementById("peakDay").textContent =
390 data.peakTraffic.peakDay;
391 document.getElementById("peakDayRequests").textContent =
392 data.peakTraffic.peakDayRequests.toLocaleString();
393
394 // Dashboard metrics
395 document.getElementById("dashboardRequests").textContent =
396 data.dashboardMetrics.statsRequests.toLocaleString();
397
398 // Determine if we're showing hourly or daily data
399 const days = parseInt(document.getElementById("daysSelect").value);
400 const isHourly = days === 1;
401
402 updateTrafficOverviewChart(data.trafficOverview, days);
403 updateTimeChart(data.requestsByDay, isHourly);
404 updateLatencyTimeChart(data.latencyAnalytics.latencyOverTime, isHourly);
405 updateLatencyDistributionChart(data.latencyAnalytics.distribution);
406 updatePercentilesChart(data.latencyAnalytics.percentiles);
407 updateEndpointChart(data.requestsByEndpoint.slice(0, 10));
408 updateSlowestEndpointsChart(data.latencyAnalytics.slowestEndpoints);
409 updateStatusChart(data.requestsByStatus);
410 updateUserAgentChart(data.topUserAgents.slice(0, 5));
411 }
412
413 function updateTrafficOverviewChart(data, days) {
414 const ctx = document
415 .getElementById("trafficOverviewChart")
416 .getContext("2d");
417
418 if (charts.trafficOverview) charts.trafficOverview.destroy();
419
420 // Update chart title based on granularity
421 const chartTitle = document
422 .querySelector("#trafficOverviewChart")
423 .parentElement.querySelector(".chart-title");
424 let titleText = "Traffic Overview - All Routes Over Time";
425 if (days === 1) {
426 titleText += " (Hourly)";
427 } else if (days <= 7) {
428 titleText += " (4-Hour Intervals)";
429 } else {
430 titleText += " (Daily)";
431 }
432 chartTitle.textContent = titleText;
433
434 // Get all unique routes across all time periods
435 const allRoutes = new Set();
436 data.forEach((timePoint) => {
437 Object.keys(timePoint.routes).forEach((route) =>
438 allRoutes.add(route),
439 );
440 });
441
442 // Define colors for different route types
443 const routeColors = {
444 Dashboard: "#3498db",
445 "User Data": "#2ecc71",
446 "User Redirects": "#27ae60",
447 "Emoji Data": "#e74c3c",
448 "Emoji Redirects": "#c0392b",
449 "Emoji List": "#e67e22",
450 "Health Check": "#f39c12",
451 "API Documentation": "#9b59b6",
452 "Cache Management": "#34495e",
453 };
454
455 // Create datasets for each route
456 const datasets = Array.from(allRoutes).map((route) => {
457 const color = routeColors[route] || "#95a5a6";
458 return {
459 label: route,
460 data: data.map((timePoint) => timePoint.routes[route] || 0),
461 borderColor: color,
462 backgroundColor: color + "20", // Add transparency
463 tension: 0.4,
464 fill: false,
465 pointRadius: 2,
466 pointHoverRadius: 4,
467 };
468 });
469
470 // Format labels based on time granularity
471 const labels = data.map((timePoint) => {
472 if (days === 1) {
473 // Show just hour for 24h view
474 return timePoint.time.split(" ")[1] || timePoint.time;
475 } else if (days <= 7) {
476 // Show day and hour for 7-day view
477 const parts = timePoint.time.split(" ");
478 const date = parts[0].split("-")[2]; // Get day
479 const hour = parts[1] || "00:00";
480 return `${date} ${hour}`;
481 } else {
482 // Show full date for longer periods
483 return timePoint.time;
484 }
485 });
486
487 charts.trafficOverview = new Chart(ctx, {
488 type: "line",
489 data: {
490 labels: labels,
491 datasets: datasets,
492 },
493 options: {
494 responsive: true,
495 maintainAspectRatio: false,
496 interaction: {
497 mode: "index",
498 intersect: false,
499 },
500 plugins: {
501 legend: {
502 position: "top",
503 labels: {
504 usePointStyle: true,
505 padding: 15,
506 font: {
507 size: 11,
508 },
509 },
510 },
511 tooltip: {
512 mode: "index",
513 intersect: false,
514 callbacks: {
515 afterLabel: function (context) {
516 const timePoint = data[context.dataIndex];
517 return `Total: ${timePoint.total} requests`;
518 },
519 },
520 },
521 },
522 scales: {
523 x: {
524 display: true,
525 title: {
526 display: true,
527 text: days === 1 ? "Hour" : days <= 7 ? "Day & Hour" : "Date",
528 },
529 ticks: {
530 maxTicksLimit: 20,
531 },
532 },
533 y: {
534 display: true,
535 title: {
536 display: true,
537 text: "Requests",
538 },
539 beginAtZero: true,
540 },
541 },
542 elements: {
543 line: {
544 tension: 0.4,
545 },
546 },
547 },
548 });
549 }
550
551 function updateTimeChart(data, isHourly) {
552 const ctx = document.getElementById("timeChart").getContext("2d");
553
554 if (charts.time) charts.time.destroy();
555
556 // Update chart title
557 const chartTitle = document
558 .querySelector("#timeChart")
559 .parentElement.querySelector(".chart-title");
560 chartTitle.textContent = isHourly
561 ? "Requests Over Time (Hourly)"
562 : "Requests Over Time (Daily)";
563
564 charts.time = new Chart(ctx, {
565 type: "line",
566 data: {
567 labels: data.map((d) => (isHourly ? d.date.split(" ")[1] : d.date)),
568 datasets: [
569 {
570 label: "Requests",
571 data: data.map((d) => d.count),
572 borderColor: "#3498db",
573 backgroundColor: "rgba(52, 152, 219, 0.1)",
574 tension: 0.4,
575 fill: true,
576 },
577 ],
578 },
579 options: {
580 responsive: true,
581 scales: {
582 y: {
583 beginAtZero: true,
584 },
585 },
586 },
587 });
588 }
589
590 function updateEndpointChart(data) {
591 const ctx = document.getElementById("endpointChart").getContext("2d");
592
593 if (charts.endpoint) charts.endpoint.destroy();
594
595 charts.endpoint = new Chart(ctx, {
596 type: "bar",
597 data: {
598 labels: data.map((d) => d.endpoint),
599 datasets: [
600 {
601 label: "Requests",
602 data: data.map((d) => d.count),
603 backgroundColor: "#2ecc71",
604 },
605 ],
606 },
607 options: {
608 responsive: true,
609 indexAxis: "y",
610 scales: {
611 x: {
612 beginAtZero: true,
613 },
614 },
615 },
616 });
617 }
618
619 function updateStatusChart(data) {
620 const ctx = document.getElementById("statusChart").getContext("2d");
621
622 if (charts.status) charts.status.destroy();
623
624 const colors = data.map((d) => {
625 if (d.status >= 200 && d.status < 300) return "#2ecc71";
626 if (d.status >= 300 && d.status < 400) return "#f39c12";
627 if (d.status >= 400 && d.status < 500) return "#e74c3c";
628 return "#9b59b6";
629 });
630
631 charts.status = new Chart(ctx, {
632 type: "doughnut",
633 data: {
634 labels: data.map((d) => `${d.status}`),
635 datasets: [
636 {
637 data: data.map((d) => d.count),
638 backgroundColor: colors,
639 },
640 ],
641 },
642 options: {
643 responsive: true,
644 },
645 });
646 }
647
648 function updateUserAgentChart(data) {
649 const ctx = document.getElementById("userAgentChart").getContext("2d");
650
651 if (charts.userAgent) charts.userAgent.destroy();
652
653 charts.userAgent = new Chart(ctx, {
654 type: "pie",
655 data: {
656 labels: data.map((d) => d.userAgent),
657 datasets: [
658 {
659 data: data.map((d) => d.count),
660 backgroundColor: [
661 "#3498db",
662 "#e74c3c",
663 "#2ecc71",
664 "#f39c12",
665 "#9b59b6",
666 "#34495e",
667 "#16a085",
668 "#8e44ad",
669 "#d35400",
670 "#7f8c8d",
671 ],
672 },
673 ],
674 },
675 options: {
676 responsive: true,
677 },
678 });
679 }
680
681 function updateLatencyTimeChart(data, isHourly) {
682 const ctx = document
683 .getElementById("latencyTimeChart")
684 .getContext("2d");
685
686 if (charts.latencyTime) charts.latencyTime.destroy();
687
688 // Update chart title
689 const chartTitle = document
690 .querySelector("#latencyTimeChart")
691 .parentElement.querySelector(".chart-title");
692 chartTitle.textContent = isHourly
693 ? "Latency Over Time (Hourly)"
694 : "Latency Over Time (Daily)";
695
696 charts.latencyTime = new Chart(ctx, {
697 type: "line",
698 data: {
699 labels: data.map((d) => (isHourly ? d.time.split(" ")[1] : d.time)),
700 datasets: [
701 {
702 label: "Average Response Time",
703 data: data.map((d) => d.averageResponseTime),
704 borderColor: "#3498db",
705 backgroundColor: "rgba(52, 152, 219, 0.1)",
706 tension: 0.4,
707 yAxisID: "y",
708 },
709 {
710 label: "P95 Response Time",
711 data: data.map((d) => d.p95),
712 borderColor: "#e74c3c",
713 backgroundColor: "rgba(231, 76, 60, 0.1)",
714 tension: 0.4,
715 yAxisID: "y",
716 },
717 ],
718 },
719 options: {
720 responsive: true,
721 scales: {
722 y: {
723 beginAtZero: true,
724 title: {
725 display: true,
726 text: "Response Time (ms)",
727 },
728 },
729 },
730 },
731 });
732 }
733
734 function updateLatencyDistributionChart(data) {
735 const ctx = document
736 .getElementById("latencyDistributionChart")
737 .getContext("2d");
738
739 if (charts.latencyDistribution) charts.latencyDistribution.destroy();
740
741 charts.latencyDistribution = new Chart(ctx, {
742 type: "bar",
743 data: {
744 labels: data.map((d) => d.range),
745 datasets: [
746 {
747 label: "Requests",
748 data: data.map((d) => d.count),
749 backgroundColor: "#2ecc71",
750 },
751 ],
752 },
753 options: {
754 responsive: true,
755 scales: {
756 y: {
757 beginAtZero: true,
758 },
759 },
760 },
761 });
762 }
763
764 function updatePercentilesChart(percentiles) {
765 const ctx = document
766 .getElementById("percentilesChart")
767 .getContext("2d");
768
769 if (charts.percentiles) charts.percentiles.destroy();
770
771 const data = [
772 { label: "P50", value: percentiles.p50 },
773 { label: "P75", value: percentiles.p75 },
774 { label: "P90", value: percentiles.p90 },
775 { label: "P95", value: percentiles.p95 },
776 { label: "P99", value: percentiles.p99 },
777 ].filter((d) => d.value !== null);
778
779 charts.percentiles = new Chart(ctx, {
780 type: "bar",
781 data: {
782 labels: data.map((d) => d.label),
783 datasets: [
784 {
785 label: "Response Time (ms)",
786 data: data.map((d) => d.value),
787 backgroundColor: [
788 "#3498db",
789 "#2ecc71",
790 "#f39c12",
791 "#e74c3c",
792 "#9b59b6",
793 ],
794 },
795 ],
796 },
797 options: {
798 responsive: true,
799 scales: {
800 y: {
801 beginAtZero: true,
802 },
803 },
804 },
805 });
806 }
807
808 function updateSlowestEndpointsChart(data) {
809 const ctx = document
810 .getElementById("slowestEndpointsChart")
811 .getContext("2d");
812
813 if (charts.slowestEndpoints) charts.slowestEndpoints.destroy();
814
815 charts.slowestEndpoints = new Chart(ctx, {
816 type: "bar",
817 data: {
818 labels: data.map((d) => d.endpoint),
819 datasets: [
820 {
821 label: "Avg Response Time (ms)",
822 data: data.map((d) => d.averageResponseTime),
823 backgroundColor: "#e74c3c",
824 },
825 ],
826 },
827 options: {
828 responsive: true,
829 indexAxis: "y",
830 scales: {
831 x: {
832 beginAtZero: true,
833 },
834 },
835 },
836 });
837 }
838
839 document
840 .getElementById("autoRefresh")
841 .addEventListener("change", function () {
842 if (this.checked) {
843 autoRefreshInterval = setInterval(loadData, 30000);
844 } else {
845 clearInterval(autoRefreshInterval);
846 }
847 });
848
849 loadData();
850 document
851 .getElementById("daysSelect")
852 .addEventListener("change", loadData);
853 </script>
854 </body>
855</html>