diff --git a/src/main/java/com/dedicatedcode/paikka/controller/StatsController.java b/src/main/java/com/dedicatedcode/paikka/controller/StatsController.java index ada8044..d090f0f 100644 --- a/src/main/java/com/dedicatedcode/paikka/controller/StatsController.java +++ b/src/main/java/com/dedicatedcode/paikka/controller/StatsController.java @@ -86,4 +86,10 @@ public ResponseEntity> getLocationStats List locationStats = statsService.getLocationStats(); return ResponseEntity.ok(locationStats); } + + @GetMapping("/api/query-summary") + @ResponseBody + public ResponseEntity getQuerySummary() { + return ResponseEntity.ok(statsService.getSummaryStats()); + } } diff --git a/src/main/java/com/dedicatedcode/paikka/service/StatsService.java b/src/main/java/com/dedicatedcode/paikka/service/StatsService.java index 1d390c4..26197e9 100644 --- a/src/main/java/com/dedicatedcode/paikka/service/StatsService.java +++ b/src/main/java/com/dedicatedcode/paikka/service/StatsService.java @@ -335,6 +335,49 @@ SELECT date_only, hour_bucket, COUNT(*) as query_count, return results; } + public SummaryStatsResponse getSummaryStats() { + LocalDate today = LocalDate.now(); + String sql = """ + SELECT COUNT(*) AS total_queries, + SUM(CASE WHEN date_only = ? THEN 1 ELSE 0 END) AS queries_today, + SUM(CASE WHEN date_only >= ? THEN 1 ELSE 0 END) AS queries_last_7_days, + SUM(CASE WHEN date_only >= ? THEN 1 ELSE 0 END) AS queries_last_30_days, + COUNT(DISTINCT CASE WHEN date_only >= ? THEN date_only END) AS active_days_last_30, + AVG(response_time_ms) AS avg_response_time, + SUM(CASE WHEN status_code >= 200 AND status_code < 300 THEN 1 ELSE 0 END) AS success_count, + SUM(CASE WHEN status_code >= 400 THEN 1 ELSE 0 END) AS error_count + FROM query_stats + """; + + try (PreparedStatement pstmt = connection.prepareStatement(sql)) { + pstmt.setString(1, today.toString()); + pstmt.setString(2, today.minusDays(6).toString()); + pstmt.setString(3, today.minusDays(29).toString()); + pstmt.setString(4, today.minusDays(29).toString()); + + try (ResultSet rs = pstmt.executeQuery()) { + if (rs.next()) { + long queriesLast30Days = rs.getLong("queries_last_30_days"); + long activeDays = rs.getLong("active_days_last_30"); + double avgQueriesPerDay = activeDays > 0 ? (double) queriesLast30Days / activeDays : 0.0; + return new SummaryStatsResponse( + rs.getLong("total_queries"), + rs.getLong("queries_today"), + rs.getLong("queries_last_7_days"), + avgQueriesPerDay, + rs.getLong("success_count"), + rs.getLong("error_count"), + rs.getDouble("avg_response_time") + ); + } + } + } catch (SQLException e) { + logger.error("Failed to retrieve summary stats", e); + } + + return new SummaryStatsResponse(0L, 0L, 0L, 0.0, 0L, 0L, 0.0); + } + public List getAvailableEndpoints() { String sql = "SELECT DISTINCT endpoint FROM query_stats ORDER BY endpoint"; List endpoints = new ArrayList<>(); @@ -422,6 +465,11 @@ void clearDatabase() { public record LocationStatsResponse(double lat, double lon, int queryCount, String lastQueried) { } + public record SummaryStatsResponse(long totalQueries, long queriesToday, long queriesLast7Days, + double avgQueriesPerDay, long successCount, long errorCount, + double avgResponseTimeMs) { + } + private record StatsRecord(String endpoint, String parametersJson, long responseTimeMs, int resultCount, LocalDate dateOnly, int hourBucket, int statusCode) { } diff --git a/src/main/resources/static/css/dark-theme.css b/src/main/resources/static/css/dark-theme.css index 05fc0df..92d2331 100644 --- a/src/main/resources/static/css/dark-theme.css +++ b/src/main/resources/static/css/dark-theme.css @@ -30,6 +30,7 @@ body { } .container { + position: relative; max-width: 1400px; margin: 0 auto; background: var(--bg-card); @@ -154,39 +155,53 @@ body { } .map-info { - padding: 30px; - background: var(--bg-secondary); - border-top: 1px solid var(--border-color); + position: absolute; + left: 15px; + right: 15px; + bottom: 32px; + padding: 8px 16px; + background: rgba(37, 37, 37, 0.88); + backdrop-filter: blur(8px); + -webkit-backdrop-filter: blur(8px); + border: 1px solid var(--border-color); + border-radius: 8px; + box-shadow: 0 4px 15px var(--shadow); + z-index: 900; } .info-grid { - display: grid; - grid-template-columns: repeat(auto-fit, minmax(200px, 1fr)); - gap: 25px; + display: flex; + flex-wrap: wrap; + align-items: center; + justify-content: space-between; + gap: 4px 24px; } .info-item { text-align: center; - padding: 20px; - background: var(--bg-tertiary); - border-radius: 8px; - border: 1px solid var(--border-color); + padding: 0; + background: transparent; + border: none; + min-width: 72px; } .info-value { - font-size: 2rem; + font-size: 1.05rem; font-weight: bold; color: var(--accent-primary); display: block; - margin-bottom: 8px; + margin-bottom: 0; + line-height: 1.2; + white-space: nowrap; } .info-label { - font-size: 0.9rem; + font-size: 0.6rem; color: var(--text-secondary); text-transform: uppercase; letter-spacing: 0.5px; font-weight: 500; + white-space: nowrap; } .legend { @@ -271,8 +286,19 @@ body { } .info-grid { - grid-template-columns: repeat(2, 1fr); - gap: 15px; + gap: 4px 14px; + justify-content: flex-start; + } + + .info-item { + min-width: 58px; + } + + .map-info { + left: 10px; + right: 10px; + bottom: 40px; + padding: 6px 10px; } .legend { diff --git a/src/main/resources/templates/map.html b/src/main/resources/templates/map.html index 166b458..84d8dda 100644 --- a/src/main/resources/templates/map.html +++ b/src/main/resources/templates/map.html @@ -6,7 +6,6 @@ Paikka Query Locations -
@@ -34,15 +33,39 @@

Query Activity

- -
Unique Locations
+
Locations
- -
Total Queries
+
Total Requests
+
+
+ - +
Today
+
+
+ - +
Last 7 Days
+
+
+ - +
Avg / Day (30d)
+
+
+ - +
Success Rate
+
+
+ - +
Errors
+
+
+ - +
Avg Response
- -
Avg Queries/Location
+
Avg / Location
- @@ -58,18 +81,10 @@

Query Activity

const map = L.map('map').setView([50.0, 10.0], 4); // Add dark tile layer as base - const darkLayer = L.tileLayer('https://{s}.basemaps.cartocdn.com/dark_all/{z}/{x}/{y}{r}.png', { - attribution: '© OpenStreetMap contributors, © CARTO', - subdomains: 'abcd', - maxZoom: 19 - }).addTo(map); - - // Add light tile layer for uncovering effect - const lightLayer = L.tileLayer('https://{s}.basemaps.cartocdn.com/light_all/{z}/{x}/{y}{r}.png', { - attribution: '© OpenStreetMap contributors, © CARTO', - subdomains: 'abcd', + const darkLayer = L.tileLayer('https://server.arcgisonline.com/ArcGIS/rest/services/Canvas/World_Dark_Gray_Base/MapServer/tile/{z}/{y}/{x}', { + attribution: 'Tiles © Esri — Source: Esri, HERE, Garmin, © OpenStreetMap contributors', maxZoom: 19, - opacity: 0 + maxNativeZoom: 16 }).addTo(map); // Function to get square opacity based on query count (for uncovering effect) @@ -155,14 +170,13 @@

Query Activity

// Update info panel const totalLocations = data.length; - const totalQueries = data.reduce((sum, loc) => sum + loc.queryCount, 0); - const avgQueries = totalLocations > 0 ? (totalQueries / totalLocations).toFixed(1) : 0; + const locationQueries = data.reduce((sum, loc) => sum + loc.queryCount, 0); + const avgQueries = totalLocations > 0 ? (locationQueries / totalLocations).toFixed(1) : 0; const lastUpdated = data.length > 0 ? new Date(Math.max(...data.map(d => new Date(d.lastQueried)))).toLocaleDateString() : 'No data'; document.getElementById('total-locations').textContent = totalLocations; - document.getElementById('total-queries').textContent = totalQueries; document.getElementById('avg-queries').textContent = avgQueries; document.getElementById('last-updated').textContent = lastUpdated; @@ -178,6 +192,23 @@

Query Activity

console.error('Error loading location data:', error); document.querySelector('.loading').textContent = 'Error loading location data'; }); + + // Load global query summary stats + fetch('/admin/api/query-summary') + .then(response => response.json()) + .then(summary => { + document.getElementById('total-queries').textContent = summary.totalQueries; + document.getElementById('queries-today').textContent = summary.queriesToday; + document.getElementById('queries-last-7-days').textContent = summary.queriesLast7Days; + document.getElementById('avg-per-day').textContent = summary.avgQueriesPerDay.toFixed(1); + const total = summary.successCount + summary.errorCount; + document.getElementById('success-rate').textContent = + total > 0 ? ((summary.successCount / total) * 100).toFixed(1) + '%' : '-'; + document.getElementById('error-count').textContent = summary.errorCount; + document.getElementById('avg-response-time').textContent = + Math.round(summary.avgResponseTimeMs) + ' ms'; + }) + .catch(error => console.error('Error loading query summary:', error)); diff --git a/src/test/java/com/dedicatedcode/paikka/service/StatsServiceIntegrationTest.java b/src/test/java/com/dedicatedcode/paikka/service/StatsServiceIntegrationTest.java index 7afada7..146a972 100644 --- a/src/test/java/com/dedicatedcode/paikka/service/StatsServiceIntegrationTest.java +++ b/src/test/java/com/dedicatedcode/paikka/service/StatsServiceIntegrationTest.java @@ -188,4 +188,27 @@ void testStatsAggregationWithEndpointFilter() { assertThat(reverseStats).isNotEmpty(); assertThat(reverseStats.get(0).getQueryCount()).isEqualTo(1); } + + @Test + void testGetSummaryStats() { + statsService.recordQuery("/api/v1/search", Map.of("q", "test1"), 100L, 5, 200); + statsService.recordQuery("/api/v1/search", Map.of("q", "test2"), 200L, 5, 404); + + await().atMost(15, TimeUnit.SECONDS) + .pollInterval(1, TimeUnit.SECONDS) + .until(() -> { + statsService.flushPendingStats(); + return statsService.getSummaryStats().totalQueries() >= 2; + }); + + StatsService.SummaryStatsResponse summary = statsService.getSummaryStats(); + + assertThat(summary.totalQueries()).isEqualTo(2); + assertThat(summary.queriesToday()).isEqualTo(2); + assertThat(summary.queriesLast7Days()).isEqualTo(2); + assertThat(summary.avgQueriesPerDay()).isEqualTo(2.0); + assertThat(summary.successCount()).isEqualTo(1); + assertThat(summary.errorCount()).isEqualTo(1); + assertThat(summary.avgResponseTimeMs()).isBetween(100.0, 200.0); + } } \ No newline at end of file