import json import logging import time from datetime import timedelta from django.contrib import messages from django.db.models import Count, Q from django.db.models.functions import TruncDay, TruncHour, TruncMinute from django.http import JsonResponse, StreamingHttpResponse from django.shortcuts import redirect, render from django.utils import timezone from django.views.generic import TemplateView, View from .forms import RouterMonSettingsForm from .models import DhcpLease, DnsQuery, RouterMonSettings, WanEvent logger = logging.getLogger(__name__) # ── Time range config (mirrors nginxmon) ───────────────────────────────────── _RANGES = { '5m': {'delta': timedelta(minutes=5), 'trunc': TruncMinute, 'group_n': 1, 'label': 'Last 5 min', 'fmt': '%H:%M'}, '30m': {'delta': timedelta(minutes=30), 'trunc': TruncMinute, 'group_n': 2, 'label': 'Last 30 min', 'fmt': '%H:%M'}, '6h': {'delta': timedelta(hours=6), 'trunc': TruncMinute, 'group_n': 15, 'label': 'Last 6 hours', 'fmt': '%H:%M'}, '1d': {'delta': timedelta(hours=24), 'trunc': TruncHour, 'group_n': 1, 'label': 'Last 24 hours', 'fmt': '%H:%M'}, '7d': {'delta': timedelta(days=7), 'trunc': TruncHour, 'group_n': 4, 'label': 'Last 7 days', 'fmt': '%m/%d %H:%M'}, '30d': {'delta': timedelta(days=30), 'trunc': TruncDay, 'group_n': 1, 'label': 'Last 30 days', 'fmt': '%m/%d'}, } _DEFAULT_RANGE = '30m' def _get_range(request): key = request.GET.get('range', _DEFAULT_RANGE) if key not in _RANGES: key = _DEFAULT_RANGE cfg = _RANGES[key] return key, cfg, timezone.now() - cfg['delta'] def _floor_bucket_key(dt, trunc_fn, group_n, fmt): if trunc_fn is TruncMinute: m = (dt.minute // group_n) * group_n aligned = dt.replace(minute=m, second=0, microsecond=0) elif trunc_fn is TruncHour: h = (dt.hour // group_n) * group_n aligned = dt.replace(hour=h, minute=0, second=0, microsecond=0) else: from datetime import date epoch = date(2020, 1, 1) d = dt.date() if hasattr(dt, 'date') else dt days = (d - epoch).days floored = epoch + timedelta(days=(days // group_n) * group_n) aligned = dt.replace( year=floored.year, month=floored.month, day=floored.day, hour=0, minute=0, second=0, microsecond=0, ) return aligned.strftime(fmt) # ── Dashboard ───────────────────────────────────────────────────────────────── class DashboardView(TemplateView): template_name = 'routermon/dashboard.html' def get_context_data(self, **kwargs): ctx = super().get_context_data(**kwargs) range_key, cfg, since = _get_range(self.request) settings = RouterMonSettings.get() qs = DnsQuery.objects.filter(timestamp__gte=since) total = qs.count() nxdomain_count = qs.filter(is_nxdomain=True).count() stats = { 'total': total, 'unique_domains': qs.values('domain').distinct().count(), 'unique_clients': qs.values('client_ip').distinct().count(), 'nxdomain_count': nxdomain_count, 'nxdomain_pct': round(nxdomain_count / total * 100, 1) if total else 0, } top_domains = list( qs.values('domain') .annotate( count=Count('id'), nxcount=Count('id', filter=Q(is_nxdomain=True)), ) .order_by('-count')[:20] ) # Attach geo (country) to top domains via resolved_ip domain_geo = {} for row in qs.filter(resolved_ip__isnull=False, country__gt='').values('domain', 'country_code', 'country').distinct()[:100]: if row['domain'] not in domain_geo: domain_geo[row['domain']] = {'country': row['country'], 'country_code': row['country_code']} for d in top_domains: geo = domain_geo.get(d['domain'], {}) d['country'] = geo.get('country', '') d['country_code'] = geo.get('country_code', '') # Build client hostname map from DhcpLease client_ips = list(qs.values_list('client_ip', flat=True).distinct()[:50]) hostname_map = { lease.ip: lease.hostname for lease in DhcpLease.objects.filter(ip__in=client_ips) if lease.hostname } top_clients = list( qs.values('client_ip', 'client_name') .annotate(count=Count('id'), nxcount=Count('id', filter=Q(is_nxdomain=True))) .order_by('-count')[:15] ) for c in top_clients: c['hostname'] = hostname_map.get(c['client_ip'], c['client_name'] or '') wan_since = timezone.now() - timedelta(hours=24) wan_qs = WanEvent.objects.filter(timestamp__gte=wan_since) wan_total = wan_qs.count() top_attacked_ports = list( wan_qs.filter(dst_port__isnull=False) .values('dst_port', 'protocol') .annotate(count=Count('id')) .order_by('-count')[:10] ) top_wan_sources = list( wan_qs.values('src_ip', 'country', 'country_code') .annotate(count=Count('id')) .order_by('-count')[:10] ) ctx.update({ 'settings': settings, 'stats': stats, 'top_domains': top_domains, 'top_clients': top_clients, 'current_range': range_key, 'range_label': cfg['label'], 'all_ranges': [(k, v['label']) for k, v in _RANGES.items()], 'top_domains_json': json.dumps(top_domains, default=str), 'wan_total_24h': wan_total, 'top_attacked_ports': top_attacked_ports, 'top_attacked_ports_json': json.dumps(top_attacked_ports, default=str), 'top_wan_sources': top_wan_sources, }) return ctx # ── Settings ────────────────────────────────────────────────────────────────── class SettingsView(View): template_name = 'routermon/settings.html' def get(self, request): obj = RouterMonSettings.get() return render(request, self.template_name, { 'form': RouterMonSettingsForm(instance=obj), 'settings': obj, }) def post(self, request): obj = RouterMonSettings.get() form = RouterMonSettingsForm(request.POST, instance=obj) if form.is_valid(): form.save() messages.success(request, 'Router Monitor settings saved.') return redirect('routermon-settings') return render(request, self.template_name, {'form': form, 'settings': obj}) # ── HTMX partials ───────────────────────────────────────────────────────────── class LiveQueriesPartialView(View): _SORT_MAP = { 'timestamp': 'timestamp', 'client': 'client_ip', 'domain': 'domain', 'type': 'query_type', } def get(self, request): qs = DnsQuery.objects.all() client_f = request.GET.get('client', '').strip() domain_f = request.GET.get('domain', '').strip() type_f = request.GET.get('type', '').strip() nx_f = request.GET.get('nxdomain', '').strip() sort_col = request.GET.get('sort', 'timestamp') sort_ord = request.GET.get('order', 'desc') if client_f: qs = qs.filter(Q(client_ip=client_f) | Q(client_name__icontains=client_f)) if domain_f: qs = qs.filter(domain__icontains=domain_f) if type_f: qs = qs.filter(query_type__iexact=type_f) if nx_f == '1': qs = qs.filter(is_nxdomain=True) db_col = self._SORT_MAP.get(sort_col, 'timestamp') qs = qs.order_by(f'{"" if sort_ord == "asc" else "-"}{db_col}') return render(request, 'routermon/_live_queries.html', {'queries': qs[:60]}) class GeoStatsView(View): """Country-level aggregates + bubble data for the geo map.""" def get(self, request): range_key, cfg, since = _get_range(request) qs = DnsQuery.objects.filter(timestamp__gte=since) countries = list( qs.exclude(country_code='') .values('country_code', 'country') .annotate(total=Count('id'), nxcount=Count('id', filter=Q(is_nxdomain=True))) .order_by('-total') ) # Bubble data from IPGeoCache for resolved IPs ip_agg = list( qs.filter(resolved_ip__isnull=False) .values('resolved_ip') .annotate(total=Count('id')) .order_by('-total')[:300] ) ip_set = [r['resolved_ip'] for r in ip_agg] try: from nginxmon.models import IPGeoCache 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) } except Exception: geo_cache = {} bubbles = [] for row in ip_agg: c = geo_cache.get(row['resolved_ip']) 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'], 'label': c[3] or c[2] or row['resolved_ip'], }) return JsonResponse({'countries': countries, 'bubbles': bubbles, 'range': range_key}) # ── Chart data ──────────────────────────────────────────────────────────────── class ChartDataView(View): def get(self, request): range_key, cfg, since = _get_range(request) trunc_fn = cfg['trunc'] group_n = cfg['group_n'] fmt = cfg['fmt'] rows = list( DnsQuery.objects .filter(timestamp__gte=since) .annotate(bucket=trunc_fn('timestamp')) .values('bucket') .annotate( total=Count('id'), nxcount=Count('id', filter=Q(is_nxdomain=True)), ) .order_by('bucket') ) buckets: dict = {} for row in rows: bkt = row['bucket'] if bkt is None: continue key = _floor_bucket_key(bkt, trunc_fn, group_n, fmt) if key not in buckets: buckets[key] = {'total': 0, 'nxcount': 0} buckets[key]['total'] += row['total'] buckets[key]['nxcount'] += row['nxcount'] return JsonResponse({ 'labels': list(buckets), 'total': [v['total'] for v in buckets.values()], 'nxcount': [v['nxcount'] for v in buckets.values()], 'range': range_key, 'label': cfg['label'], }) # ── Toggle + SSE status stream ───────────────────────────────────────────────── class ToggleView(View): def post(self, request): settings = RouterMonSettings.get() settings.enabled = not settings.enabled settings.save(update_fields=['enabled']) return JsonResponse({'enabled': settings.enabled}) class StatusStreamView(View): """SSE — pushes receiver status JSON every 3 seconds.""" def get(self, request): def event_stream(): while True: try: settings = RouterMonSettings.get() total = DnsQuery.objects.count() last = settings.last_received_at.isoformat() if settings.last_received_at else None payload = json.dumps({ 'enabled': settings.enabled, 'last_received_at': last, 'total_queries': total, }) yield f'data: {payload}\n\n' except Exception: pass time.sleep(3) resp = StreamingHttpResponse(event_stream(), content_type='text/event-stream') resp['Cache-Control'] = 'no-cache' resp['X-Accel-Buffering'] = 'no' return resp # ── WAN views ───────────────────────────────────────────────────────────────── class WanLivePartialView(View): """HTMX partial: recent WAN events table.""" _SORT_MAP = { 'timestamp': 'timestamp', 'src_ip': 'src_ip', 'dst_port': 'dst_port', 'protocol': 'protocol', 'country': 'country', } def get(self, request): qs = WanEvent.objects.all() src_f = request.GET.get('src_ip', '').strip() port_f = request.GET.get('dst_port', '').strip() proto_f = request.GET.get('protocol', '').strip() country_f = request.GET.get('country', '').strip() sort_col = request.GET.get('sort', 'timestamp') sort_ord = request.GET.get('order', 'desc') if src_f: qs = qs.filter(src_ip__icontains=src_f) if port_f: try: qs = qs.filter(dst_port=int(port_f)) except ValueError: pass if proto_f: qs = qs.filter(protocol__iexact=proto_f) if country_f: qs = qs.filter(Q(country__icontains=country_f) | Q(country_code__iexact=country_f)) db_col = self._SORT_MAP.get(sort_col, 'timestamp') qs = qs.order_by(f'{"" if sort_ord == "asc" else "-"}{db_col}') return render(request, 'routermon/_live_wan.html', {'wan_events': qs[:60]}) class WanGeoStatsView(View): """Country-level aggregates + bubble data for WAN incoming events.""" def get(self, request): range_key, cfg, since = _get_range(request) qs = WanEvent.objects.filter(timestamp__gte=since) countries = list( qs.exclude(country_code='') .values('country_code', 'country') .annotate(total=Count('id')) .order_by('-total') ) ip_agg = list( qs.values('src_ip') .annotate(total=Count('id')) .order_by('-total')[:300] ) ip_set = [r['src_ip'] for r in ip_agg] try: from nginxmon.models import IPGeoCache 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) } except Exception: geo_cache = {} bubbles = [] for row in ip_agg: c = geo_cache.get(row['src_ip']) 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'], 'label': c[3] or c[2] or row['src_ip'], }) return JsonResponse({'countries': countries, 'bubbles': bubbles, 'range': range_key}) class WanChartDataView(View): """JSON — WAN event counts over time (for chart).""" def get(self, request): range_key, cfg, since = _get_range(request) trunc_fn = cfg['trunc'] group_n = cfg['group_n'] fmt = cfg['fmt'] rows = list( WanEvent.objects .filter(timestamp__gte=since) .annotate(bucket=trunc_fn('timestamp')) .values('bucket') .annotate(total=Count('id')) .order_by('bucket') ) buckets: dict = {} for row in rows: bkt = row['bucket'] if bkt is None: continue key = _floor_bucket_key(bkt, trunc_fn, group_n, fmt) buckets[key] = buckets.get(key, 0) + row['total'] return JsonResponse({ 'labels': list(buckets), 'total': list(buckets.values()), 'range': range_key, 'label': cfg['label'], })