diff --git a/nginxmon/templates/nginxmon/dashboard.html b/nginxmon/templates/nginxmon/dashboard.html
index 3763314..c9c8292 100644
--- a/nginxmon/templates/nginxmon/dashboard.html
+++ b/nginxmon/templates/nginxmon/dashboard.html
@@ -2,6 +2,8 @@
{% block content %}
+
+
@@ -413,4 +424,160 @@ function _applyToggleUI(enabled) {
es.onerror = function() { es.close(); setTimeout(startStatusStream, 5000); };
})();
+
+
{% endblock %}
diff --git a/nginxmon/urls.py b/nginxmon/urls.py
index 44ad6a0..5903c31 100644
--- a/nginxmon/urls.py
+++ b/nginxmon/urls.py
@@ -26,4 +26,5 @@ urlpatterns = [
path('api/ingest/', views.IngestLogsView.as_view(), name='nginxmon-ingest'),
path('api/toggle-ingestion/', views.ToggleIngestionView.as_view(), name='nginxmon-toggle'),
path('api/status-stream/', views.StatusStreamView.as_view(), name='nginxmon-status-stream'),
+ path('api/geo-stats/', views.GeoStatsView.as_view(), name='nginxmon-geo-stats'),
]
diff --git a/nginxmon/views.py b/nginxmon/views.py
index 7ae5e2c..73c023a 100644
--- a/nginxmon/views.py
+++ b/nginxmon/views.py
@@ -16,7 +16,7 @@ from django.views.generic import CreateView, DeleteView, TemplateView, UpdateVie
from .detector import detect_threats as run_detect
from .fetcher import fetch_and_store, ingest_raw
from .forms import NginxSettingsForm, NginxAlertProfileForm, PasteLogsForm
-from .models import NginxAccessLog, NginxAlertProfile, NginxSettings, ThreatAlert
+from .models import IPGeoCache, NginxAccessLog, NginxAlertProfile, NginxSettings, ThreatAlert
from .notifications import send_test_telegram
from .tasks import start_fetch_job, stop_fetch_job, schedule_profile, unschedule_profile
@@ -334,6 +334,42 @@ class ChartDataView(View):
})
+class GeoStatsView(View):
+ """Returns country-level aggregates + IP bubble data for the geo map."""
+ def get(self, request):
+ range_key, cfg, since = _get_range(request)
+ countries = list(
+ NginxAccessLog.objects
+ .filter(timestamp__gte=since).exclude(country_code='')
+ .values('country_code', 'country')
+ .annotate(total=Count('id'), errors=Count('id', filter=Q(status__gte=400)))
+ .order_by('-total')
+ )
+ ip_agg = list(
+ NginxAccessLog.objects.filter(timestamp__gte=since)
+ .values('remote_addr')
+ .annotate(total=Count('id'), errors=Count('id', filter=Q(status__gte=400)))
+ .order_by('-total')[:300]
+ )
+ ip_set = [r['remote_addr'] for r in ip_agg]
+ geo_cache = {
+ c.ip: (c.lat, c.lon, c.country, c.city)
+ for c in IPGeoCache.objects.filter(ip__in=ip_set, lat__isnull=False, is_private=False)
+ }
+ bubbles = []
+ for row in ip_agg:
+ c = geo_cache.get(row['remote_addr'])
+ if c and c[0] is not None and c[1] is not None:
+ bubbles.append({
+ 'lat': round(float(c[0]), 2),
+ 'lon': round(float(c[1]), 2),
+ 'total': row['total'],
+ 'errors': row['errors'],
+ 'label': c[3] or c[2] or row['remote_addr'],
+ })
+ return JsonResponse({'countries': countries, 'bubbles': bubbles, 'range': range_key})
+
+
class IngestLogsView(View):
"""POST endpoint for the paste-logs form (used from SettingsView)."""
def post(self, request):