Files
links/routermon/views.py
T
2026-04-16 14:01:43 +10:00

308 lines
12 KiB
Python

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
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 '')
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),
})
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