+530
-121
src/cache.ts
+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
+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 (<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
+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