a cache for slack profile pictures and emojis

feat: overhaul the dashboard and make it significantly faster

dunkirk.sh 2e38ff21 9e2761e3

verified
Changed files
+1126 -1550
src
+530 -121
src/cache.ts
··· 47 47 private defaultExpiration: number; // in hours 48 48 private onEmojiExpired?: () => void; 49 49 private analyticsCache: Map<string, { data: any; timestamp: number }> = new Map(); 50 - private analyticsCacheTTL = 60000; // 1 minute cache for analytics 50 + private analyticsCacheTTL = 30000; // 30 second cache for faster updates 51 51 52 52 /** 53 53 * Creates a new Cache instance ··· 139 139 ON request_analytics(timestamp, endpoint, status_code) 140 140 `); 141 141 142 + // Additional performance indexes 143 + this.db.run(` 144 + CREATE INDEX IF NOT EXISTS idx_request_analytics_user_agent 145 + ON request_analytics(user_agent, timestamp) WHERE user_agent IS NOT NULL 146 + `); 147 + 148 + this.db.run(` 149 + CREATE INDEX IF NOT EXISTS idx_request_analytics_time_response 150 + ON request_analytics(timestamp, response_time) WHERE response_time IS NOT NULL 151 + `); 152 + 153 + this.db.run(` 154 + CREATE INDEX IF NOT EXISTS idx_request_analytics_exclude_stats 155 + ON request_analytics(timestamp, endpoint, status_code) WHERE endpoint != '/stats' 156 + `); 157 + 142 158 // Enable WAL mode for better concurrent performance 143 159 this.db.run('PRAGMA journal_mode = WAL'); 144 160 this.db.run('PRAGMA synchronous = NORMAL'); 145 - this.db.run('PRAGMA cache_size = 10000'); 161 + this.db.run('PRAGMA cache_size = 50000'); // Increased cache size 146 162 this.db.run('PRAGMA temp_store = memory'); 163 + this.db.run('PRAGMA mmap_size = 268435456'); // 256MB memory map 164 + this.db.run('PRAGMA page_size = 4096'); // Optimal page size 147 165 148 166 // check if there are any emojis in the db 149 167 if (this.onEmojiExpired) { ··· 733 751 }>; 734 752 735 753 if (days === 1) { 736 - // Hourly data for last 24 hours (excluding stats) 754 + // 15-minute intervals for last 24 hours (excluding stats) 755 + const intervalResultsRaw = this.db 756 + .query( 757 + ` 758 + SELECT 759 + strftime('%Y-%m-%d %H:', datetime(timestamp / 1000, 'unixepoch')) || 760 + CASE 761 + WHEN CAST(strftime('%M', datetime(timestamp / 1000, 'unixepoch')) AS INTEGER) < 15 THEN '00' 762 + WHEN CAST(strftime('%M', datetime(timestamp / 1000, 'unixepoch')) AS INTEGER) < 30 THEN '15' 763 + WHEN CAST(strftime('%M', datetime(timestamp / 1000, 'unixepoch')) AS INTEGER) < 45 THEN '30' 764 + ELSE '45' 765 + END as date, 766 + COUNT(*) as count, 767 + AVG(response_time) as averageResponseTime 768 + FROM request_analytics 769 + WHERE timestamp > ? AND endpoint != '/stats' 770 + GROUP BY strftime('%Y-%m-%d %H:', datetime(timestamp / 1000, 'unixepoch')) || 771 + CASE 772 + WHEN CAST(strftime('%M', datetime(timestamp / 1000, 'unixepoch')) AS INTEGER) < 15 THEN '00' 773 + WHEN CAST(strftime('%M', datetime(timestamp / 1000, 'unixepoch')) AS INTEGER) < 30 THEN '15' 774 + WHEN CAST(strftime('%M', datetime(timestamp / 1000, 'unixepoch')) AS INTEGER) < 45 THEN '30' 775 + ELSE '45' 776 + END 777 + ORDER BY date ASC 778 + `, 779 + ) 780 + .all(cutoffTime) as Array<{ 781 + date: string; 782 + count: number; 783 + averageResponseTime: number | null; 784 + }>; 785 + 786 + timeResults = intervalResultsRaw.map((h) => ({ 787 + date: h.date, 788 + count: h.count, 789 + averageResponseTime: h.averageResponseTime ?? 0, 790 + })); 791 + } else if (days <= 7) { 792 + // Hourly data for 7 days (excluding stats) 737 793 const hourResultsRaw = this.db 738 794 .query( 739 795 ` ··· 759 815 averageResponseTime: h.averageResponseTime ?? 0, 760 816 })); 761 817 } else { 762 - // Daily data for longer periods (excluding stats) 763 - const dayResultsRaw = this.db 818 + // 4-hour intervals for longer periods (excluding stats) 819 + const intervalResultsRaw = this.db 764 820 .query( 765 821 ` 766 822 SELECT 767 - DATE(timestamp / 1000, 'unixepoch') as date, 823 + strftime('%Y-%m-%d ', datetime(timestamp / 1000, 'unixepoch')) || 824 + CASE 825 + WHEN CAST(strftime('%H', datetime(timestamp / 1000, 'unixepoch')) AS INTEGER) < 4 THEN '00:00' 826 + WHEN CAST(strftime('%H', datetime(timestamp / 1000, 'unixepoch')) AS INTEGER) < 8 THEN '04:00' 827 + WHEN CAST(strftime('%H', datetime(timestamp / 1000, 'unixepoch')) AS INTEGER) < 12 THEN '08:00' 828 + WHEN CAST(strftime('%H', datetime(timestamp / 1000, 'unixepoch')) AS INTEGER) < 16 THEN '12:00' 829 + WHEN CAST(strftime('%H', datetime(timestamp / 1000, 'unixepoch')) AS INTEGER) < 20 THEN '16:00' 830 + ELSE '20:00' 831 + END as date, 768 832 COUNT(*) as count, 769 833 AVG(response_time) as averageResponseTime 770 834 FROM request_analytics 771 835 WHERE timestamp > ? AND endpoint != '/stats' 772 - GROUP BY DATE(timestamp / 1000, 'unixepoch') 836 + GROUP BY strftime('%Y-%m-%d ', datetime(timestamp / 1000, 'unixepoch')) || 837 + CASE 838 + WHEN CAST(strftime('%H', datetime(timestamp / 1000, 'unixepoch')) AS INTEGER) < 4 THEN '00:00' 839 + WHEN CAST(strftime('%H', datetime(timestamp / 1000, 'unixepoch')) AS INTEGER) < 8 THEN '04:00' 840 + WHEN CAST(strftime('%H', datetime(timestamp / 1000, 'unixepoch')) AS INTEGER) < 12 THEN '08:00' 841 + WHEN CAST(strftime('%H', datetime(timestamp / 1000, 'unixepoch')) AS INTEGER) < 16 THEN '12:00' 842 + WHEN CAST(strftime('%H', datetime(timestamp / 1000, 'unixepoch')) AS INTEGER) < 20 THEN '16:00' 843 + ELSE '20:00' 844 + END 773 845 ORDER BY date ASC 774 846 `, 775 847 ) ··· 779 851 averageResponseTime: number | null; 780 852 }>; 781 853 782 - timeResults = dayResultsRaw.map((d) => ({ 854 + timeResults = intervalResultsRaw.map((d) => ({ 783 855 date: d.date, 784 856 count: d.count, 785 857 averageResponseTime: d.averageResponseTime ?? 0, ··· 797 869 ) 798 870 .get(cutoffTime) as { avg: number | null }; 799 871 800 - // Top user agents (simplified and grouped, excluding stats) 801 - const rawUserAgentResults = this.db 872 + // Top user agents (raw strings, excluding stats) - optimized with index hint 873 + const topUserAgents = this.db 802 874 .query( 803 875 ` 804 876 SELECT user_agent as userAgent, COUNT(*) as count 805 - FROM request_analytics 877 + FROM request_analytics INDEXED BY idx_request_analytics_user_agent 806 878 WHERE timestamp > ? AND user_agent IS NOT NULL AND endpoint != '/stats' 807 879 GROUP BY user_agent 808 880 ORDER BY count DESC 809 - LIMIT 20 881 + LIMIT 50 810 882 `, 811 883 ) 812 884 .all(cutoffTime) as Array<{ userAgent: string; count: number }>; 813 - 814 - // Group user agents intelligently 815 - const userAgentGroups: Record<string, number> = {}; 816 - 817 - for (const result of rawUserAgentResults) { 818 - const ua = result.userAgent.toLowerCase(); 819 - let groupKey: string; 820 - 821 - if (ua.includes("chrome") && !ua.includes("edg")) { 822 - groupKey = "Chrome"; 823 - } else if (ua.includes("firefox")) { 824 - groupKey = "Firefox"; 825 - } else if (ua.includes("safari") && !ua.includes("chrome")) { 826 - groupKey = "Safari"; 827 - } else if (ua.includes("edg")) { 828 - groupKey = "Edge"; 829 - } else if (ua.includes("curl")) { 830 - groupKey = "curl"; 831 - } else if (ua.includes("wget")) { 832 - groupKey = "wget"; 833 - } else if (ua.includes("postman")) { 834 - groupKey = "Postman"; 835 - } else if ( 836 - ua.includes("bot") || 837 - ua.includes("crawler") || 838 - ua.includes("spider") 839 - ) { 840 - groupKey = "Bots/Crawlers"; 841 - } else if (ua.includes("python")) { 842 - groupKey = "Python Scripts"; 843 - } else if ( 844 - ua.includes("node") || 845 - ua.includes("axios") || 846 - ua.includes("fetch") 847 - ) { 848 - groupKey = "API Clients"; 849 - } else { 850 - groupKey = "Other"; 851 - } 852 - 853 - userAgentGroups[groupKey] = 854 - (userAgentGroups[groupKey] || 0) + result.count; 855 - } 856 - 857 - // Convert back to array format, sorted by count 858 - const topUserAgents = Object.entries(userAgentGroups) 859 - .map(([userAgent, count]) => ({ userAgent, count })) 860 - .sort((a, b) => b.count - a.count) 861 - .slice(0, 10); 862 885 863 886 // Enhanced Latency Analytics 864 887 ··· 935 958 }>; 936 959 937 960 if (days === 1) { 938 - // Hourly latency data for last 24 hours (excluding stats) 961 + // 15-minute intervals for last 24 hours (excluding stats) 962 + const latencyOverTimeRaw = this.db 963 + .query( 964 + ` 965 + SELECT 966 + strftime('%Y-%m-%d %H:', datetime(timestamp / 1000, 'unixepoch')) || 967 + CASE 968 + WHEN CAST(strftime('%M', datetime(timestamp / 1000, 'unixepoch')) AS INTEGER) < 15 THEN '00' 969 + WHEN CAST(strftime('%M', datetime(timestamp / 1000, 'unixepoch')) AS INTEGER) < 30 THEN '15' 970 + WHEN CAST(strftime('%M', datetime(timestamp / 1000, 'unixepoch')) AS INTEGER) < 45 THEN '30' 971 + ELSE '45' 972 + END as time, 973 + AVG(response_time) as averageResponseTime, 974 + COUNT(*) as count 975 + FROM request_analytics 976 + WHERE timestamp > ? AND response_time IS NOT NULL AND endpoint != '/stats' 977 + GROUP BY strftime('%Y-%m-%d %H:', datetime(timestamp / 1000, 'unixepoch')) || 978 + CASE 979 + WHEN CAST(strftime('%M', datetime(timestamp / 1000, 'unixepoch')) AS INTEGER) < 15 THEN '00' 980 + WHEN CAST(strftime('%M', datetime(timestamp / 1000, 'unixepoch')) AS INTEGER) < 30 THEN '15' 981 + WHEN CAST(strftime('%M', datetime(timestamp / 1000, 'unixepoch')) AS INTEGER) < 45 THEN '30' 982 + ELSE '45' 983 + END 984 + ORDER BY time ASC 985 + `, 986 + ) 987 + .all(cutoffTime) as Array<{ 988 + time: string; 989 + averageResponseTime: number; 990 + count: number; 991 + }>; 992 + 993 + // For 15-minute intervals, we'll skip P95 calculation to improve performance 994 + latencyOverTime = latencyOverTimeRaw.map((intervalData) => ({ 995 + time: intervalData.time, 996 + averageResponseTime: intervalData.averageResponseTime, 997 + p95: null, // Skip P95 for better performance with high granularity 998 + count: intervalData.count, 999 + })); 1000 + } else if (days <= 7) { 1001 + // Hourly latency data for 7 days (excluding stats) 939 1002 const latencyOverTimeRaw = this.db 940 1003 .query( 941 1004 ` ··· 955 1018 count: number; 956 1019 }>; 957 1020 958 - // Calculate P95 for each hour 959 - latencyOverTime = latencyOverTimeRaw.map((hourData) => { 960 - const hourStart = new Date(hourData.time).getTime(); 961 - const hourEnd = hourStart + 60 * 60 * 1000; // 1 hour later 962 - 963 - const hourResponseTimes = this.db 964 - .query( 965 - ` 966 - SELECT response_time 967 - FROM request_analytics 968 - WHERE timestamp >= ? AND timestamp < ? AND response_time IS NOT NULL AND endpoint != '/stats' 969 - ORDER BY response_time 970 - `, 971 - ) 972 - .all(hourStart, hourEnd) as Array<{ response_time: number }>; 973 - 974 - const hourTimes = hourResponseTimes 975 - .map((r) => r.response_time) 976 - .sort((a, b) => a - b); 977 - const p95 = calculatePercentile(hourTimes, 95); 978 - 979 - return { 980 - time: hourData.time, 981 - averageResponseTime: hourData.averageResponseTime, 982 - p95, 983 - count: hourData.count, 984 - }; 985 - }); 1021 + latencyOverTime = latencyOverTimeRaw.map((hourData) => ({ 1022 + time: hourData.time, 1023 + averageResponseTime: hourData.averageResponseTime, 1024 + p95: null, // Skip P95 for better performance 1025 + count: hourData.count, 1026 + })); 986 1027 } else { 987 - // Daily latency data for longer periods (excluding stats) 1028 + // 4-hour intervals for longer periods (excluding stats) 988 1029 const latencyOverTimeRaw = this.db 989 1030 .query( 990 1031 ` 991 1032 SELECT 992 - DATE(timestamp / 1000, 'unixepoch') as time, 1033 + strftime('%Y-%m-%d ', datetime(timestamp / 1000, 'unixepoch')) || 1034 + CASE 1035 + WHEN CAST(strftime('%H', datetime(timestamp / 1000, 'unixepoch')) AS INTEGER) < 4 THEN '00:00' 1036 + WHEN CAST(strftime('%H', datetime(timestamp / 1000, 'unixepoch')) AS INTEGER) < 8 THEN '04:00' 1037 + WHEN CAST(strftime('%H', datetime(timestamp / 1000, 'unixepoch')) AS INTEGER) < 12 THEN '08:00' 1038 + WHEN CAST(strftime('%H', datetime(timestamp / 1000, 'unixepoch')) AS INTEGER) < 16 THEN '12:00' 1039 + WHEN CAST(strftime('%H', datetime(timestamp / 1000, 'unixepoch')) AS INTEGER) < 20 THEN '16:00' 1040 + ELSE '20:00' 1041 + END as time, 993 1042 AVG(response_time) as averageResponseTime, 994 1043 COUNT(*) as count 995 1044 FROM request_analytics 996 1045 WHERE timestamp > ? AND response_time IS NOT NULL AND endpoint != '/stats' 997 - GROUP BY DATE(timestamp / 1000, 'unixepoch') 1046 + GROUP BY strftime('%Y-%m-%d ', datetime(timestamp / 1000, 'unixepoch')) || 1047 + CASE 1048 + WHEN CAST(strftime('%H', datetime(timestamp / 1000, 'unixepoch')) AS INTEGER) < 4 THEN '00:00' 1049 + WHEN CAST(strftime('%H', datetime(timestamp / 1000, 'unixepoch')) AS INTEGER) < 8 THEN '04:00' 1050 + WHEN CAST(strftime('%H', datetime(timestamp / 1000, 'unixepoch')) AS INTEGER) < 12 THEN '08:00' 1051 + WHEN CAST(strftime('%H', datetime(timestamp / 1000, 'unixepoch')) AS INTEGER) < 16 THEN '12:00' 1052 + WHEN CAST(strftime('%H', datetime(timestamp / 1000, 'unixepoch')) AS INTEGER) < 20 THEN '16:00' 1053 + ELSE '20:00' 1054 + END 998 1055 ORDER BY time ASC 999 1056 `, 1000 1057 ) ··· 1004 1061 count: number; 1005 1062 }>; 1006 1063 1007 - // Calculate P95 for each day 1008 - latencyOverTime = latencyOverTimeRaw.map((dayData) => { 1009 - const dayStart = new Date(dayData.time + " 00:00:00").getTime(); 1010 - const dayEnd = dayStart + 24 * 60 * 60 * 1000; // 1 day later 1011 - 1012 - const dayResponseTimes = this.db 1013 - .query( 1014 - ` 1015 - SELECT response_time 1016 - FROM request_analytics 1017 - WHERE timestamp >= ? AND timestamp < ? AND response_time IS NOT NULL AND endpoint != '/stats' 1018 - ORDER BY response_time 1019 - `, 1020 - ) 1021 - .all(dayStart, dayEnd) as Array<{ response_time: number }>; 1022 - 1023 - const dayTimes = dayResponseTimes 1024 - .map((r) => r.response_time) 1025 - .sort((a, b) => a - b); 1026 - const p95 = calculatePercentile(dayTimes, 95); 1027 - 1028 - return { 1029 - time: dayData.time, 1030 - averageResponseTime: dayData.averageResponseTime, 1031 - p95, 1032 - count: dayData.count, 1033 - }; 1034 - }); 1064 + latencyOverTime = latencyOverTimeRaw.map((intervalData) => ({ 1065 + time: intervalData.time, 1066 + averageResponseTime: intervalData.averageResponseTime, 1067 + p95: null, // Skip P95 for better performance 1068 + count: intervalData.count, 1069 + })); 1035 1070 } 1036 1071 1037 1072 // Performance Metrics ··· 1406 1441 } 1407 1442 1408 1443 return result; 1444 + } 1445 + 1446 + /** 1447 + * Gets essential stats only (fast loading) 1448 + * @param days Number of days to look back (default: 7) 1449 + * @returns Essential stats data 1450 + */ 1451 + async getEssentialStats(days: number = 7): Promise<{ 1452 + totalRequests: number; 1453 + averageResponseTime: number | null; 1454 + uptime: number; 1455 + }> { 1456 + // Check cache first 1457 + const cacheKey = `essential_${days}`; 1458 + const cached = this.analyticsCache.get(cacheKey); 1459 + const now = Date.now(); 1460 + 1461 + if (cached && (now - cached.timestamp) < this.analyticsCacheTTL) { 1462 + return cached.data; 1463 + } 1464 + 1465 + const cutoffTime = Date.now() - days * 24 * 60 * 60 * 1000; 1466 + 1467 + // Total requests (excluding stats endpoint) - fastest query 1468 + const totalResult = this.db 1469 + .query( 1470 + "SELECT COUNT(*) as count FROM request_analytics WHERE timestamp > ? AND endpoint != '/stats'", 1471 + ) 1472 + .get(cutoffTime) as { count: number }; 1473 + 1474 + // Average response time (excluding stats) - simple query 1475 + const avgResponseResult = this.db 1476 + .query( 1477 + "SELECT AVG(response_time) as avg FROM request_analytics WHERE timestamp > ? AND response_time IS NOT NULL AND endpoint != '/stats'", 1478 + ) 1479 + .get(cutoffTime) as { avg: number | null }; 1480 + 1481 + // Simple error rate calculation for uptime 1482 + const errorRequests = this.db 1483 + .query( 1484 + "SELECT COUNT(*) as count FROM request_analytics WHERE timestamp > ? AND status_code >= 400 AND endpoint != '/stats'", 1485 + ) 1486 + .get(cutoffTime) as { count: number }; 1487 + 1488 + const errorRate = totalResult.count > 0 ? (errorRequests.count / totalResult.count) * 100 : 0; 1489 + const uptime = Math.max(0, 100 - errorRate * 2); // Simple approximation 1490 + 1491 + const result = { 1492 + totalRequests: totalResult.count, 1493 + averageResponseTime: avgResponseResult.avg, 1494 + uptime: uptime, 1495 + }; 1496 + 1497 + // Cache the result 1498 + this.analyticsCache.set(cacheKey, { 1499 + data: result, 1500 + timestamp: now 1501 + }); 1502 + 1503 + return result; 1504 + } 1505 + 1506 + /** 1507 + * Gets chart data only (requests and latency over time) 1508 + * @param days Number of days to look back (default: 7) 1509 + * @returns Chart data 1510 + */ 1511 + async getChartData(days: number = 7): Promise<{ 1512 + requestsByDay: Array<{ 1513 + date: string; 1514 + count: number; 1515 + averageResponseTime: number; 1516 + }>; 1517 + latencyOverTime: Array<{ 1518 + time: string; 1519 + averageResponseTime: number; 1520 + p95: number | null; 1521 + count: number; 1522 + }>; 1523 + }> { 1524 + // Check cache first 1525 + const cacheKey = `charts_${days}`; 1526 + const cached = this.analyticsCache.get(cacheKey); 1527 + const now = Date.now(); 1528 + 1529 + if (cached && (now - cached.timestamp) < this.analyticsCacheTTL) { 1530 + return cached.data; 1531 + } 1532 + 1533 + const cutoffTime = Date.now() - days * 24 * 60 * 60 * 1000; 1534 + 1535 + // Reuse the existing time logic from getAnalytics 1536 + let timeResults: Array<{ 1537 + date: string; 1538 + count: number; 1539 + averageResponseTime: number; 1540 + }>; 1541 + 1542 + if (days === 1) { 1543 + // 15-minute intervals for last 24 hours (excluding stats) 1544 + const intervalResultsRaw = this.db 1545 + .query( 1546 + ` 1547 + SELECT 1548 + strftime('%Y-%m-%d %H:', datetime(timestamp / 1000, 'unixepoch')) || 1549 + CASE 1550 + WHEN CAST(strftime('%M', datetime(timestamp / 1000, 'unixepoch')) AS INTEGER) < 15 THEN '00' 1551 + WHEN CAST(strftime('%M', datetime(timestamp / 1000, 'unixepoch')) AS INTEGER) < 30 THEN '15' 1552 + WHEN CAST(strftime('%M', datetime(timestamp / 1000, 'unixepoch')) AS INTEGER) < 45 THEN '30' 1553 + ELSE '45' 1554 + END as date, 1555 + COUNT(*) as count, 1556 + AVG(response_time) as averageResponseTime 1557 + FROM request_analytics 1558 + WHERE timestamp > ? AND endpoint != '/stats' 1559 + GROUP BY strftime('%Y-%m-%d %H:', datetime(timestamp / 1000, 'unixepoch')) || 1560 + CASE 1561 + WHEN CAST(strftime('%M', datetime(timestamp / 1000, 'unixepoch')) AS INTEGER) < 15 THEN '00' 1562 + WHEN CAST(strftime('%M', datetime(timestamp / 1000, 'unixepoch')) AS INTEGER) < 30 THEN '15' 1563 + WHEN CAST(strftime('%M', datetime(timestamp / 1000, 'unixepoch')) AS INTEGER) < 45 THEN '30' 1564 + ELSE '45' 1565 + END 1566 + ORDER BY date ASC 1567 + `, 1568 + ) 1569 + .all(cutoffTime) as Array<{ 1570 + date: string; 1571 + count: number; 1572 + averageResponseTime: number | null; 1573 + }>; 1574 + 1575 + timeResults = intervalResultsRaw.map((h) => ({ 1576 + date: h.date, 1577 + count: h.count, 1578 + averageResponseTime: h.averageResponseTime ?? 0, 1579 + })); 1580 + } else if (days <= 7) { 1581 + // Hourly data for 7 days (excluding stats) 1582 + const hourResultsRaw = this.db 1583 + .query( 1584 + ` 1585 + SELECT 1586 + strftime('%Y-%m-%d %H:00', datetime(timestamp / 1000, 'unixepoch')) as date, 1587 + COUNT(*) as count, 1588 + AVG(response_time) as averageResponseTime 1589 + FROM request_analytics 1590 + WHERE timestamp > ? AND endpoint != '/stats' 1591 + GROUP BY strftime('%Y-%m-%d %H:00', datetime(timestamp / 1000, 'unixepoch')) 1592 + ORDER BY date ASC 1593 + `, 1594 + ) 1595 + .all(cutoffTime) as Array<{ 1596 + date: string; 1597 + count: number; 1598 + averageResponseTime: number | null; 1599 + }>; 1600 + 1601 + timeResults = hourResultsRaw.map((h) => ({ 1602 + date: h.date, 1603 + count: h.count, 1604 + averageResponseTime: h.averageResponseTime ?? 0, 1605 + })); 1606 + } else { 1607 + // 4-hour intervals for longer periods (excluding stats) 1608 + const intervalResultsRaw = this.db 1609 + .query( 1610 + ` 1611 + SELECT 1612 + strftime('%Y-%m-%d ', datetime(timestamp / 1000, 'unixepoch')) || 1613 + CASE 1614 + WHEN CAST(strftime('%H', datetime(timestamp / 1000, 'unixepoch')) AS INTEGER) < 4 THEN '00:00' 1615 + WHEN CAST(strftime('%H', datetime(timestamp / 1000, 'unixepoch')) AS INTEGER) < 8 THEN '04:00' 1616 + WHEN CAST(strftime('%H', datetime(timestamp / 1000, 'unixepoch')) AS INTEGER) < 12 THEN '08:00' 1617 + WHEN CAST(strftime('%H', datetime(timestamp / 1000, 'unixepoch')) AS INTEGER) < 16 THEN '12:00' 1618 + WHEN CAST(strftime('%H', datetime(timestamp / 1000, 'unixepoch')) AS INTEGER) < 20 THEN '16:00' 1619 + ELSE '20:00' 1620 + END as date, 1621 + COUNT(*) as count, 1622 + AVG(response_time) as averageResponseTime 1623 + FROM request_analytics 1624 + WHERE timestamp > ? AND endpoint != '/stats' 1625 + GROUP BY strftime('%Y-%m-%d ', datetime(timestamp / 1000, 'unixepoch')) || 1626 + CASE 1627 + WHEN CAST(strftime('%H', datetime(timestamp / 1000, 'unixepoch')) AS INTEGER) < 4 THEN '00:00' 1628 + WHEN CAST(strftime('%H', datetime(timestamp / 1000, 'unixepoch')) AS INTEGER) < 8 THEN '04:00' 1629 + WHEN CAST(strftime('%H', datetime(timestamp / 1000, 'unixepoch')) AS INTEGER) < 12 THEN '08:00' 1630 + WHEN CAST(strftime('%H', datetime(timestamp / 1000, 'unixepoch')) AS INTEGER) < 16 THEN '12:00' 1631 + WHEN CAST(strftime('%H', datetime(timestamp / 1000, 'unixepoch')) AS INTEGER) < 20 THEN '16:00' 1632 + ELSE '20:00' 1633 + END 1634 + ORDER BY date ASC 1635 + `, 1636 + ) 1637 + .all(cutoffTime) as Array<{ 1638 + date: string; 1639 + count: number; 1640 + averageResponseTime: number | null; 1641 + }>; 1642 + 1643 + timeResults = intervalResultsRaw.map((d) => ({ 1644 + date: d.date, 1645 + count: d.count, 1646 + averageResponseTime: d.averageResponseTime ?? 0, 1647 + })); 1648 + } 1649 + 1650 + // Latency over time data (reuse from getAnalytics) 1651 + let latencyOverTime: Array<{ 1652 + time: string; 1653 + averageResponseTime: number; 1654 + p95: number | null; 1655 + count: number; 1656 + }>; 1657 + 1658 + if (days === 1) { 1659 + const latencyOverTimeRaw = this.db 1660 + .query( 1661 + ` 1662 + SELECT 1663 + strftime('%Y-%m-%d %H:', datetime(timestamp / 1000, 'unixepoch')) || 1664 + CASE 1665 + WHEN CAST(strftime('%M', datetime(timestamp / 1000, 'unixepoch')) AS INTEGER) < 15 THEN '00' 1666 + WHEN CAST(strftime('%M', datetime(timestamp / 1000, 'unixepoch')) AS INTEGER) < 30 THEN '15' 1667 + WHEN CAST(strftime('%M', datetime(timestamp / 1000, 'unixepoch')) AS INTEGER) < 45 THEN '30' 1668 + ELSE '45' 1669 + END as time, 1670 + AVG(response_time) as averageResponseTime, 1671 + COUNT(*) as count 1672 + FROM request_analytics 1673 + WHERE timestamp > ? AND response_time IS NOT NULL AND endpoint != '/stats' 1674 + GROUP BY strftime('%Y-%m-%d %H:', datetime(timestamp / 1000, 'unixepoch')) || 1675 + CASE 1676 + WHEN CAST(strftime('%M', datetime(timestamp / 1000, 'unixepoch')) AS INTEGER) < 15 THEN '00' 1677 + WHEN CAST(strftime('%M', datetime(timestamp / 1000, 'unixepoch')) AS INTEGER) < 30 THEN '15' 1678 + WHEN CAST(strftime('%M', datetime(timestamp / 1000, 'unixepoch')) AS INTEGER) < 45 THEN '30' 1679 + ELSE '45' 1680 + END 1681 + ORDER BY time ASC 1682 + `, 1683 + ) 1684 + .all(cutoffTime) as Array<{ 1685 + time: string; 1686 + averageResponseTime: number; 1687 + count: number; 1688 + }>; 1689 + 1690 + latencyOverTime = latencyOverTimeRaw.map((intervalData) => ({ 1691 + time: intervalData.time, 1692 + averageResponseTime: intervalData.averageResponseTime, 1693 + p95: null, // Skip P95 for better performance 1694 + count: intervalData.count, 1695 + })); 1696 + } else if (days <= 7) { 1697 + const latencyOverTimeRaw = this.db 1698 + .query( 1699 + ` 1700 + SELECT 1701 + strftime('%Y-%m-%d %H:00', datetime(timestamp / 1000, 'unixepoch')) as time, 1702 + AVG(response_time) as averageResponseTime, 1703 + COUNT(*) as count 1704 + FROM request_analytics 1705 + WHERE timestamp > ? AND response_time IS NOT NULL AND endpoint != '/stats' 1706 + GROUP BY strftime('%Y-%m-%d %H:00', datetime(timestamp / 1000, 'unixepoch')) 1707 + ORDER BY time ASC 1708 + `, 1709 + ) 1710 + .all(cutoffTime) as Array<{ 1711 + time: string; 1712 + averageResponseTime: number; 1713 + count: number; 1714 + }>; 1715 + 1716 + latencyOverTime = latencyOverTimeRaw.map((hourData) => ({ 1717 + time: hourData.time, 1718 + averageResponseTime: hourData.averageResponseTime, 1719 + p95: null, // Skip P95 for better performance 1720 + count: hourData.count, 1721 + })); 1722 + } else { 1723 + const latencyOverTimeRaw = this.db 1724 + .query( 1725 + ` 1726 + SELECT 1727 + strftime('%Y-%m-%d ', datetime(timestamp / 1000, 'unixepoch')) || 1728 + CASE 1729 + WHEN CAST(strftime('%H', datetime(timestamp / 1000, 'unixepoch')) AS INTEGER) < 4 THEN '00:00' 1730 + WHEN CAST(strftime('%H', datetime(timestamp / 1000, 'unixepoch')) AS INTEGER) < 8 THEN '04:00' 1731 + WHEN CAST(strftime('%H', datetime(timestamp / 1000, 'unixepoch')) AS INTEGER) < 12 THEN '08:00' 1732 + WHEN CAST(strftime('%H', datetime(timestamp / 1000, 'unixepoch')) AS INTEGER) < 16 THEN '12:00' 1733 + WHEN CAST(strftime('%H', datetime(timestamp / 1000, 'unixepoch')) AS INTEGER) < 20 THEN '16:00' 1734 + ELSE '20:00' 1735 + END as time, 1736 + AVG(response_time) as averageResponseTime, 1737 + COUNT(*) as count 1738 + FROM request_analytics 1739 + WHERE timestamp > ? AND response_time IS NOT NULL AND endpoint != '/stats' 1740 + GROUP BY strftime('%Y-%m-%d ', datetime(timestamp / 1000, 'unixepoch')) || 1741 + CASE 1742 + WHEN CAST(strftime('%H', datetime(timestamp / 1000, 'unixepoch')) AS INTEGER) < 4 THEN '00:00' 1743 + WHEN CAST(strftime('%H', datetime(timestamp / 1000, 'unixepoch')) AS INTEGER) < 8 THEN '04:00' 1744 + WHEN CAST(strftime('%H', datetime(timestamp / 1000, 'unixepoch')) AS INTEGER) < 12 THEN '08:00' 1745 + WHEN CAST(strftime('%H', datetime(timestamp / 1000, 'unixepoch')) AS INTEGER) < 16 THEN '12:00' 1746 + WHEN CAST(strftime('%H', datetime(timestamp / 1000, 'unixepoch')) AS INTEGER) < 20 THEN '16:00' 1747 + ELSE '20:00' 1748 + END 1749 + ORDER BY time ASC 1750 + `, 1751 + ) 1752 + .all(cutoffTime) as Array<{ 1753 + time: string; 1754 + averageResponseTime: number; 1755 + count: number; 1756 + }>; 1757 + 1758 + latencyOverTime = latencyOverTimeRaw.map((intervalData) => ({ 1759 + time: intervalData.time, 1760 + averageResponseTime: intervalData.averageResponseTime, 1761 + p95: null, // Skip P95 for better performance 1762 + count: intervalData.count, 1763 + })); 1764 + } 1765 + 1766 + const result = { 1767 + requestsByDay: timeResults, 1768 + latencyOverTime: latencyOverTime, 1769 + }; 1770 + 1771 + // Cache the result 1772 + this.analyticsCache.set(cacheKey, { 1773 + data: result, 1774 + timestamp: now 1775 + }); 1776 + 1777 + return result; 1778 + } 1779 + 1780 + /** 1781 + * Gets user agents data only (slowest loading) 1782 + * @param days Number of days to look back (default: 7) 1783 + * @returns User agents data 1784 + */ 1785 + async getUserAgents(days: number = 7): Promise<Array<{ userAgent: string; count: number }>> { 1786 + // Check cache first 1787 + const cacheKey = `useragents_${days}`; 1788 + const cached = this.analyticsCache.get(cacheKey); 1789 + const now = Date.now(); 1790 + 1791 + if (cached && (now - cached.timestamp) < this.analyticsCacheTTL) { 1792 + return cached.data; 1793 + } 1794 + 1795 + const cutoffTime = Date.now() - days * 24 * 60 * 60 * 1000; 1796 + 1797 + // Top user agents (raw strings, excluding stats) - optimized with index hint 1798 + const topUserAgents = this.db 1799 + .query( 1800 + ` 1801 + SELECT user_agent as userAgent, COUNT(*) as count 1802 + FROM request_analytics INDEXED BY idx_request_analytics_user_agent 1803 + WHERE timestamp > ? AND user_agent IS NOT NULL AND endpoint != '/stats' 1804 + GROUP BY user_agent 1805 + ORDER BY count DESC 1806 + LIMIT 50 1807 + `, 1808 + ) 1809 + .all(cutoffTime) as Array<{ userAgent: string; count: number }>; 1810 + 1811 + // Cache the result 1812 + this.analyticsCache.set(cacheKey, { 1813 + data: topUserAgents, 1814 + timestamp: now 1815 + }); 1816 + 1817 + return topUserAgents; 1409 1818 } 1410 1819 } 1411 1820
+475 -1428
src/dashboard.html
··· 16 16 body { 17 17 font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, 18 18 sans-serif; 19 - background: #f8fafc; 20 - color: #1e293b; 19 + background: #f9fafb; 20 + color: #111827; 21 21 line-height: 1.6; 22 22 } 23 23 24 24 .header { 25 25 background: #fff; 26 - padding: 1rem 2rem; 26 + padding: 1.5rem 2rem; 27 27 box-shadow: 0 1px 3px rgba(0, 0, 0, 0.1); 28 28 margin-bottom: 2rem; 29 - display: flex; 30 - justify-content: space-between; 31 - align-items: center; 32 - border-bottom: 1px solid #e2e8f0; 29 + border-bottom: 1px solid #e5e7eb; 33 30 } 34 31 35 32 .header h1 { 36 - color: #2c3e50; 33 + color: #111827; 34 + font-size: 1.875rem; 35 + font-weight: 700; 36 + margin-bottom: 0.5rem; 37 + } 38 + 39 + .header-links { 40 + display: flex; 41 + gap: 1.5rem; 37 42 } 38 43 39 44 .header-links a { 40 - margin-left: 1rem; 41 - color: #3498db; 45 + color: #6366f1; 42 46 text-decoration: none; 47 + font-weight: 500; 43 48 } 44 49 45 50 .header-links a:hover { 51 + color: #4f46e5; 46 52 text-decoration: underline; 47 53 } 48 54 ··· 69 75 70 76 .controls select:hover, 71 77 .controls select:focus { 72 - border-color: #3b82f6; 78 + border-color: #6366f1; 73 79 outline: none; 74 - box-shadow: 0 0 0 3px rgba(59, 130, 246, 0.1); 80 + box-shadow: 0 0 0 3px rgba(99, 102, 241, 0.1); 75 81 } 76 82 77 83 .controls button { 78 - background: #3b82f6; 84 + background: #6366f1; 79 85 color: white; 80 86 border: none; 81 87 } 82 88 83 89 .controls button:hover { 84 - background: #2563eb; 85 - transform: translateY(-1px); 90 + background: #4f46e5; 86 91 } 87 92 88 93 .controls button:disabled { 89 94 background: #9ca3af; 90 95 cursor: not-allowed; 91 - transform: none; 92 96 } 93 97 94 98 .dashboard { ··· 99 103 100 104 .stats-grid { 101 105 display: grid; 102 - grid-template-columns: repeat(auto-fit, minmax(200px, 1fr)); 103 - gap: 1rem; 104 - margin-bottom: 2rem; 106 + grid-template-columns: repeat(auto-fit, minmax(250px, 1fr)); 107 + gap: 1.5rem; 108 + margin-bottom: 3rem; 105 109 } 106 110 107 111 .stat-card { 108 112 background: white; 109 - padding: 1.5rem; 113 + padding: 2rem; 110 114 border-radius: 12px; 111 115 box-shadow: 0 1px 3px rgba(0, 0, 0, 0.1); 112 116 text-align: center; 113 - border: 1px solid #e2e8f0; 117 + border: 1px solid #e5e7eb; 114 118 transition: all 0.2s ease; 115 - position: relative; 116 - overflow: hidden; 119 + min-height: 140px; 117 120 display: flex; 118 121 flex-direction: column; 119 122 justify-content: center; 120 - min-height: 120px; 121 123 } 122 124 123 125 .stat-card:hover { ··· 125 127 box-shadow: 0 4px 12px rgba(0, 0, 0, 0.15); 126 128 } 127 129 128 - .stat-card.loading { 129 - opacity: 0.6; 130 - } 131 - 132 - .stat-card.loading::after { 133 - content: ''; 134 - position: absolute; 135 - top: 0; 136 - left: -100%; 137 - width: 100%; 138 - height: 100%; 139 - background: linear-gradient(90deg, transparent, rgba(255,255,255,0.4), transparent); 140 - animation: shimmer 1.5s infinite; 141 - } 142 - 143 - @keyframes shimmer { 144 - 0% { left: -100%; } 145 - 100% { left: 100%; } 146 - } 147 - 148 130 .stat-number { 149 - font-weight: 700; 150 - color: #1f2937; 151 - margin-bottom: 0.25rem; 152 - word-break: break-word; 153 - overflow-wrap: break-word; 154 - line-height: 1.1; 155 - 156 - /* Responsive font sizing using clamp() */ 157 - font-size: clamp(1.25rem, 4vw, 2.5rem); 131 + font-weight: 800; 132 + color: #111827; 133 + margin-bottom: 0.5rem; 134 + font-size: 2.5rem; 135 + line-height: 1; 158 136 } 159 137 160 138 .stat-label { 161 - color: #4b5563; 162 - margin-top: 0.5rem; 163 - font-weight: 500; 164 - line-height: 1.3; 165 - 166 - /* Responsive font sizing for labels */ 167 - font-size: clamp(0.75rem, 2vw, 0.875rem); 139 + color: #6b7280; 140 + font-weight: 600; 141 + font-size: 0.875rem; 142 + text-transform: uppercase; 143 + letter-spacing: 0.05em; 168 144 } 169 145 170 - /* Container query support for modern browsers */ 171 - @supports (container-type: inline-size) { 172 - .stats-grid { 173 - container-type: inline-size; 174 - } 175 - 176 - @container (max-width: 250px) { 177 - .stat-number { 178 - font-size: 1.25rem; 179 - } 180 - .stat-label { 181 - font-size: 0.75rem; 182 - } 183 - } 184 - 185 - @container (min-width: 300px) { 186 - .stat-number { 187 - font-size: 2rem; 188 - } 189 - .stat-label { 190 - font-size: 0.875rem; 191 - } 192 - } 193 - 194 - @container (min-width: 400px) { 195 - .stat-number { 196 - font-size: 2.5rem; 197 - } 198 - .stat-label { 199 - font-size: 1rem; 200 - } 201 - } 146 + .charts-grid { 147 + display: grid; 148 + grid-template-columns: 1fr; 149 + gap: 2rem; 150 + margin-bottom: 3rem; 202 151 } 203 152 204 - .charts-grid { 153 + .charts-row { 205 154 display: grid; 206 - grid-template-columns: repeat(auto-fit, minmax(400px, 1fr)); 155 + grid-template-columns: 1fr 1fr; 207 156 gap: 2rem; 208 - margin-bottom: 2rem; 209 157 } 210 158 211 - @media (max-width: 480px) { 212 - .charts-grid { 159 + @media (max-width: 768px) { 160 + .charts-row { 213 161 grid-template-columns: 1fr; 214 162 } 215 - 216 - .chart-container { 217 - min-height: 250px; 218 - } 219 - 163 + 220 164 .stats-grid { 221 - grid-template-columns: repeat(auto-fit, minmax(140px, 1fr)); 165 + grid-template-columns: 1fr; 222 166 } 223 - 224 - .stat-card { 225 - padding: 1rem; 226 - min-height: 100px; 167 + 168 + .dashboard { 169 + padding: 0 1rem; 227 170 } 228 - 171 + 229 172 .stat-number { 230 - font-size: clamp(0.9rem, 4vw, 1.5rem) !important; 231 - } 232 - 233 - .stat-label { 234 - font-size: clamp(0.65rem, 2.5vw, 0.75rem) !important; 173 + font-size: 2rem; 235 174 } 236 175 } 237 176 ··· 240 179 padding: 1.5rem; 241 180 border-radius: 12px; 242 181 box-shadow: 0 1px 3px rgba(0, 0, 0, 0.1); 243 - border: 1px solid #e2e8f0; 182 + border: 1px solid #e5e7eb; 183 + height: 25rem; 184 + padding-bottom: 5rem; 185 + } 186 + 187 + .chart-title { 188 + font-size: 1.25rem; 189 + margin-bottom: 1.5rem; 190 + color: #111827; 191 + font-weight: 700; 192 + } 193 + 194 + .user-agents-table { 195 + background: white; 196 + padding: 2rem; 197 + border-radius: 12px; 198 + box-shadow: 0 1px 3px rgba(0, 0, 0, 0.1); 199 + border: 1px solid #e5e7eb; 200 + } 201 + 202 + .search-container { 203 + margin-bottom: 1.5rem; 244 204 position: relative; 245 - min-height: 350px; 246 - max-height: 500px; 247 - overflow: hidden; 248 - display: flex; 249 - flex-direction: column; 250 205 } 251 206 252 - .chart-container.loading { 253 - display: flex; 254 - align-items: center; 255 - justify-content: center; 207 + .search-input { 208 + width: 100%; 209 + padding: 0.75rem 1rem; 210 + border: 1px solid #d1d5db; 211 + border-radius: 8px; 212 + font-size: 0.875rem; 213 + background: #f9fafb; 214 + transition: border-color 0.2s ease; 215 + } 216 + 217 + .search-input:focus { 218 + outline: none; 219 + border-color: #6366f1; 220 + background: white; 221 + box-shadow: 0 0 0 3px rgba(99, 102, 241, 0.1); 256 222 } 257 223 258 - .chart-container.loading::before { 259 - content: 'Loading chart...'; 260 - color: #64748b; 224 + .ua-table { 225 + width: 100%; 226 + border-collapse: collapse; 261 227 font-size: 0.875rem; 262 228 } 263 229 264 - .chart-title { 265 - font-size: 1.125rem; 266 - margin-bottom: 1rem; 267 - padding-top: 1rem; 268 - color: #1e293b; 230 + .ua-table th { 231 + text-align: left; 232 + padding: 0.75rem 1rem; 233 + background: #f9fafb; 234 + border-bottom: 2px solid #e5e7eb; 269 235 font-weight: 600; 270 - display: block; 271 - width: 100%; 272 - text-align: left; 236 + color: #374151; 237 + position: sticky; 238 + top: 0; 239 + } 240 + 241 + .ua-table td { 242 + padding: 0.75rem 1rem; 243 + border-bottom: 1px solid #f3f4f6; 244 + vertical-align: top; 245 + } 246 + 247 + .ua-table tbody tr:hover { 248 + background: #f9fafb; 249 + } 250 + 251 + .ua-name { 252 + font-weight: 500; 253 + color: #111827; 254 + line-height: 1.4; 255 + max-width: 400px; 273 256 word-break: break-word; 274 - overflow-wrap: break-word; 275 257 } 276 258 277 - .chart-title-with-indicator { 278 - display: flex; 279 - align-items: center; 280 - justify-content: space-between; 281 - flex-wrap: wrap; 282 - gap: 0.5rem; 283 - margin-bottom: 1rem; 259 + .ua-raw { 260 + font-family: monospace; 261 + font-size: 0.75rem; 262 + color: #6b7280; 263 + margin-top: 0.25rem; 264 + max-width: 400px; 265 + word-break: break-all; 266 + line-height: 1.3; 284 267 } 285 268 286 - .chart-title-with-indicator .chart-title { 287 - margin-bottom: 0; 288 - flex: 1; 289 - min-width: 0; 269 + .ua-count { 270 + font-weight: 600; 271 + color: #111827; 272 + text-align: right; 273 + white-space: nowrap; 290 274 } 291 275 292 - .chart-error { 293 - color: #dc2626; 276 + .ua-percentage { 277 + color: #6b7280; 278 + text-align: right; 279 + font-size: 0.75rem; 280 + } 281 + 282 + .no-results { 294 283 text-align: center; 295 284 padding: 2rem; 296 - font-size: 0.875rem; 285 + color: #6b7280; 286 + font-style: italic; 297 287 } 298 288 299 289 .loading { 300 290 text-align: center; 301 291 padding: 3rem; 302 - color: #64748b; 292 + color: #6b7280; 303 293 } 304 294 305 295 .loading-spinner { 306 296 display: inline-block; 307 297 width: 2rem; 308 298 height: 2rem; 309 - border: 3px solid #e2e8f0; 299 + border: 3px solid #e5e7eb; 310 300 border-radius: 50%; 311 - border-top-color: #3b82f6; 301 + border-top-color: #6366f1; 312 302 animation: spin 1s ease-in-out infinite; 313 303 margin-bottom: 1rem; 314 304 } ··· 331 321 align-items: center; 332 322 gap: 0.5rem; 333 323 font-size: 0.875rem; 334 - color: #64748b; 324 + color: #6b7280; 335 325 } 336 326 337 327 .auto-refresh input[type="checkbox"] { 338 328 transform: scale(1.1); 339 - accent-color: #3b82f6; 340 - } 341 - 342 - .performance-indicator { 343 - display: inline-flex; 344 - align-items: center; 345 - gap: 0.25rem; 346 - font-size: 0.75rem; 347 - color: #64748b; 348 - } 349 - 350 - .performance-indicator.good { color: #059669; } 351 - .performance-indicator.warning { color: #d97706; } 352 - .performance-indicator.error { color: #dc2626; } 353 - 354 - .lazy-chart { 355 - min-height: 300px; 356 - display: flex; 357 - align-items: center; 358 - justify-content: center; 359 - background: #f8fafc; 360 - border-radius: 8px; 361 - margin: 1rem 0; 362 - } 363 - 364 - .lazy-chart.visible { 365 - background: transparent; 366 - } 367 - 368 - @media (max-width: 768px) { 369 - .charts-grid { 370 - grid-template-columns: 1fr; 371 - } 372 - 373 - .dashboard { 374 - padding: 0 1rem; 375 - } 376 - 377 - .header { 378 - flex-direction: column; 379 - gap: 1rem; 380 - text-align: center; 381 - padding: 1rem; 382 - } 383 - 384 - .controls { 385 - flex-direction: column; 386 - align-items: stretch; 387 - } 388 - 389 - .controls select, 390 - .controls button { 391 - margin: 0.25rem 0; 392 - } 393 - 394 - .stat-number { 395 - font-size: clamp(1rem, 5vw, 1.75rem) !important; 396 - } 397 - 398 - .stat-label { 399 - font-size: clamp(0.7rem, 3vw, 0.8rem) !important; 400 - } 401 - 402 - .chart-container { 403 - padding: 1rem; 404 - min-height: 250px; 405 - } 406 - 407 - .chart-title { 408 - font-size: 1rem; 409 - flex-direction: column; 410 - align-items: flex-start; 411 - } 412 - } 413 - 414 - .toast { 415 - position: fixed; 416 - top: 1rem; 417 - right: 1rem; 418 - background: #1f2937; 419 - color: white; 420 - padding: 0.75rem 1rem; 421 - border-radius: 8px; 422 - font-size: 0.875rem; 423 - z-index: 1000; 424 - transform: translateX(100%); 425 - transition: transform 0.3s ease; 426 - } 427 - 428 - .toast.show { 429 - transform: translateX(0); 430 - } 431 - 432 - .toast.success { 433 - background: #059669; 434 - } 435 - 436 - .toast.error { 437 - background: #dc2626; 329 + accent-color: #6366f1; 438 330 } 439 331 </style> 440 332 </head> ··· 469 361 <div id="error" class="error" style="display: none"></div> 470 362 471 363 <div id="content" style="display: none"> 472 - <!-- Key Metrics Overview --> 473 - <div class="chart-container" style="margin-bottom: 2rem; height: 450px"> 474 - <div class="chart-title-with-indicator"> 475 - <div class="chart-title">Traffic Overview - All Routes Over Time</div> 476 - <span class="performance-indicator" id="trafficPerformance"></span> 477 - </div> 478 - <canvas 479 - id="trafficOverviewChart" 480 - style="padding-bottom: 2rem" 481 - ></canvas> 482 - </div> 483 - 484 - <!-- Stats Grid --> 364 + <!-- Key Metrics --> 485 365 <div class="stats-grid"> 486 - <div class="stat-card" id="totalRequestsCard"> 366 + <div class="stat-card"> 487 367 <div class="stat-number" id="totalRequests">-</div> 488 368 <div class="stat-label">Total Requests</div> 489 369 </div> 490 - <div class="stat-card" id="avgResponseTimeCard"> 491 - <div class="stat-number" id="avgResponseTime">-</div> 492 - <div class="stat-label">Avg Response Time (ms)</div> 493 - </div> 494 - <div class="stat-card" id="p95ResponseTimeCard"> 495 - <div class="stat-number" id="p95ResponseTime">-</div> 496 - <div class="stat-label">P95 Response Time (ms)</div> 497 - </div> 498 - <div class="stat-card" id="uniqueEndpointsCard"> 499 - <div class="stat-number" id="uniqueEndpoints">-</div> 500 - <div class="stat-label">Unique Endpoints</div> 501 - </div> 502 - <div class="stat-card" id="errorRateCard"> 503 - <div class="stat-number" id="errorRate">-</div> 504 - <div class="stat-label">Error Rate (%)</div> 505 - </div> 506 - <div class="stat-card" id="fastRequestsCard"> 507 - <div class="stat-number" id="fastRequests">-</div> 508 - <div class="stat-label">Fast Requests (&lt;100ms)</div> 509 - </div> 510 - <div class="stat-card" id="uptimeCard"> 370 + <div class="stat-card"> 511 371 <div class="stat-number" id="uptime">-</div> 512 - <div class="stat-label">Uptime (%)</div> 372 + <div class="stat-label">Uptime</div> 513 373 </div> 514 - <div class="stat-card" id="throughputCard"> 515 - <div class="stat-number" id="throughput">-</div> 516 - <div class="stat-label">Throughput (req/hr)</div> 517 - </div> 518 - <div class="stat-card" id="apdexCard"> 519 - <div class="stat-number" id="apdex">-</div> 520 - <div class="stat-label">APDEX Score</div> 521 - </div> 522 - <div class="stat-card" id="cacheHitRateCard"> 523 - <div class="stat-number" id="cacheHitRate">-</div> 524 - <div class="stat-label">Cache Hit Rate (%)</div> 374 + <div class="stat-card"> 375 + <div class="stat-number" id="avgResponseTime">-</div> 376 + <div class="stat-label">Avg Response Time</div> 525 377 </div> 526 378 </div> 527 379 528 - <!-- Peak Traffic Stats --> 529 - <div class="stats-grid"> 530 - <div class="stat-card" id="peakHourCard"> 531 - <div class="stat-number" id="peakHour">-</div> 532 - <div class="stat-label">Peak Hour</div> 533 - </div> 534 - <div class="stat-card" id="peakHourRequestsCard"> 535 - <div class="stat-number" id="peakHourRequests">-</div> 536 - <div class="stat-label">Peak Hour Requests</div> 537 - </div> 538 - <div class="stat-card" id="peakDayCard"> 539 - <div class="stat-number" id="peakDay">-</div> 540 - <div class="stat-label">Peak Day</div> 541 - </div> 542 - <div class="stat-card" id="peakDayRequestsCard"> 543 - <div class="stat-number" id="peakDayRequests">-</div> 544 - <div class="stat-label">Peak Day Requests</div> 545 - </div> 546 - <div class="stat-card" id="dashboardRequestsCard"> 547 - <div class="stat-number" id="dashboardRequests">-</div> 548 - <div class="stat-label">Dashboard Requests</div> 549 - </div> 550 - </div> 551 - 552 - <!-- Charts Grid with Lazy Loading --> 380 + <!-- Main Charts --> 553 381 <div class="charts-grid"> 554 - <div class="chart-container lazy-chart" data-chart="timeChart"> 555 - <div class="chart-title">Requests Over Time</div> 556 - <canvas id="timeChart"></canvas> 557 - </div> 558 - 559 - <div class="chart-container lazy-chart" data-chart="latencyTimeChart"> 560 - <div class="chart-title">Latency Over Time (Hourly)</div> 561 - <canvas id="latencyTimeChart"></canvas> 562 - </div> 563 - 564 - <div class="chart-container lazy-chart" data-chart="latencyDistributionChart"> 565 - <div class="chart-title">Response Time Distribution</div> 566 - <canvas id="latencyDistributionChart"></canvas> 567 - </div> 568 - 569 - <div class="chart-container lazy-chart" data-chart="percentilesChart"> 570 - <div class="chart-title">Latency Percentiles</div> 571 - <canvas id="percentilesChart"></canvas> 572 - </div> 573 - 574 - <div class="chart-container lazy-chart" data-chart="endpointChart"> 575 - <div class="chart-title">Top Endpoints</div> 576 - <canvas id="endpointChart"></canvas> 577 - </div> 578 - 579 - <div class="chart-container lazy-chart" data-chart="slowestEndpointsChart"> 580 - <div class="chart-title">Slowest Endpoints</div> 581 - <canvas id="slowestEndpointsChart"></canvas> 382 + <div class="charts-row"> 383 + <div class="chart-container"> 384 + <div class="chart-title">Requests Over Time</div> 385 + <canvas id="requestsChart"></canvas> 386 + </div> 387 + <div class="chart-container"> 388 + <div class="chart-title">Latency Over Time</div> 389 + <canvas id="latencyChart"></canvas> 390 + </div> 582 391 </div> 392 + </div> 583 393 584 - <div class="chart-container lazy-chart" data-chart="statusChart"> 585 - <div class="chart-title">Status Codes</div> 586 - <canvas id="statusChart"></canvas> 394 + <!-- User Agents Table --> 395 + <div class="user-agents-table"> 396 + <div class="chart-title">User Agents</div> 397 + <div class="search-container"> 398 + <input type="text" id="userAgentSearch" class="search-input" placeholder="Search user agents..."> 587 399 </div> 588 - 589 - <div class="chart-container lazy-chart" data-chart="userAgentChart"> 590 - <div class="chart-title">Top User Agents</div> 591 - <canvas id="userAgentChart"></canvas> 400 + <div id="userAgentsTable"> 401 + <div class="loading">Loading user agents...</div> 592 402 </div> 593 403 </div> 594 404 </div> ··· 599 409 let autoRefreshInterval; 600 410 let currentData = null; 601 411 let isLoading = false; 602 - let visibleCharts = new Set(); 603 - let intersectionObserver; 604 - 605 - // Performance monitoring 606 - const performance = { 607 - startTime: 0, 608 - endTime: 0, 609 - loadTime: 0 610 - }; 611 - 612 - // Initialize intersection observer for lazy loading 613 - function initLazyLoading() { 614 - intersectionObserver = new IntersectionObserver((entries) => { 615 - entries.forEach(entry => { 616 - if (entry.isIntersecting) { 617 - const chartContainer = entry.target; 618 - const chartType = chartContainer.dataset.chart; 619 - 620 - if (!visibleCharts.has(chartType) && currentData) { 621 - visibleCharts.add(chartType); 622 - chartContainer.classList.add('visible'); 623 - loadChart(chartType, currentData); 624 - } 625 - } 626 - }); 627 - }, { 628 - rootMargin: '50px', 629 - threshold: 0.1 630 - }); 631 - 632 - // Observe all lazy chart containers 633 - document.querySelectorAll('.lazy-chart').forEach(container => { 634 - intersectionObserver.observe(container); 635 - }); 636 - } 637 - 638 - // Show toast notification 639 - function showToast(message, type = 'info') { 640 - const toast = document.createElement('div'); 641 - toast.className = `toast ${type}`; 642 - toast.textContent = message; 643 - document.body.appendChild(toast); 644 - 645 - setTimeout(() => toast.classList.add('show'), 100); 646 - setTimeout(() => { 647 - toast.classList.remove('show'); 648 - setTimeout(() => document.body.removeChild(toast), 300); 649 - }, 3000); 650 - } 651 - 652 - // Update loading states for stat cards 653 - function setStatCardLoading(cardId, loading) { 654 - const card = document.getElementById(cardId); 655 - if (card) { 656 - if (loading) { 657 - card.classList.add('loading'); 658 - } else { 659 - card.classList.remove('loading'); 660 - } 661 - } 662 - } 412 + let currentRequestId = 0; 413 + let abortController = null; 663 414 664 415 // Debounced resize handler for charts 665 416 let resizeTimeout; ··· 677 428 window.addEventListener('resize', handleResize); 678 429 679 430 async function loadData() { 680 - if (isLoading) return; 681 - 431 + // Cancel any existing requests 432 + if (abortController) { 433 + abortController.abort(); 434 + } 435 + 436 + // Create new abort controller for this request 437 + abortController = new AbortController(); 438 + const requestId = ++currentRequestId; 439 + const signal = abortController.signal; 440 + 682 441 isLoading = true; 683 - performance.startTime = Date.now(); 684 - 442 + const startTime = Date.now(); 443 + 444 + // Capture the days value at the start to ensure consistency 685 445 const days = document.getElementById("daysSelect").value; 686 446 const loading = document.getElementById("loading"); 687 447 const error = document.getElementById("error"); 688 448 const content = document.getElementById("content"); 689 449 const refreshBtn = document.getElementById("refreshBtn"); 450 + 451 + console.log(`Starting request ${requestId} for ${days} days`); 690 452 691 453 // Update UI state 692 454 loading.style.display = "block"; ··· 695 457 refreshBtn.disabled = true; 696 458 refreshBtn.textContent = "Loading..."; 697 459 698 - // Set all stat cards to loading state 699 - const statCards = [ 700 - 'totalRequestsCard', 'avgResponseTimeCard', 'p95ResponseTimeCard', 701 - 'uniqueEndpointsCard', 'errorRateCard', 'fastRequestsCard', 702 - 'uptimeCard', 'throughputCard', 'apdexCard', 'cacheHitRateCard', 703 - 'peakHourCard', 'peakHourRequestsCard', 'peakDayCard', 704 - 'peakDayRequestsCard', 'dashboardRequestsCard' 705 - ]; 706 - statCards.forEach(cardId => setStatCardLoading(cardId, true)); 707 - 708 460 try { 709 - const response = await fetch(`/stats?days=${days}`); 710 - if (!response.ok) throw new Error(`HTTP ${response.status}`); 461 + // Step 1: Load essential stats first (fastest) 462 + console.log(`[${requestId}] Loading essential stats...`); 463 + const essentialResponse = await fetch(`/api/stats/essential?days=${days}`, { signal }); 711 464 712 - const data = await response.json(); 713 - currentData = data; 714 - 715 - performance.endTime = Date.now(); 716 - performance.loadTime = performance.endTime - performance.startTime; 717 - 718 - updateDashboard(data); 719 - 465 + // Check if this request is still current 466 + if (requestId !== currentRequestId) { 467 + console.log(`[${requestId}] Request cancelled (essential stats)`); 468 + return; 469 + } 470 + 471 + if (!essentialResponse.ok) throw new Error(`HTTP ${essentialResponse.status}`); 472 + 473 + const essentialData = await essentialResponse.json(); 474 + 475 + // Double-check we're still the current request 476 + if (requestId !== currentRequestId) { 477 + console.log(`[${requestId}] Request cancelled (essential stats after response)`); 478 + return; 479 + } 480 + 481 + updateEssentialStats(essentialData); 482 + 483 + // Show content immediately with essential stats 720 484 loading.style.display = "none"; 721 485 content.style.display = "block"; 722 - 723 - showToast(`Dashboard updated in ${performance.loadTime}ms`, 'success'); 724 - } catch (err) { 725 - loading.style.display = "none"; 726 - error.style.display = "block"; 727 - error.textContent = `Failed to load data: ${err.message}`; 728 - showToast(`Error: ${err.message}`, 'error'); 729 - } finally { 730 - isLoading = false; 731 - refreshBtn.disabled = false; 732 - refreshBtn.textContent = "Refresh"; 733 - 734 - // Remove loading state from stat cards 735 - statCards.forEach(cardId => setStatCardLoading(cardId, false)); 736 - } 737 - } 486 + refreshBtn.textContent = "Loading Charts..."; 738 487 739 - function updateDashboard(data) { 740 - // Update main metrics with animation 741 - updateStatWithAnimation("totalRequests", data.totalRequests.toLocaleString()); 742 - updateStatWithAnimation("avgResponseTime", 743 - data.averageResponseTime ? Math.round(data.averageResponseTime) : "N/A"); 744 - updateStatWithAnimation("p95ResponseTime", 745 - data.latencyAnalytics.percentiles.p95 ? Math.round(data.latencyAnalytics.percentiles.p95) : "N/A"); 746 - updateStatWithAnimation("uniqueEndpoints", data.requestsByEndpoint.length); 488 + console.log(`[${requestId}] Essential stats loaded in ${Date.now() - startTime}ms`); 747 489 748 - const errorRequests = data.requestsByStatus 749 - .filter((s) => s.status >= 400) 750 - .reduce((sum, s) => sum + s.count, 0); 751 - const errorRate = data.totalRequests > 0 752 - ? ((errorRequests / data.totalRequests) * 100).toFixed(1) 753 - : "0.0"; 754 - updateStatWithAnimation("errorRate", errorRate); 490 + // Step 2: Load chart data (medium speed) 491 + console.log(`[${requestId}] Loading chart data...`); 492 + const chartResponse = await fetch(`/api/stats/charts?days=${days}`, { signal }); 755 493 756 - // Calculate fast requests percentage 757 - const fastRequestsData = data.latencyAnalytics.distribution 758 - .filter((d) => d.range === "0-50ms" || d.range === "50-100ms") 759 - .reduce((sum, d) => sum + d.percentage, 0); 760 - updateStatWithAnimation("fastRequests", fastRequestsData.toFixed(1) + "%"); 494 + if (requestId !== currentRequestId) { 495 + console.log(`[${requestId}] Request cancelled (chart data)`); 496 + return; 497 + } 761 498 762 - // Performance metrics 763 - updateStatWithAnimation("uptime", data.performanceMetrics.uptime.toFixed(1)); 764 - updateStatWithAnimation("throughput", Math.round(data.performanceMetrics.throughput)); 765 - updateStatWithAnimation("apdex", data.performanceMetrics.apdex.toFixed(2)); 766 - updateStatWithAnimation("cacheHitRate", data.performanceMetrics.cachehitRate.toFixed(1)); 499 + if (!chartResponse.ok) throw new Error(`HTTP ${chartResponse.status}`); 767 500 768 - // Peak traffic 769 - updateStatWithAnimation("peakHour", data.peakTraffic.peakHour); 770 - updateStatWithAnimation("peakHourRequests", data.peakTraffic.peakRequests.toLocaleString()); 771 - updateStatWithAnimation("peakDay", data.peakTraffic.peakDay); 772 - updateStatWithAnimation("peakDayRequests", data.peakTraffic.peakDayRequests.toLocaleString()); 773 - updateStatWithAnimation("dashboardRequests", data.dashboardMetrics.statsRequests.toLocaleString()); 501 + const chartData = await chartResponse.json(); 774 502 775 - // Update performance indicator 776 - updatePerformanceIndicator(data); 503 + if (requestId !== currentRequestId) { 504 + console.log(`[${requestId}] Request cancelled (chart data after response)`); 505 + return; 506 + } 777 507 778 - // Load main traffic overview chart immediately 779 - const days = parseInt(document.getElementById("daysSelect").value); 780 - updateTrafficOverviewChart(data.trafficOverview, days); 508 + updateCharts(chartData, parseInt(days)); 509 + refreshBtn.textContent = "Loading User Agents..."; 781 510 782 - // Other charts will be loaded lazily when they come into view 783 - } 511 + console.log(`[${requestId}] Charts loaded in ${Date.now() - startTime}ms`); 784 512 785 - function updateStatWithAnimation(elementId, value) { 786 - const element = document.getElementById(elementId); 787 - if (element && element.textContent !== value.toString()) { 788 - element.style.transform = 'scale(1.1)'; 789 - element.style.transition = 'transform 0.2s ease'; 790 - 791 - setTimeout(() => { 792 - element.textContent = value; 793 - element.style.transform = 'scale(1)'; 794 - }, 100); 795 - } 796 - } 513 + // Step 3: Load user agents last (slowest) 514 + console.log(`[${requestId}] Loading user agents...`); 515 + const userAgentsResponse = await fetch(`/api/stats/useragents?days=${days}`, { signal }); 797 516 798 - function updatePerformanceIndicator(data) { 799 - const indicator = document.getElementById('trafficPerformance'); 800 - const avgResponseTime = data.averageResponseTime || 0; 801 - const errorRate = data.requestsByStatus 802 - .filter((s) => s.status >= 400) 803 - .reduce((sum, s) => sum + s.count, 0) / data.totalRequests * 100; 517 + if (requestId !== currentRequestId) { 518 + console.log(`[${requestId}] Request cancelled (user agents)`); 519 + return; 520 + } 804 521 805 - let status, text; 806 - if (avgResponseTime < 100 && errorRate < 1) { 807 - status = 'good'; 808 - text = '🟢 Excellent'; 809 - } else if (avgResponseTime < 300 && errorRate < 5) { 810 - status = 'warning'; 811 - text = '🟡 Good'; 812 - } else { 813 - status = 'error'; 814 - text = '🔴 Needs Attention'; 815 - } 522 + if (!userAgentsResponse.ok) throw new Error(`HTTP ${userAgentsResponse.status}`); 523 + 524 + const userAgentsData = await userAgentsResponse.json(); 816 525 817 - indicator.className = `performance-indicator ${status}`; 818 - indicator.textContent = text; 819 - } 526 + if (requestId !== currentRequestId) { 527 + console.log(`[${requestId}] Request cancelled (user agents after response)`); 528 + return; 529 + } 820 530 821 - // Load individual charts (called by intersection observer) 822 - function loadChart(chartType, data) { 823 - const days = parseInt(document.getElementById("daysSelect").value); 824 - const isHourly = days === 1; 531 + updateUserAgentsTable(userAgentsData); 825 532 826 - try { 827 - switch(chartType) { 828 - case 'timeChart': 829 - updateTimeChart(data.requestsByDay, isHourly); 830 - break; 831 - case 'latencyTimeChart': 832 - updateLatencyTimeChart(data.latencyAnalytics.latencyOverTime, isHourly); 833 - break; 834 - case 'latencyDistributionChart': 835 - updateLatencyDistributionChart(data.latencyAnalytics.distribution); 836 - break; 837 - case 'percentilesChart': 838 - updatePercentilesChart(data.latencyAnalytics.percentiles); 839 - break; 840 - case 'endpointChart': 841 - updateEndpointChart(data.requestsByEndpoint.slice(0, 10)); 842 - break; 843 - case 'slowestEndpointsChart': 844 - updateSlowestEndpointsChart(data.latencyAnalytics.slowestEndpoints); 845 - break; 846 - case 'statusChart': 847 - updateStatusChart(data.requestsByStatus); 848 - break; 849 - case 'userAgentChart': 850 - updateUserAgentChart(data.topUserAgents.slice(0, 5)); 851 - break; 533 + const totalTime = Date.now() - startTime; 534 + console.log(`[${requestId}] All data loaded in ${totalTime}ms`); 535 + } catch (err) { 536 + // Only show error if this is still the current request 537 + if (requestId === currentRequestId) { 538 + if (err.name === 'AbortError') { 539 + console.log(`[${requestId}] Request aborted`); 540 + } else { 541 + loading.style.display = "none"; 542 + error.style.display = "block"; 543 + error.textContent = `Failed to load data: ${err.message}`; 544 + console.error(`[${requestId}] Error: ${err.message}`); 545 + } 852 546 } 853 - } catch (error) { 854 - console.error(`Error loading chart ${chartType}:`, error); 855 - const container = document.querySelector(`[data-chart="${chartType}"]`); 856 - if (container) { 857 - container.innerHTML = `<div class="chart-error">Error loading chart: ${error.message}</div>`; 547 + } finally { 548 + // Only update UI if this is still the current request 549 + if (requestId === currentRequestId) { 550 + isLoading = false; 551 + refreshBtn.disabled = false; 552 + refreshBtn.textContent = "Refresh"; 553 + abortController = null; 858 554 } 859 555 } 860 556 } 861 557 862 - function updateTrafficOverviewChart(data, days) { 863 - const canvas = document.getElementById("trafficOverviewChart"); 864 - if (!canvas) { 865 - console.warn('trafficOverviewChart canvas not found'); 866 - return; 867 - } 868 - 869 - const ctx = canvas.getContext("2d"); 870 - if (!ctx) { 871 - console.warn('Could not get 2d context for trafficOverviewChart'); 872 - return; 873 - } 558 + // Update just the essential stats (fast) 559 + function updateEssentialStats(data) { 560 + document.getElementById("totalRequests").textContent = data.totalRequests.toLocaleString(); 561 + document.getElementById("uptime").textContent = data.uptime.toFixed(1) + "%"; 562 + document.getElementById("avgResponseTime").textContent = 563 + data.averageResponseTime ? Math.round(data.averageResponseTime) + "ms" : "N/A"; 564 + } 874 565 875 - if (charts.trafficOverview) { 876 - charts.trafficOverview.destroy(); 877 - } 566 + // Update charts (medium speed) 567 + function updateCharts(data, days) { 568 + updateRequestsChart(data.requestsByDay, days === 1); 569 + updateLatencyChart(data.latencyOverTime, days === 1); 570 + } 878 571 879 - // Update chart title based on granularity 880 - const chartTitleElement = document.querySelector(".chart-title-with-indicator .chart-title"); 881 - let titleText = "Traffic Overview - All Routes Over Time"; 882 - if (days === 1) { 883 - titleText += " (Hourly)"; 884 - } else if (days <= 7) { 885 - titleText += " (4-Hour Intervals)"; 886 - } else { 887 - titleText += " (Daily)"; 888 - } 889 - if (chartTitleElement) { 890 - chartTitleElement.textContent = titleText; 891 - } 892 572 893 - // Get all unique routes across all time periods 894 - const allRoutes = new Set(); 895 - data.forEach((timePoint) => { 896 - Object.keys(timePoint.routes).forEach((route) => 897 - allRoutes.add(route), 898 - ); 899 - }); 573 + // Requests Over Time Chart 574 + function updateRequestsChart(data, isHourly) { 575 + const ctx = document.getElementById("requestsChart").getContext("2d"); 576 + const days = parseInt(document.getElementById("daysSelect").value); 900 577 901 - // Define colors for different route types 902 - const routeColors = { 903 - Dashboard: "#3b82f6", 904 - "User Data": "#10b981", 905 - "User Redirects": "#059669", 906 - "Emoji Data": "#ef4444", 907 - "Emoji Redirects": "#dc2626", 908 - "Emoji List": "#f97316", 909 - "Health Check": "#f59e0b", 910 - "API Documentation": "#8b5cf6", 911 - "Cache Management": "#6b7280", 912 - }; 578 + if (charts.requests) charts.requests.destroy(); 913 579 914 - // Create datasets for each route 915 - const datasets = Array.from(allRoutes).map((route) => { 916 - const color = routeColors[route] || "#9ca3af"; 917 - return { 918 - label: route, 919 - data: data.map((timePoint) => timePoint.routes[route] || 0), 920 - borderColor: color, 921 - backgroundColor: color + "20", 922 - tension: 0.4, 923 - fill: false, 924 - pointRadius: 2, 925 - pointHoverRadius: 4, 926 - }; 927 - }); 928 - 929 - // Format labels based on time granularity 930 - const labels = data.map((timePoint) => { 580 + // Format labels based on granularity 581 + const labels = data.map((d) => { 931 582 if (days === 1) { 932 - return timePoint.time.split(" ")[1] || timePoint.time; 583 + // 15-minute intervals: show just time 584 + return d.date.split(" ")[1] || d.date; 933 585 } else if (days <= 7) { 934 - const parts = timePoint.time.split(" "); 935 - const date = parts[0].split("-")[2]; 586 + // Hourly: show date + hour 587 + const parts = d.date.split(" "); 588 + const date = parts[0].split("-")[2]; // Get day 936 589 const hour = parts[1] || "00:00"; 937 590 return `${date} ${hour}`; 938 591 } else { 939 - return timePoint.time; 592 + // 4-hour intervals: show abbreviated 593 + return d.date.split(" ")[0]; 940 594 } 941 595 }); 942 596 943 - charts.trafficOverview = new Chart(ctx, { 597 + charts.requests = new Chart(ctx, { 944 598 type: "line", 945 599 data: { 946 600 labels: labels, 947 - datasets: datasets, 601 + datasets: [{ 602 + label: "Requests", 603 + data: data.map((d) => d.count), 604 + borderColor: "#6366f1", 605 + backgroundColor: "rgba(99, 102, 241, 0.1)", 606 + tension: 0.4, 607 + fill: true, 608 + borderWidth: 1.5, 609 + pointRadius: 1, 610 + pointBackgroundColor: "#6366f1", 611 + }], 948 612 }, 949 613 options: { 950 614 responsive: true, 951 615 maintainAspectRatio: false, 952 - layout: { 953 - padding: { 954 - left: 10, 955 - right: 10, 956 - top: 10, 957 - bottom: 50 958 - } 959 - }, 960 - animation: { 961 - duration: 750, 962 - easing: 'easeInOutQuart' 963 - }, 964 - interaction: { 965 - mode: "index", 966 - intersect: false, 967 - }, 968 616 plugins: { 969 - legend: { 970 - position: "top", 971 - labels: { 972 - usePointStyle: true, 973 - padding: 15, 974 - font: { 975 - size: 11, 976 - }, 977 - boxWidth: 12, 978 - boxHeight: 12, 979 - generateLabels: function(chart) { 980 - const original = Chart.defaults.plugins.legend.labels.generateLabels; 981 - const labels = original.call(this, chart); 982 - 983 - // Add total request count to legend labels 984 - labels.forEach((label, index) => { 985 - const dataset = chart.data.datasets[index]; 986 - const total = dataset.data.reduce((sum, val) => sum + val, 0); 987 - label.text += ` (${total.toLocaleString()})`; 988 - }); 989 - 990 - return labels; 991 - } 992 - }, 993 - }, 617 + legend: { display: false }, 994 618 tooltip: { 995 - mode: "index", 996 - intersect: false, 997 - backgroundColor: 'rgba(0, 0, 0, 0.8)', 998 - titleColor: 'white', 999 - bodyColor: 'white', 1000 - borderColor: 'rgba(255, 255, 255, 0.1)', 1001 - borderWidth: 1, 1002 619 callbacks: { 1003 620 title: function(context) { 1004 - return `Time: ${context[0].label}`; 621 + const original = data[context[0].dataIndex]; 622 + if (days === 1) return `Time: ${original.date}`; 623 + if (days <= 7) return `DateTime: ${original.date}`; 624 + return `Interval: ${original.date}`; 1005 625 }, 1006 - afterBody: function(context) { 1007 - const timePoint = data[context[0].dataIndex]; 1008 - return [ 1009 - `Total Requests: ${timePoint.total.toLocaleString()}`, 1010 - `Peak Route: ${Object.entries(timePoint.routes).sort((a, b) => b[1] - a[1])[0]?.[0] || 'N/A'}` 1011 - ]; 1012 - }, 1013 - }, 1014 - }, 626 + label: function(context) { 627 + return `Requests: ${context.parsed.y.toLocaleString()}`; 628 + } 629 + } 630 + } 1015 631 }, 1016 632 scales: { 1017 633 x: { 1018 - display: true, 1019 634 title: { 1020 635 display: true, 1021 - text: days === 1 ? "Hour" : days <= 7 ? "Day & Hour" : "Date", 1022 - font: { 1023 - weight: 'bold' 1024 - } 636 + text: days === 1 ? 'Time (15min intervals)' : days <= 7 ? 'Time (hourly)' : 'Time (4hr intervals)' 1025 637 }, 638 + grid: { color: 'rgba(0, 0, 0, 0.05)' }, 1026 639 ticks: { 1027 - maxTicksLimit: window.innerWidth < 768 ? 8 : 20, 1028 - maxRotation: 0, // Don't rotate labels 1029 - minRotation: 0, 1030 - callback: function(value, index, values) { 1031 - const label = this.getLabelForValue(value); 1032 - // Truncate long labels 1033 - if (label.length > 8) { 1034 - if (days === 1) { 1035 - return label; // Hours are usually short 1036 - } else { 1037 - // For longer periods, abbreviate 1038 - return label.substring(0, 6) + '...'; 1039 - } 1040 - } 1041 - return label; 1042 - } 1043 - }, 1044 - grid: { 1045 - color: 'rgba(0, 0, 0, 0.05)', 1046 - }, 640 + maxTicksLimit: days === 1 ? 12 : 20, 641 + maxRotation: 0, 642 + minRotation: 0 643 + } 1047 644 }, 1048 645 y: { 1049 - display: true, 1050 - title: { 1051 - display: true, 1052 - text: "Requests", 1053 - font: { 1054 - weight: 'bold' 1055 - } 1056 - }, 646 + title: { display: true, text: 'Requests' }, 1057 647 beginAtZero: true, 1058 - grid: { 1059 - color: 'rgba(0, 0, 0, 0.05)', 1060 - }, 1061 - ticks: { 1062 - callback: function(value) { 1063 - return value.toLocaleString(); 1064 - } 1065 - } 1066 - }, 1067 - }, 1068 - elements: { 1069 - line: { 1070 - tension: 0.4, 1071 - }, 1072 - point: { 1073 - radius: 3, 1074 - hoverRadius: 6, 1075 - hitRadius: 10, 1076 - }, 1077 - }, 1078 - }, 648 + grid: { color: 'rgba(0, 0, 0, 0.05)' } 649 + } 650 + } 651 + } 1079 652 }); 1080 653 } 1081 654 1082 - function updateTimeChart(data, isHourly) { 1083 - const canvas = document.getElementById("timeChart"); 1084 - if (!canvas) { 1085 - console.warn('timeChart canvas not found'); 1086 - return; 1087 - } 1088 - 1089 - const ctx = canvas.getContext("2d"); 1090 - if (!ctx) { 1091 - console.warn('Could not get 2d context for timeChart'); 1092 - return; 1093 - } 655 + // Latency Over Time Chart 656 + function updateLatencyChart(data, isHourly) { 657 + const ctx = document.getElementById("latencyChart").getContext("2d"); 658 + const days = parseInt(document.getElementById("daysSelect").value); 1094 659 1095 - if (charts.time) charts.time.destroy(); 660 + if (charts.latency) charts.latency.destroy(); 1096 661 1097 - const chartTitle = document 1098 - .querySelector("#timeChart") 1099 - .parentElement.querySelector(".chart-title"); 1100 - if (chartTitle) { 1101 - chartTitle.textContent = isHourly 1102 - ? "Requests Over Time (Hourly)" 1103 - : "Requests Over Time (Daily)"; 1104 - } 662 + // Format labels based on granularity 663 + const labels = data.map((d) => { 664 + if (days === 1) { 665 + // 15-minute intervals: show just time 666 + return d.time.split(" ")[1] || d.time; 667 + } else if (days <= 7) { 668 + // Hourly: show date + hour 669 + const parts = d.time.split(" "); 670 + const date = parts[0].split("-")[2]; // Get day 671 + const hour = parts[1] || "00:00"; 672 + return `${date} ${hour}`; 673 + } else { 674 + // 4-hour intervals: show abbreviated 675 + return d.time.split(" ")[0]; 676 + } 677 + }); 1105 678 1106 - charts.time = new Chart(ctx, { 679 + charts.latency = new Chart(ctx, { 1107 680 type: "line", 1108 681 data: { 1109 - labels: data.map((d) => (isHourly ? d.date.split(" ")[1] : d.date)), 1110 - datasets: [ 1111 - { 1112 - label: "Requests", 1113 - data: data.map((d) => d.count), 1114 - borderColor: "#3b82f6", 1115 - backgroundColor: "rgba(59, 130, 246, 0.1)", 1116 - tension: 0.4, 1117 - fill: true, 1118 - pointRadius: 4, 1119 - pointHoverRadius: 6, 1120 - pointBackgroundColor: "#3b82f6", 1121 - pointBorderColor: "#ffffff", 1122 - pointBorderWidth: 2, 1123 - }, 1124 - ], 682 + labels: labels, 683 + datasets: [{ 684 + label: "Average Response Time", 685 + data: data.map((d) => d.averageResponseTime), 686 + borderColor: "#10b981", 687 + backgroundColor: "rgba(16, 185, 129, 0.1)", 688 + tension: 0.4, 689 + fill: true, 690 + borderWidth: 1.5, 691 + pointRadius: 1, 692 + pointBackgroundColor: "#10b981", 693 + }], 1125 694 }, 1126 695 options: { 1127 696 responsive: true, 1128 697 maintainAspectRatio: false, 1129 - layout: { 1130 - padding: { 1131 - left: 10, 1132 - right: 10, 1133 - top: 10, 1134 - bottom: 50 // Extra space for rotated labels 1135 - } 1136 - }, 1137 - animation: { 1138 - duration: 500, 1139 - easing: 'easeInOutQuart' 1140 - }, 1141 698 plugins: { 699 + legend: { display: false }, 1142 700 tooltip: { 1143 - backgroundColor: 'rgba(0, 0, 0, 0.8)', 1144 - titleColor: 'white', 1145 - bodyColor: 'white', 1146 - borderColor: 'rgba(255, 255, 255, 0.1)', 1147 - borderWidth: 1, 1148 701 callbacks: { 1149 702 title: function(context) { 1150 - const point = data[context[0].dataIndex]; 1151 - return isHourly ? `Hour: ${context[0].label}` : `Date: ${context[0].label}`; 703 + const original = data[context[0].dataIndex]; 704 + if (days === 1) return `Time: ${original.time}`; 705 + if (days <= 7) return `DateTime: ${original.time}`; 706 + return `Interval: ${original.time}`; 1152 707 }, 1153 708 label: function(context) { 1154 709 const point = data[context.dataIndex]; 1155 710 return [ 1156 - `Requests: ${context.parsed.y.toLocaleString()}`, 1157 - `Avg Response Time: ${Math.round(point.averageResponseTime || 0)}ms` 711 + `Response Time: ${Math.round(context.parsed.y)}ms`, 712 + `Request Count: ${point.count.toLocaleString()}` 1158 713 ]; 1159 714 } 1160 715 } 1161 - }, 1162 - legend: { 1163 - labels: { 1164 - generateLabels: function(chart) { 1165 - const total = data.reduce((sum, d) => sum + d.count, 0); 1166 - const avg = Math.round(data.reduce((sum, d) => sum + (d.averageResponseTime || 0), 0) / data.length); 1167 - return [{ 1168 - text: `Requests (Total: ${total.toLocaleString()}, Avg RT: ${avg}ms)`, 1169 - fillStyle: '#3b82f6', 1170 - strokeStyle: '#3b82f6', 1171 - lineWidth: 2, 1172 - pointStyle: 'circle' 1173 - }]; 1174 - } 1175 - } 1176 716 } 1177 717 }, 1178 718 scales: { 1179 719 x: { 1180 720 title: { 1181 721 display: true, 1182 - text: isHourly ? 'Hour of Day' : 'Date', 1183 - font: { weight: 'bold' } 1184 - }, 1185 - ticks: { 1186 - maxTicksLimit: window.innerWidth < 768 ? 6 : 12, 1187 - maxRotation: 0, // Don't rotate labels 1188 - minRotation: 0, 1189 - callback: function(value, index, values) { 1190 - const label = this.getLabelForValue(value); 1191 - // Truncate long labels for better fit 1192 - if (isHourly) { 1193 - return label; // Hours are short 1194 - } else { 1195 - // For dates, show abbreviated format 1196 - const date = new Date(label); 1197 - return date.toLocaleDateString('en-US', { month: 'short', day: 'numeric' }); 1198 - } 1199 - } 1200 - }, 1201 - grid: { 1202 - color: 'rgba(0, 0, 0, 0.05)', 1203 - }, 1204 - }, 1205 - y: { 1206 - title: { 1207 - display: true, 1208 - text: 'Number of Requests', 1209 - font: { weight: 'bold' } 722 + text: days === 1 ? 'Time (15min intervals)' : days <= 7 ? 'Time (hourly)' : 'Time (4hr intervals)' 1210 723 }, 1211 - beginAtZero: true, 1212 - grid: { 1213 - color: 'rgba(0, 0, 0, 0.05)', 1214 - }, 724 + grid: { color: 'rgba(0, 0, 0, 0.05)' }, 1215 725 ticks: { 1216 - callback: function(value) { 1217 - return value.toLocaleString(); 1218 - } 726 + maxTicksLimit: days === 1 ? 12 : 20, 727 + maxRotation: 0, 728 + minRotation: 0 1219 729 } 1220 730 }, 1221 - }, 1222 - }, 1223 - }); 1224 - } 1225 - 1226 - function updateEndpointChart(data) { 1227 - const canvas = document.getElementById("endpointChart"); 1228 - if (!canvas) { 1229 - console.warn('endpointChart canvas not found'); 1230 - return; 1231 - } 1232 - 1233 - const ctx = canvas.getContext("2d"); 1234 - if (!ctx) { 1235 - console.warn('Could not get 2d context for endpointChart'); 1236 - return; 1237 - } 1238 - 1239 - if (charts.endpoint) charts.endpoint.destroy(); 1240 - 1241 - charts.endpoint = new Chart(ctx, { 1242 - type: "bar", 1243 - data: { 1244 - labels: data.map((d) => d.endpoint), 1245 - datasets: [ 1246 - { 1247 - label: "Requests", 1248 - data: data.map((d) => d.count), 1249 - backgroundColor: "#10b981", 1250 - borderRadius: 4, 1251 - }, 1252 - ], 1253 - }, 1254 - options: { 1255 - responsive: true, 1256 - maintainAspectRatio: false, 1257 - layout: { 1258 - padding: { 1259 - left: 10, 1260 - right: 10, 1261 - top: 10, 1262 - bottom: 60 // Extra space for labels 1263 - } 1264 - }, 1265 - animation: { 1266 - duration: 500, 1267 - easing: 'easeInOutQuart' 1268 - }, 1269 - indexAxis: "x", 1270 - scales: { 1271 - x: { 1272 - title: { 1273 - display: true, 1274 - text: 'Endpoints', 1275 - font: { weight: 'bold' } 1276 - }, 1277 - ticks: { 1278 - maxRotation: 0, // Don't rotate 1279 - minRotation: 0, 1280 - callback: function(value, index, values) { 1281 - const label = this.getLabelForValue(value); 1282 - // Truncate long labels but show full in tooltip 1283 - return label.length > 12 ? label.substring(0, 9) + '...' : label; 1284 - } 1285 - }, 1286 - grid: { 1287 - color: 'rgba(0, 0, 0, 0.05)', 1288 - }, 1289 - }, 1290 731 y: { 1291 - title: { 1292 - display: true, 1293 - text: 'Number of Requests', 1294 - font: { weight: 'bold' } 1295 - }, 1296 - beginAtZero: true, 1297 - grid: { 1298 - color: 'rgba(0, 0, 0, 0.05)', 1299 - }, 732 + type: 'logarithmic', 733 + title: { display: true, text: 'Response Time (ms, log scale)' }, 734 + min: 1, 735 + grid: { color: 'rgba(0, 0, 0, 0.05)' }, 1300 736 ticks: { 1301 737 callback: function(value) { 1302 - return value.toLocaleString(); 1303 - } 1304 - } 1305 - }, 1306 - }, 1307 - plugins: { 1308 - tooltip: { 1309 - backgroundColor: 'rgba(0, 0, 0, 0.8)', 1310 - titleColor: 'white', 1311 - bodyColor: 'white', 1312 - borderColor: 'rgba(255, 255, 255, 0.1)', 1313 - borderWidth: 1, 1314 - callbacks: { 1315 - title: function(context) { 1316 - return context[0].label; // Show full label in tooltip 1317 - }, 1318 - label: function(context) { 1319 - return `Requests: ${context.parsed.y.toLocaleString()}`; 1320 - } 1321 - } 1322 - }, 1323 - legend: { 1324 - labels: { 1325 - generateLabels: function(chart) { 1326 - const total = data.reduce((sum, d) => sum + d.count, 0); 1327 - return [{ 1328 - text: `Total Requests: ${total.toLocaleString()}`, 1329 - fillStyle: '#10b981', 1330 - strokeStyle: '#10b981', 1331 - lineWidth: 2, 1332 - pointStyle: 'rect' 1333 - }]; 738 + // Show clean numbers: 1, 10, 100, 1000, etc. 739 + if (value === 1 || value === 10 || value === 100 || value === 1000 || value === 10000) { 740 + return value + 'ms'; 741 + } 742 + return ''; 1334 743 } 1335 744 } 1336 745 } 1337 - }, 1338 - }, 746 + } 747 + } 1339 748 }); 1340 749 } 1341 750 1342 - function updateStatusChart(data) { 1343 - const ctx = document.getElementById("statusChart").getContext("2d"); 751 + // User Agents Table 752 + let allUserAgents = []; 1344 753 1345 - if (charts.status) charts.status.destroy(); 754 + function updateUserAgentsTable(userAgents) { 755 + allUserAgents = userAgents; 756 + renderUserAgentsTable(userAgents); 757 + setupUserAgentSearch(); 758 + } 1346 759 1347 - const colors = data.map((d) => { 1348 - if (d.status >= 200 && d.status < 300) return "#10b981"; 1349 - if (d.status >= 300 && d.status < 400) return "#f59e0b"; 1350 - if (d.status >= 400 && d.status < 500) return "#ef4444"; 1351 - return "#8b5cf6"; 1352 - }); 760 + function parseUserAgent(ua) { 761 + // Keep strange/unique ones as-is 762 + if (ua.length < 50 || 763 + !ua.includes('Mozilla/') || 764 + ua.includes('bot') || 765 + ua.includes('crawler') || 766 + ua.includes('spider') || 767 + !ua.includes('AppleWebKit') || 768 + ua.includes('Shiba-Arcade') || 769 + ua === 'node' || 770 + ua.includes('curl') || 771 + ua.includes('python') || 772 + ua.includes('PostmanRuntime')) { 773 + return ua; 774 + } 1353 775 1354 - charts.status = new Chart(ctx, { 1355 - type: "doughnut", 1356 - data: { 1357 - labels: data.map((d) => `${d.status}`), 1358 - datasets: [ 1359 - { 1360 - data: data.map((d) => d.count), 1361 - backgroundColor: colors, 1362 - borderWidth: 2, 1363 - borderColor: '#fff' 1364 - }, 1365 - ], 1366 - }, 1367 - options: { 1368 - responsive: true, 1369 - animation: { 1370 - duration: 500, 1371 - easing: 'easeInOutQuart' 1372 - }, 1373 - }, 1374 - }); 1375 - } 776 + // Parse common browsers 777 + const os = ua.includes('Macintosh') ? 'macOS' : 778 + ua.includes('Windows NT 10.0') ? 'Windows 10' : 779 + ua.includes('Windows NT') ? 'Windows' : 780 + ua.includes('X11; Linux') ? 'Linux' : 781 + ua.includes('iPhone') ? 'iOS' : 782 + ua.includes('Android') ? 'Android' : 'Unknown OS'; 1376 783 1377 - function updateUserAgentChart(data) { 1378 - const ctx = document.getElementById("userAgentChart").getContext("2d"); 784 + // Detect browser and version 785 + let browser = 'Unknown Browser'; 1379 786 1380 - if (charts.userAgent) charts.userAgent.destroy(); 787 + if (ua.includes('Edg/')) { 788 + const match = ua.match(/Edg\/(\d+\.\d+)/); 789 + const version = match ? match[1] : ''; 790 + browser = `Edge ${version}`; 791 + } else if (ua.includes('Chrome/')) { 792 + const match = ua.match(/Chrome\/(\d+\.\d+)/); 793 + const version = match ? match[1] : ''; 794 + browser = `Chrome ${version}`; 795 + } else if (ua.includes('Firefox/')) { 796 + const match = ua.match(/Firefox\/(\d+\.\d+)/); 797 + const version = match ? match[1] : ''; 798 + browser = `Firefox ${version}`; 799 + } else if (ua.includes('Safari/') && !ua.includes('Chrome')) { 800 + browser = 'Safari'; 801 + } 1381 802 1382 - charts.userAgent = new Chart(ctx, { 1383 - type: "pie", 1384 - data: { 1385 - labels: data.map((d) => d.userAgent), 1386 - datasets: [ 1387 - { 1388 - data: data.map((d) => d.count), 1389 - backgroundColor: [ 1390 - "#3b82f6", 1391 - "#ef4444", 1392 - "#10b981", 1393 - "#f59e0b", 1394 - "#8b5cf6", 1395 - "#6b7280", 1396 - "#06b6d4", 1397 - "#84cc16", 1398 - "#f97316", 1399 - "#64748b", 1400 - ], 1401 - borderWidth: 2, 1402 - borderColor: '#fff' 1403 - }, 1404 - ], 1405 - }, 1406 - options: { 1407 - responsive: true, 1408 - animation: { 1409 - duration: 500, 1410 - easing: 'easeInOutQuart' 1411 - }, 1412 - }, 1413 - }); 803 + return `${browser} (${os})`; 1414 804 } 1415 805 1416 - function updateLatencyTimeChart(data, isHourly) { 1417 - const ctx = document.getElementById("latencyTimeChart").getContext("2d"); 1418 - 1419 - if (charts.latencyTime) charts.latencyTime.destroy(); 806 + function renderUserAgentsTable(userAgents) { 807 + const container = document.getElementById("userAgentsTable"); 1420 808 1421 - const chartTitle = document 1422 - .querySelector("#latencyTimeChart") 1423 - .parentElement.querySelector(".chart-title"); 1424 - if (chartTitle) { 1425 - chartTitle.textContent = isHourly 1426 - ? "Latency Over Time (Hourly)" 1427 - : "Latency Over Time (Daily)"; 809 + if (userAgents.length === 0) { 810 + container.innerHTML = '<div class="no-results">No user agents found</div>'; 811 + return; 1428 812 } 1429 813 1430 - charts.latencyTime = new Chart(ctx, { 1431 - type: "line", 1432 - data: { 1433 - labels: data.map((d) => (isHourly ? d.time.split(" ")[1] : d.time)), 1434 - datasets: [ 1435 - { 1436 - label: "Average Response Time", 1437 - data: data.map((d) => d.averageResponseTime), 1438 - borderColor: "#3b82f6", 1439 - backgroundColor: "rgba(59, 130, 246, 0.1)", 1440 - tension: 0.4, 1441 - yAxisID: "y", 1442 - pointRadius: 4, 1443 - pointHoverRadius: 6, 1444 - pointBackgroundColor: "#3b82f6", 1445 - pointBorderColor: "#ffffff", 1446 - pointBorderWidth: 2, 1447 - }, 1448 - { 1449 - label: "P95 Response Time", 1450 - data: data.map((d) => d.p95), 1451 - borderColor: "#ef4444", 1452 - backgroundColor: "rgba(239, 68, 68, 0.1)", 1453 - tension: 0.4, 1454 - yAxisID: "y", 1455 - pointRadius: 4, 1456 - pointHoverRadius: 6, 1457 - pointBackgroundColor: "#ef4444", 1458 - pointBorderColor: "#ffffff", 1459 - pointBorderWidth: 2, 1460 - }, 1461 - ], 1462 - }, 1463 - options: { 1464 - responsive: true, 1465 - maintainAspectRatio: false, 1466 - layout: { 1467 - padding: { 1468 - left: 10, 1469 - right: 10, 1470 - top: 10, 1471 - bottom: 50 1472 - } 1473 - }, 1474 - animation: { 1475 - duration: 500, 1476 - easing: 'easeInOutQuart' 1477 - }, 1478 - plugins: { 1479 - tooltip: { 1480 - backgroundColor: 'rgba(0, 0, 0, 0.8)', 1481 - titleColor: 'white', 1482 - bodyColor: 'white', 1483 - borderColor: 'rgba(255, 255, 255, 0.1)', 1484 - borderWidth: 1, 1485 - callbacks: { 1486 - title: function(context) { 1487 - return isHourly ? `Hour: ${context[0].label}` : `Date: ${context[0].label}`; 1488 - }, 1489 - afterBody: function(context) { 1490 - const point = data[context[0].dataIndex]; 1491 - return [ 1492 - `Request Count: ${point.count.toLocaleString()}`, 1493 - `Performance: ${point.averageResponseTime < 100 ? 'Excellent' : point.averageResponseTime < 300 ? 'Good' : 'Needs Attention'}` 1494 - ]; 1495 - } 1496 - } 1497 - }, 1498 - legend: { 1499 - labels: { 1500 - generateLabels: function(chart) { 1501 - const avgAvg = Math.round(data.reduce((sum, d) => sum + d.averageResponseTime, 0) / data.length); 1502 - const avgP95 = Math.round(data.reduce((sum, d) => sum + (d.p95 || 0), 0) / data.length); 1503 - return [ 1504 - { 1505 - text: `Average Response Time (Overall: ${avgAvg}ms)`, 1506 - fillStyle: '#3b82f6', 1507 - strokeStyle: '#3b82f6', 1508 - lineWidth: 2, 1509 - pointStyle: 'circle' 1510 - }, 1511 - { 1512 - text: `P95 Response Time (Overall: ${avgP95}ms)`, 1513 - fillStyle: '#ef4444', 1514 - strokeStyle: '#ef4444', 1515 - lineWidth: 2, 1516 - pointStyle: 'circle' 1517 - } 1518 - ]; 1519 - } 1520 - } 1521 - } 1522 - }, 1523 - scales: { 1524 - x: { 1525 - title: { 1526 - display: true, 1527 - text: isHourly ? 'Hour of Day' : 'Date', 1528 - font: { weight: 'bold' } 1529 - }, 1530 - ticks: { 1531 - maxTicksLimit: window.innerWidth < 768 ? 6 : 12, 1532 - maxRotation: 0, 1533 - minRotation: 0, 1534 - callback: function(value, index, values) { 1535 - const label = this.getLabelForValue(value); 1536 - if (isHourly) { 1537 - return label; 1538 - } else { 1539 - const date = new Date(label); 1540 - return date.toLocaleDateString('en-US', { month: 'short', day: 'numeric' }); 1541 - } 1542 - } 1543 - }, 1544 - grid: { 1545 - color: 'rgba(0, 0, 0, 0.05)', 1546 - }, 1547 - }, 1548 - y: { 1549 - title: { 1550 - display: true, 1551 - text: "Response Time (ms)", 1552 - font: { weight: 'bold' } 1553 - }, 1554 - beginAtZero: true, 1555 - grid: { 1556 - color: 'rgba(0, 0, 0, 0.05)', 1557 - }, 1558 - ticks: { 1559 - callback: function(value) { 1560 - return Math.round(value) + 'ms'; 1561 - } 1562 - } 1563 - }, 1564 - }, 1565 - }, 1566 - }); 1567 - } 814 + const totalRequests = userAgents.reduce((sum, ua) => sum + ua.count, 0); 1568 815 1569 - function updateLatencyDistributionChart(data) { 1570 - const ctx = document.getElementById("latencyDistributionChart").getContext("2d"); 816 + const tableHTML = ` 817 + <table class="ua-table"> 818 + <thead> 819 + <tr> 820 + <th style="width: 50%">User Agent</th> 821 + <th style="width: 20%">Requests</th> 822 + <th style="width: 15%">Percentage</th> 823 + </tr> 824 + </thead> 825 + <tbody> 826 + ${userAgents.map(ua => { 827 + const displayName = parseUserAgent(ua.userAgent); 828 + const percentage = ((ua.count / totalRequests) * 100).toFixed(1); 1571 829 1572 - if (charts.latencyDistribution) charts.latencyDistribution.destroy(); 830 + return ` 831 + <tr> 832 + <td> 833 + <div class="ua-name">${displayName}</div> 834 + <div class="ua-raw">${ua.userAgent}</div> 835 + </td> 836 + <td class="ua-count">${ua.count.toLocaleString()}</td> 837 + <td class="ua-percentage">${percentage}%</td> 838 + </tr> 839 + `; 840 + }).join('')} 841 + </tbody> 842 + </table> 843 + `; 1573 844 1574 - charts.latencyDistribution = new Chart(ctx, { 1575 - type: "bar", 1576 - data: { 1577 - labels: data.map((d) => d.range), 1578 - datasets: [ 1579 - { 1580 - label: "Requests", 1581 - data: data.map((d) => d.count), 1582 - backgroundColor: "#10b981", 1583 - borderRadius: 4, 1584 - }, 1585 - ], 1586 - }, 1587 - options: { 1588 - responsive: true, 1589 - animation: { 1590 - duration: 500, 1591 - easing: 'easeInOutQuart' 1592 - }, 1593 - scales: { 1594 - y: { 1595 - beginAtZero: true, 1596 - }, 1597 - }, 1598 - }, 1599 - }); 845 + container.innerHTML = tableHTML; 1600 846 } 1601 847 1602 - function updatePercentilesChart(percentiles) { 1603 - const ctx = document.getElementById("percentilesChart").getContext("2d"); 848 + function setupUserAgentSearch() { 849 + const searchInput = document.getElementById('userAgentSearch'); 1604 850 1605 - if (charts.percentiles) charts.percentiles.destroy(); 851 + searchInput.addEventListener('input', function() { 852 + const searchTerm = this.value.toLowerCase().trim(); 1606 853 1607 - const data = [ 1608 - { label: "P50 (Median)", value: percentiles.p50 }, 1609 - { label: "P75", value: percentiles.p75 }, 1610 - { label: "P90", value: percentiles.p90 }, 1611 - { label: "P95", value: percentiles.p95 }, 1612 - { label: "P99", value: percentiles.p99 }, 1613 - ].filter((d) => d.value !== null); 854 + if (searchTerm === '') { 855 + renderUserAgentsTable(allUserAgents); 856 + return; 857 + } 1614 858 1615 - charts.percentiles = new Chart(ctx, { 1616 - type: "bar", 1617 - data: { 1618 - labels: data.map((d) => d.label), 1619 - datasets: [ 1620 - { 1621 - label: "Response Time (ms)", 1622 - data: data.map((d) => d.value), 1623 - backgroundColor: [ 1624 - "#10b981", // P50 - Green (good) 1625 - "#3b82f6", // P75 - Blue 1626 - "#f59e0b", // P90 - Yellow (warning) 1627 - "#ef4444", // P95 - Red (concerning) 1628 - "#8b5cf6", // P99 - Purple (critical) 1629 - ], 1630 - borderRadius: 4, 1631 - borderWidth: 2, 1632 - borderColor: '#ffffff', 1633 - }, 1634 - ], 1635 - }, 1636 - options: { 1637 - responsive: true, 1638 - maintainAspectRatio: false, 1639 - animation: { 1640 - duration: 500, 1641 - easing: 'easeInOutQuart' 1642 - }, 1643 - plugins: { 1644 - tooltip: { 1645 - backgroundColor: 'rgba(0, 0, 0, 0.8)', 1646 - titleColor: 'white', 1647 - bodyColor: 'white', 1648 - borderColor: 'rgba(255, 255, 255, 0.1)', 1649 - borderWidth: 1, 1650 - callbacks: { 1651 - label: function(context) { 1652 - const percentile = context.label; 1653 - const value = Math.round(context.parsed.y); 1654 - let interpretation = ''; 1655 - 1656 - if (percentile.includes('P50')) { 1657 - interpretation = '50% of requests are faster than this'; 1658 - } else if (percentile.includes('P95')) { 1659 - interpretation = '95% of requests are faster than this'; 1660 - } else if (percentile.includes('P99')) { 1661 - interpretation = '99% of requests are faster than this'; 1662 - } 1663 - 1664 - return [ 1665 - `${percentile}: ${value}ms`, 1666 - interpretation 1667 - ]; 1668 - } 1669 - } 1670 - }, 1671 - legend: { 1672 - display: false // Hide legend since colors are self-explanatory 1673 - } 1674 - }, 1675 - scales: { 1676 - x: { 1677 - title: { 1678 - display: true, 1679 - text: 'Response Time Percentiles', 1680 - font: { weight: 'bold' } 1681 - }, 1682 - grid: { 1683 - color: 'rgba(0, 0, 0, 0.05)', 1684 - }, 1685 - }, 1686 - y: { 1687 - title: { 1688 - display: true, 1689 - text: 'Response Time (ms)', 1690 - font: { weight: 'bold' } 1691 - }, 1692 - beginAtZero: true, 1693 - grid: { 1694 - color: 'rgba(0, 0, 0, 0.05)', 1695 - }, 1696 - ticks: { 1697 - callback: function(value) { 1698 - return Math.round(value) + 'ms'; 1699 - } 1700 - } 1701 - }, 1702 - }, 1703 - }, 1704 - }); 1705 - } 859 + const filtered = allUserAgents.filter(ua => { 860 + const displayName = parseUserAgent(ua.userAgent).toLowerCase(); 861 + const rawUA = ua.userAgent.toLowerCase(); 862 + return displayName.includes(searchTerm) || rawUA.includes(searchTerm); 863 + }); 1706 864 1707 - function updateSlowestEndpointsChart(data) { 1708 - const ctx = document.getElementById("slowestEndpointsChart").getContext("2d"); 1709 - 1710 - if (charts.slowestEndpoints) charts.slowestEndpoints.destroy(); 1711 - 1712 - charts.slowestEndpoints = new Chart(ctx, { 1713 - type: "bar", 1714 - data: { 1715 - labels: data.map((d) => d.endpoint), 1716 - datasets: [ 1717 - { 1718 - label: "Avg Response Time (ms)", 1719 - data: data.map((d) => d.averageResponseTime), 1720 - backgroundColor: "#ef4444", 1721 - borderRadius: 4, 1722 - }, 1723 - ], 1724 - }, 1725 - options: { 1726 - responsive: true, 1727 - maintainAspectRatio: false, 1728 - animation: { 1729 - duration: 500, 1730 - easing: 'easeInOutQuart' 1731 - }, 1732 - indexAxis: "x", // Changed from "y" to "x" to put labels on top 1733 - scales: { 1734 - x: { 1735 - title: { 1736 - display: true, 1737 - text: 'Endpoints', 1738 - font: { weight: 'bold' } 1739 - }, 1740 - ticks: { 1741 - maxRotation: 45, 1742 - minRotation: 45, 1743 - callback: function(value, index, values) { 1744 - const label = this.getLabelForValue(value); 1745 - return label.length > 15 ? label.substring(0, 12) + '...' : label; 1746 - } 1747 - }, 1748 - grid: { 1749 - color: 'rgba(0, 0, 0, 0.05)', 1750 - }, 1751 - }, 1752 - y: { 1753 - title: { 1754 - display: true, 1755 - text: 'Response Time (ms)', 1756 - font: { weight: 'bold' } 1757 - }, 1758 - beginAtZero: true, 1759 - grid: { 1760 - color: 'rgba(0, 0, 0, 0.05)', 1761 - }, 1762 - ticks: { 1763 - callback: function(value) { 1764 - return Math.round(value) + 'ms'; 1765 - } 1766 - } 1767 - }, 1768 - }, 1769 - plugins: { 1770 - tooltip: { 1771 - backgroundColor: 'rgba(0, 0, 0, 0.8)', 1772 - titleColor: 'white', 1773 - bodyColor: 'white', 1774 - borderColor: 'rgba(255, 255, 255, 0.1)', 1775 - borderWidth: 1, 1776 - callbacks: { 1777 - title: function(context) { 1778 - return context[0].label; // Show full label in tooltip 1779 - }, 1780 - label: function(context) { 1781 - const point = data[context.dataIndex]; 1782 - return [ 1783 - `Avg Response Time: ${Math.round(context.parsed.y)}ms`, 1784 - `Request Count: ${point.count.toLocaleString()}` 1785 - ]; 1786 - } 1787 - } 1788 - }, 1789 - legend: { 1790 - labels: { 1791 - generateLabels: function(chart) { 1792 - const avgTime = Math.round(data.reduce((sum, d) => sum + d.averageResponseTime, 0) / data.length); 1793 - return [{ 1794 - text: `Average Response Time: ${avgTime}ms`, 1795 - fillStyle: '#ef4444', 1796 - strokeStyle: '#ef4444', 1797 - lineWidth: 2, 1798 - pointStyle: 'rect' 1799 - }]; 1800 - } 1801 - } 1802 - } 1803 - }, 1804 - }, 865 + renderUserAgentsTable(filtered); 1805 866 }); 1806 867 } 1807 868 869 + // Event Handlers 1808 870 document.getElementById("autoRefresh").addEventListener("change", function () { 1809 871 if (this.checked) { 1810 872 autoRefreshInterval = setInterval(loadData, 30000); 1811 - showToast('Auto-refresh enabled', 'success'); 1812 873 } else { 1813 874 clearInterval(autoRefreshInterval); 1814 - showToast('Auto-refresh disabled', 'info'); 1815 875 } 1816 876 }); 1817 877 1818 - // Days selector change handler 1819 - document.getElementById("daysSelect").addEventListener("change", function() { 1820 - // Reset visible charts when changing time period 1821 - visibleCharts.clear(); 1822 - loadData(); 1823 - }); 878 + document.getElementById("daysSelect").addEventListener("change", loadData); 1824 879 1825 880 // Initialize dashboard 1826 - document.addEventListener('DOMContentLoaded', function() { 1827 - initLazyLoading(); 1828 - loadData(); 1829 - }); 881 + document.addEventListener('DOMContentLoaded', loadData); 1830 882 1831 883 // Cleanup on page unload 1832 884 window.addEventListener('beforeunload', function() { 1833 - if (intersectionObserver) { 1834 - intersectionObserver.disconnect(); 1835 - } 1836 885 clearInterval(autoRefreshInterval); 1837 - 1838 - // Destroy all charts to prevent memory leaks 1839 886 Object.values(charts).forEach(chart => { 1840 887 if (chart && typeof chart.destroy === 'function') { 1841 888 chart.destroy();
+121 -1
src/index.ts
··· 322 322 }, 323 323 }, 324 324 325 - // Stats endpoint 325 + // Fast essential stats endpoint - loads immediately 326 + "/api/stats/essential": { 327 + async GET(request) { 328 + const startTime = Date.now(); 329 + const recordAnalytics = async (statusCode: number) => { 330 + const userAgent = request.headers.get("user-agent") || ""; 331 + const ipAddress = 332 + request.headers.get("x-forwarded-for") || 333 + request.headers.get("x-real-ip") || 334 + "unknown"; 335 + 336 + await cache.recordRequest( 337 + "/api/stats/essential", 338 + "GET", 339 + statusCode, 340 + userAgent, 341 + ipAddress, 342 + Date.now() - startTime, 343 + ); 344 + }; 345 + 346 + return handleGetEssentialStats(request, recordAnalytics); 347 + }, 348 + }, 349 + 350 + // Chart data endpoint - loads after essential stats 351 + "/api/stats/charts": { 352 + async GET(request) { 353 + const startTime = Date.now(); 354 + const recordAnalytics = async (statusCode: number) => { 355 + const userAgent = request.headers.get("user-agent") || ""; 356 + const ipAddress = 357 + request.headers.get("x-forwarded-for") || 358 + request.headers.get("x-real-ip") || 359 + "unknown"; 360 + 361 + await cache.recordRequest( 362 + "/api/stats/charts", 363 + "GET", 364 + statusCode, 365 + userAgent, 366 + ipAddress, 367 + Date.now() - startTime, 368 + ); 369 + }; 370 + 371 + return handleGetChartData(request, recordAnalytics); 372 + }, 373 + }, 374 + 375 + // User agents endpoint - loads last 376 + "/api/stats/useragents": { 377 + async GET(request) { 378 + const startTime = Date.now(); 379 + const recordAnalytics = async (statusCode: number) => { 380 + const userAgent = request.headers.get("user-agent") || ""; 381 + const ipAddress = 382 + request.headers.get("x-forwarded-for") || 383 + request.headers.get("x-real-ip") || 384 + "unknown"; 385 + 386 + await cache.recordRequest( 387 + "/api/stats/useragents", 388 + "GET", 389 + statusCode, 390 + userAgent, 391 + ipAddress, 392 + Date.now() - startTime, 393 + ); 394 + }; 395 + 396 + return handleGetUserAgents(request, recordAnalytics); 397 + }, 398 + }, 399 + 400 + // Original stats endpoint (for backwards compatibility) 326 401 "/stats": { 327 402 async GET(request) { 328 403 const startTime = Date.now(); ··· 717 792 718 793 await recordAnalytics(200); 719 794 return Response.json(analytics); 795 + } 796 + 797 + // Fast essential stats - just the 3 key metrics 798 + async function handleGetEssentialStats( 799 + request: Request, 800 + recordAnalytics: (statusCode: number) => Promise<void>, 801 + ) { 802 + const url = new URL(request.url); 803 + const params = new URLSearchParams(url.search); 804 + const days = params.get("days") ? parseInt(params.get("days")!) : 7; 805 + 806 + const essentialStats = await cache.getEssentialStats(days); 807 + 808 + await recordAnalytics(200); 809 + return Response.json(essentialStats); 810 + } 811 + 812 + // Chart data - requests and latency over time 813 + async function handleGetChartData( 814 + request: Request, 815 + recordAnalytics: (statusCode: number) => Promise<void>, 816 + ) { 817 + const url = new URL(request.url); 818 + const params = new URLSearchParams(url.search); 819 + const days = params.get("days") ? parseInt(params.get("days")!) : 7; 820 + 821 + const chartData = await cache.getChartData(days); 822 + 823 + await recordAnalytics(200); 824 + return Response.json(chartData); 825 + } 826 + 827 + // User agents data - slowest loading part 828 + async function handleGetUserAgents( 829 + request: Request, 830 + recordAnalytics: (statusCode: number) => Promise<void>, 831 + ) { 832 + const url = new URL(request.url); 833 + const params = new URLSearchParams(url.search); 834 + const days = params.get("days") ? parseInt(params.get("days")!) : 7; 835 + 836 + const userAgents = await cache.getUserAgents(days); 837 + 838 + await recordAnalytics(200); 839 + return Response.json(userAgents); 720 840 } 721 841 722 842 // Setup cron jobs for cache maintenance