···
+
<meta charset="UTF-8" />
+
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
+
<title>Cachet Analytics Dashboard</title>
+
<script src="https://cdn.jsdelivr.net/npm/chart.js@4.4.0/dist/chart.umd.js"></script>
+
src="https://cdn.jsdelivr.net/npm/chartjs-adapter-date-fns@3.0.0/dist/chartjs-adapter-date-fns.bundle.min.js"></script>
+
box-sizing: border-box;
+
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto,
+
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.1);
+
border-bottom: 1px solid #e5e7eb;
+
.header-links a:hover {
+
text-decoration: underline;
+
justify-content: center;
+
padding: 0.75rem 1.25rem;
+
border: 1px solid #d1d5db;
+
transition: all 0.2s ease;
+
.controls select:hover,
+
.controls select:focus {
+
box-shadow: 0 0 0 3px rgba(99, 102, 241, 0.1);
+
.controls button:hover {
+
.controls button:disabled {
+
grid-template-columns: repeat(auto-fit, minmax(250px, 1fr));
+
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.1);
+
border: 1px solid #e5e7eb;
+
transition: all 0.2s ease;
+
flex-direction: column;
+
justify-content: center;
+
transform: translateY(-2px);
+
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.15);
+
text-transform: uppercase;
+
letter-spacing: 0.05em;
+
grid-template-columns: 1fr;
+
grid-template-columns: 1fr 1fr;
+
@media (max-width: 768px) {
grid-template-columns: 1fr;
+
grid-template-columns: 1fr;
+
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.1);
+
border: 1px solid #e5e7eb;
+
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.1);
+
border: 1px solid #e5e7eb;
+
border: 1px solid #d1d5db;
+
transition: border-color 0.2s ease;
+
box-shadow: 0 0 0 3px rgba(99, 102, 241, 0.1);
+
border-collapse: collapse;
+
border-bottom: 2px solid #e5e7eb;
+
border-bottom: 1px solid #f3f4f6;
+
.ua-table tbody tr:hover {
+
word-break: break-word;
+
font-family: monospace;
+
border: 3px solid #e5e7eb;
+
border-top-color: #6366f1;
+
animation: spin 1s ease-in-out infinite;
+
transform: rotate(360deg);
+
border: 1px solid #fecaca;
+
.auto-refresh input[type="checkbox"] {
+
<h1>📊 Cachet Analytics Dashboard</h1>
+
<div class="header-links">
+
<a href="https://github.com/taciturnaxolotl/cachet">Github</a>
+
<a href="/swagger">API Docs</a>
+
<a href="/stats">Raw Stats</a>
+
<a href="https://status.dunkirk.sh/status/cachet">Status</a>
+
<div class="dashboard">
+
<select id="daysSelect">
+
<option value="1">Last 24 hours</option>
+
<option value="7" selected>Last 7 days</option>
+
<option value="30">Last 30 days</option>
+
<button id="refreshBtn" onclick="loadData()">Refresh</button>
+
<div class="auto-refresh">
+
<input type="checkbox" id="autoRefresh" />
+
<label for="autoRefresh">Auto-refresh (30s)</label>
+
<div id="loading" class="loading">
+
<div class="loading-spinner"></div>
+
Loading analytics data...
+
<div id="error" class="error" style="display: none"></div>
+
<div id="content" style="display: none">
+
<div class="stats-grid">
+
<div class="stat-card">
+
<div class="stat-number" id="totalRequests">-</div>
+
<div class="stat-label">Total Requests</div>
+
<div class="stat-card">
+
<div class="stat-number" id="uptime">-</div>
+
<div class="stat-label">Uptime</div>
+
<div class="stat-card">
+
<div class="stat-number" id="avgResponseTime">-</div>
+
<div class="stat-label">Avg Response Time</div>
+
<div class="charts-grid">
+
<div class="charts-row">
+
<div class="chart-container">
+
<div class="chart-title">Requests Over Time</div>
+
<canvas id="requestsChart"></canvas>
+
<div class="chart-container">
+
<div class="chart-title">Latency Over Time</div>
+
<canvas id="latencyChart"></canvas>
+
<!-- User Agents Table -->
+
<div class="user-agents-table">
+
<div class="chart-title">User Agents</div>
+
<div class="search-container">
+
<input type="text" id="userAgentSearch" class="search-input" placeholder="Search user agents...">
+
<div id="userAgentsTable">
+
<div class="loading">Loading user agents...</div>
+
let autoRefreshInterval;
+
const _currentData = null;
+
let _isLoading = false;
+
let currentRequestId = 0;
+
let abortController = null;
+
// Debounced resize handler for charts
+
function handleResize() {
+
clearTimeout(resizeTimeout);
+
resizeTimeout = setTimeout(() => {
+
Object.values(charts).forEach(chart => {
+
if (chart && typeof chart.resize === 'function') {
+
window.addEventListener('resize', handleResize);
+
async function loadData() {
+
// Cancel any existing requests
+
abortController.abort();
+
// Create new abort controller for this request
+
abortController = new AbortController();
+
const requestId = ++currentRequestId;
+
const signal = abortController.signal;
+
const startTime = Date.now();
+
// Capture the days value at the start to ensure consistency
+
const days = document.getElementById("daysSelect").value;
+
const loading = document.getElementById("loading");
+
const error = document.getElementById("error");
+
const content = document.getElementById("content");
+
const refreshBtn = document.getElementById("refreshBtn");
+
console.log(`Starting request ${requestId} for ${days} days`);
+
loading.style.display = "block";
+
error.style.display = "none";
+
content.style.display = "none";
+
refreshBtn.disabled = true;
+
refreshBtn.textContent = "Loading...";
+
// Step 1: Load essential stats first (fastest)
+
console.log(`[${requestId}] Loading essential stats...`);
+
const essentialResponse = await fetch(`/api/stats/essential?days=${days}`, {signal});
+
// Check if this request is still current
+
if (requestId !== currentRequestId) {
+
console.log(`[${requestId}] Request cancelled (essential stats)`);
+
if (!essentialResponse.ok) throw new Error(`HTTP ${essentialResponse.status}`);
+
const essentialData = await essentialResponse.json();
+
// Double-check we're still the current request
+
if (requestId !== currentRequestId) {
+
console.log(`[${requestId}] Request cancelled (essential stats after response)`);
+
updateEssentialStats(essentialData);
+
// Show content immediately with essential stats
+
loading.style.display = "none";
+
content.style.display = "block";
+
refreshBtn.textContent = "Loading Charts...";
+
console.log(`[${requestId}] Essential stats loaded in ${Date.now() - startTime}ms`);
+
// Step 2: Load chart data (medium speed)
+
console.log(`[${requestId}] Loading chart data...`);
+
const chartResponse = await fetch(`/api/stats/charts?days=${days}`, {signal});
+
if (requestId !== currentRequestId) {
+
console.log(`[${requestId}] Request cancelled (chart data)`);
+
if (!chartResponse.ok) throw new Error(`HTTP ${chartResponse.status}`);
+
const chartData = await chartResponse.json();
+
if (requestId !== currentRequestId) {
+
console.log(`[${requestId}] Request cancelled (chart data after response)`);
+
updateCharts(chartData, parseInt(days, 10));
+
refreshBtn.textContent = "Loading User Agents...";
+
console.log(`[${requestId}] Charts loaded in ${Date.now() - startTime}ms`);
+
// Step 3: Load user agents last (slowest)
+
console.log(`[${requestId}] Loading user agents...`);
+
const userAgentsResponse = await fetch(`/api/stats/useragents?days=${days}`, {signal});
+
if (requestId !== currentRequestId) {
+
console.log(`[${requestId}] Request cancelled (user agents)`);
+
if (!userAgentsResponse.ok) throw new Error(`HTTP ${userAgentsResponse.status}`);
+
const userAgentsData = await userAgentsResponse.json();
+
if (requestId !== currentRequestId) {
+
console.log(`[${requestId}] Request cancelled (user agents after response)`);
+
updateUserAgentsTable(userAgentsData);
+
const totalTime = Date.now() - startTime;
+
console.log(`[${requestId}] All data loaded in ${totalTime}ms`);
+
// Only show error if this is still the current request
+
if (requestId === currentRequestId) {
+
if (err.name === 'AbortError') {
+
console.log(`[${requestId}] Request aborted`);
+
loading.style.display = "none";
+
error.style.display = "block";
+
error.textContent = `Failed to load data: ${err.message}`;
+
console.error(`[${requestId}] Error: ${err.message}`);
+
// Only update UI if this is still the current request
+
if (requestId === currentRequestId) {
+
refreshBtn.disabled = false;
+
refreshBtn.textContent = "Refresh";
+
abortController = null;
+
// Update just the essential stats (fast)
+
function updateEssentialStats(data) {
+
document.getElementById("totalRequests").textContent = data.totalRequests.toLocaleString();
+
document.getElementById("uptime").textContent = `${data.uptime.toFixed(1)}%`;
+
document.getElementById("avgResponseTime").textContent =
+
data.averageResponseTime ? `${Math.round(data.averageResponseTime)}ms` : "N/A";
+
// Update charts (medium speed)
+
function updateCharts(data, days) {
+
updateRequestsChart(data.requestsByDay, days === 1);
+
updateLatencyChart(data.latencyOverTime, days === 1);
+
// Requests Over Time Chart
+
function updateRequestsChart(data, _isHourly) {
+
const ctx = document.getElementById("requestsChart").getContext("2d");
+
const days = parseInt(document.getElementById("daysSelect").value, 10);
+
if (charts.requests) charts.requests.destroy();
+
// Format labels based on granularity
+
const labels = data.map((d) => {
+
// 15-minute intervals: show just time
+
return d.date.split(" ")[1] || d.date;
+
} else if (days <= 7) {
+
// Hourly: show date + hour
+
const parts = d.date.split(" ");
+
const date = parts[0].split("-")[2]; // Get day
+
const hour = parts[1] || "00:00";
+
return `${date} ${hour}`;
+
// 4-hour intervals: show abbreviated
+
return d.date.split(" ")[0];
+
charts.requests = new Chart(ctx, {
+
data: data.map((d) => d.count),
+
borderColor: "#6366f1",
+
backgroundColor: "rgba(99, 102, 241, 0.1)",
+
pointBackgroundColor: "#6366f1",
+
maintainAspectRatio: false,
+
legend: {display: false},
+
const original = data[context[0].dataIndex];
+
if (days === 1) return `Time: ${original.date}`;
+
if (days <= 7) return `DateTime: ${original.date}`;
+
return `Interval: ${original.date}`;
+
label: (context) => `Requests: ${context.parsed.y.toLocaleString()}`
+
text: days === 1 ? 'Time (15min intervals)' : days <= 7 ? 'Time (hourly)' : 'Time (4hr intervals)'
+
grid: {color: 'rgba(0, 0, 0, 0.05)'},
+
maxTicksLimit: days === 1 ? 12 : 20,
+
title: {display: true, text: 'Requests'},
+
grid: {color: 'rgba(0, 0, 0, 0.05)'}
+
// Latency Over Time Chart
+
function updateLatencyChart(data, _isHourly) {
+
const ctx = document.getElementById("latencyChart").getContext("2d");
+
const days = parseInt(document.getElementById("daysSelect").value, 10);
+
if (charts.latency) charts.latency.destroy();
+
// Format labels based on granularity
+
const labels = data.map((d) => {
+
// 15-minute intervals: show just time
+
return d.time.split(" ")[1] || d.time;
+
} else if (days <= 7) {
+
// Hourly: show date + hour
+
const parts = d.time.split(" ");
+
const date = parts[0].split("-")[2]; // Get day
+
const hour = parts[1] || "00:00";
+
return `${date} ${hour}`;
+
// 4-hour intervals: show abbreviated
+
return d.time.split(" ")[0];
+
// Calculate dynamic max for logarithmic scale
+
const responseTimes = data.map((d) => d.averageResponseTime);
+
const maxResponseTime = Math.max(...responseTimes);
+
// Calculate appropriate max for log scale (next power of 10)
+
const logMax = 10 ** Math.ceil(Math.log10(maxResponseTime));
+
// Generate dynamic tick values based on the data range
+
const generateLogTicks = (_min, max) => {
+
while (current <= max) {
+
const dynamicTicks = generateLogTicks(1, logMax);
+
charts.latency = new Chart(ctx, {
+
label: "Average Response Time",
+
borderColor: "#10b981",
+
backgroundColor: "rgba(16, 185, 129, 0.1)",
+
pointBackgroundColor: "#10b981",
+
maintainAspectRatio: false,
+
legend: {display: false},
+
const original = data[context[0].dataIndex];
+
if (days === 1) return `Time: ${original.time}`;
+
if (days <= 7) return `DateTime: ${original.time}`;
+
return `Interval: ${original.time}`;
+
const point = data[context.dataIndex];
+
`Response Time: ${Math.round(context.parsed.y)}ms`,
+
`Request Count: ${point.count.toLocaleString()}`
+
text: days === 1 ? 'Time (15min intervals)' : days <= 7 ? 'Time (hourly)' : 'Time (4hr intervals)'
+
grid: {color: 'rgba(0, 0, 0, 0.05)'},
+
maxTicksLimit: days === 1 ? 12 : 20,
+
title: {display: true, text: 'Response Time (ms, log scale)'},
+
grid: {color: 'rgba(0, 0, 0, 0.05)'},
+
// Show clean numbers based on dynamic range
+
if (dynamicTicks.includes(value)) {
+
let allUserAgents = [];
+
function updateUserAgentsTable(userAgents) {
+
allUserAgents = userAgents;
+
renderUserAgentsTable(userAgents);
+
setupUserAgentSearch();
+
function parseUserAgent(ua) {
+
// Keep strange/unique ones as-is
+
!ua.includes('Mozilla/') ||
+
ua.includes('crawler') ||
+
ua.includes('spider') ||
+
!ua.includes('AppleWebKit') ||
+
ua.includes('Shiba-Arcade') ||
+
ua.includes('python') ||
+
ua.includes('PostmanRuntime')) {
+
// Parse common browsers
+
const os = ua.includes('Macintosh') ? 'macOS' :
+
ua.includes('Windows NT 10.0') ? 'Windows 10' :
+
ua.includes('Windows NT') ? 'Windows' :
+
ua.includes('X11; Linux') ? 'Linux' :
+
ua.includes('iPhone') ? 'iOS' :
+
ua.includes('Android') ? 'Android' : 'Unknown OS';
+
// Detect browser and version
+
let browser = 'Unknown Browser';
+
if (ua.includes('Edg/')) {
+
const match = ua.match(/Edg\/(\d+\.\d+)/);
+
const version = match ? match[1] : '';
+
browser = `Edge ${version}`;
+
} else if (ua.includes('Chrome/')) {
+
const match = ua.match(/Chrome\/(\d+\.\d+)/);
+
const version = match ? match[1] : '';
+
browser = `Chrome ${version}`;
+
} else if (ua.includes('Firefox/')) {
+
const match = ua.match(/Firefox\/(\d+\.\d+)/);
+
const version = match ? match[1] : '';
+
browser = `Firefox ${version}`;
+
} else if (ua.includes('Safari/') && !ua.includes('Chrome')) {
+
return `${browser} (${os})`;
+
function renderUserAgentsTable(userAgents) {
+
const container = document.getElementById("userAgentsTable");
+
if (userAgents.length === 0) {
+
container.innerHTML = '<div class="no-results">No user agents found</div>';
+
const totalRequests = userAgents.reduce((sum, ua) => sum + ua.count, 0);
···
+
const displayName = parseUserAgent(ua.userAgent);
+
const percentage = ((ua.count / totalRequests) * 100).toFixed(1);
<div class="ua-name">${displayName}</div>
···
<td class="ua-percentage">${percentage}%</td>
+
container.innerHTML = tableHTML;
+
function setupUserAgentSearch() {
+
const searchInput = document.getElementById('userAgentSearch');
+
searchInput.addEventListener('input', function () {
+
const searchTerm = this.value.toLowerCase().trim();
+
if (searchTerm === '') {
+
renderUserAgentsTable(allUserAgents);
+
const filtered = allUserAgents.filter(ua => {
+
const displayName = parseUserAgent(ua.userAgent).toLowerCase();
+
const rawUA = ua.userAgent.toLowerCase();
+
return displayName.includes(searchTerm) || rawUA.includes(searchTerm);
+
renderUserAgentsTable(filtered);
+
document.getElementById("autoRefresh").addEventListener("change", function () {
+
autoRefreshInterval = setInterval(loadData, 30000);
+
clearInterval(autoRefreshInterval);
+
document.getElementById("daysSelect").addEventListener("change", loadData);
+
// Initialize dashboard
+
document.addEventListener('DOMContentLoaded', loadData);
+
// Cleanup on page unload
+
window.addEventListener('beforeunload', () => {
+
clearInterval(autoRefreshInterval);
+
Object.values(charts).forEach(chart => {
+
if (chart && typeof chart.destroy === 'function') {