mirror of
https://github.com/wahyd4/links.git
synced 2026-08-08 21:04:53 +10:00
Add nginx visualization
This commit is contained in:
@@ -0,0 +1,39 @@
|
||||
from django.contrib import admin
|
||||
from .models import NginxSettings, NginxAlertProfile, NginxAccessLog, IPGeoCache, ThreatAlert
|
||||
|
||||
|
||||
@admin.register(NginxSettings)
|
||||
class NginxSettingsAdmin(admin.ModelAdmin):
|
||||
list_display = ['namespace', 'pod_label', 'container', 'fetch_interval_seconds',
|
||||
'log_file_path', 'enabled', 'last_fetch_at']
|
||||
|
||||
|
||||
@admin.register(NginxAlertProfile)
|
||||
class NginxAlertProfileAdmin(admin.ModelAdmin):
|
||||
list_display = ['name', 'alert_window_seconds', 'alert_max_requests',
|
||||
'alert_max_error_rate', 'enabled', 'created_at']
|
||||
list_filter = ['enabled']
|
||||
|
||||
|
||||
@admin.register(NginxAccessLog)
|
||||
class NginxAccessLogAdmin(admin.ModelAdmin):
|
||||
list_display = ['timestamp', 'remote_addr', 'method', 'request_uri', 'status',
|
||||
'request_time', 'service', 'country', 'city']
|
||||
list_filter = ['status', 'service', 'country']
|
||||
search_fields = ['remote_addr', 'request_uri', 'request_id']
|
||||
readonly_fields = ['timestamp', 'request_id']
|
||||
date_hierarchy = 'timestamp'
|
||||
|
||||
|
||||
@admin.register(IPGeoCache)
|
||||
class IPGeoCacheAdmin(admin.ModelAdmin):
|
||||
list_display = ['ip', 'country', 'city', 'isp', 'is_private', 'fetched_at']
|
||||
search_fields = ['ip', 'country', 'city']
|
||||
|
||||
|
||||
@admin.register(ThreatAlert)
|
||||
class ThreatAlertAdmin(admin.ModelAdmin):
|
||||
list_display = ['detected_at', 'remote_addr', 'alert_type', 'profile',
|
||||
'request_count', 'error_count', 'country', 'notified', 'dismissed']
|
||||
list_filter = ['alert_type', 'dismissed', 'notified', 'profile']
|
||||
search_fields = ['remote_addr', 'country']
|
||||
@@ -0,0 +1,26 @@
|
||||
import logging
|
||||
from django.apps import AppConfig
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class NginxmonConfig(AppConfig):
|
||||
default_auto_field = 'django.db.models.BigAutoField'
|
||||
name = 'nginxmon'
|
||||
verbose_name = 'Nginx Monitor'
|
||||
|
||||
def ready(self):
|
||||
try:
|
||||
from nginxmon.models import NginxSettings, NginxAlertProfile
|
||||
from nginxmon.tasks import start_fetch_job, schedule_profile, start_cleanup_job
|
||||
|
||||
settings = NginxSettings.get()
|
||||
if settings.enabled:
|
||||
start_fetch_job(settings.fetch_interval_seconds)
|
||||
|
||||
for profile in NginxAlertProfile.objects.filter(enabled=True):
|
||||
schedule_profile(profile)
|
||||
|
||||
start_cleanup_job()
|
||||
except Exception as exc:
|
||||
logger.warning('nginxmon: could not schedule on startup: %s', exc)
|
||||
@@ -0,0 +1,86 @@
|
||||
"""
|
||||
Threat detection against NginxAccessLog for each enabled NginxAlertProfile.
|
||||
"""
|
||||
import logging
|
||||
from datetime import timedelta
|
||||
|
||||
from django.db.models import Count, Q
|
||||
from django.utils import timezone
|
||||
|
||||
from .models import NginxAlertProfile, NginxAccessLog, ThreatAlert, IPGeoCache
|
||||
from .notifications import send_alert_telegram
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
def detect_threats(profile_id: int):
|
||||
try:
|
||||
profile = NginxAlertProfile.objects.get(pk=profile_id, enabled=True)
|
||||
except NginxAlertProfile.DoesNotExist:
|
||||
return
|
||||
|
||||
window = timedelta(seconds=profile.alert_window_seconds)
|
||||
now = timezone.now()
|
||||
window_start = now - window
|
||||
|
||||
qs = (
|
||||
NginxAccessLog.objects
|
||||
.filter(timestamp__gte=window_start)
|
||||
.values('remote_addr')
|
||||
.annotate(
|
||||
total=Count('id'),
|
||||
errors=Count('id', filter=Q(status__gte=400)),
|
||||
)
|
||||
)
|
||||
|
||||
for row in qs:
|
||||
ip, total, errors = row['remote_addr'], row['total'], row['errors']
|
||||
|
||||
existing = ThreatAlert.objects.filter(
|
||||
profile=profile,
|
||||
remote_addr=ip,
|
||||
window_start__gte=window_start,
|
||||
dismissed=False,
|
||||
)
|
||||
|
||||
if total >= profile.alert_max_requests:
|
||||
if not existing.filter(alert_type='rate_limit').exists():
|
||||
_create_alert(profile, 'rate_limit', ip, total, errors, window_start, now)
|
||||
|
||||
if total >= profile.alert_min_requests:
|
||||
if (errors / total) >= profile.alert_max_error_rate:
|
||||
if not existing.filter(alert_type='error_rate').exists():
|
||||
_create_alert(profile, 'error_rate', ip, total, errors, window_start, now)
|
||||
|
||||
|
||||
def _create_alert(profile, alert_type, ip, total, errors, window_start, window_end):
|
||||
geo = IPGeoCache.objects.filter(ip=ip).first()
|
||||
country = geo.country if geo else ''
|
||||
city = geo.city if geo else ''
|
||||
|
||||
if alert_type == 'rate_limit':
|
||||
detail = (
|
||||
f'{total} requests from {ip} in the last {profile.alert_window_seconds}s '
|
||||
f'(threshold: {profile.alert_max_requests})'
|
||||
)
|
||||
else:
|
||||
rate_pct = round(errors / total * 100)
|
||||
detail = (
|
||||
f'{errors}/{total} requests from {ip} failed '
|
||||
f'({rate_pct}% error rate, threshold: {int(profile.alert_max_error_rate * 100)}%)'
|
||||
)
|
||||
|
||||
alert = ThreatAlert.objects.create(
|
||||
profile=profile,
|
||||
alert_type=alert_type,
|
||||
remote_addr=ip,
|
||||
window_start=window_start,
|
||||
window_end=window_end,
|
||||
request_count=total,
|
||||
error_count=errors,
|
||||
detail=detail,
|
||||
country=country,
|
||||
city=city,
|
||||
)
|
||||
logger.warning('nginxmon alert: %s', detail)
|
||||
send_alert_telegram(profile, alert)
|
||||
@@ -0,0 +1,181 @@
|
||||
"""
|
||||
Fetch nginx ingress logs — from kubectl (production) or a local file (dev/testing).
|
||||
Deduplicates by request_id so overlapping windows don't double-insert.
|
||||
"""
|
||||
import logging
|
||||
import subprocess
|
||||
from datetime import timedelta
|
||||
|
||||
from django.db import transaction
|
||||
from django.utils import timezone as dj_tz
|
||||
|
||||
from .models import NginxSettings, NginxAccessLog
|
||||
from .parser import parse_lines
|
||||
from .geo import enrich_geo_batch
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
_OVERLAP = 60 # extra seconds to avoid missing entries near boundaries
|
||||
|
||||
|
||||
def fetch_and_store() -> int:
|
||||
"""
|
||||
Pull new log lines, parse, deduplicate, and save.
|
||||
Returns the number of new rows inserted.
|
||||
"""
|
||||
settings = NginxSettings.get()
|
||||
if not settings.enabled:
|
||||
return 0
|
||||
|
||||
raw = _read_file(settings) if settings.log_file_path else _kubectl_logs(settings)
|
||||
if raw is None:
|
||||
return 0
|
||||
|
||||
entries = parse_lines(raw)
|
||||
if not entries:
|
||||
_touch(settings)
|
||||
return 0
|
||||
|
||||
since_seconds = settings.fetch_interval_seconds + _OVERLAP
|
||||
existing_ids = set(
|
||||
NginxAccessLog.objects.filter(
|
||||
timestamp__gte=dj_tz.now() - timedelta(seconds=since_seconds + 10),
|
||||
).values_list('request_id', flat=True)
|
||||
)
|
||||
|
||||
new_logs, seen = [], set()
|
||||
for e in entries:
|
||||
key = e['request_id'] or (
|
||||
f"{e['timestamp'].isoformat()}|{e['remote_addr']}|{e['request_uri']}"
|
||||
)
|
||||
if key in existing_ids or key in seen:
|
||||
continue
|
||||
seen.add(key)
|
||||
new_logs.append(NginxAccessLog(
|
||||
timestamp=e['timestamp'],
|
||||
remote_addr=e['remote_addr'],
|
||||
method=e['method'],
|
||||
request_uri=e['request_uri'],
|
||||
protocol=e['protocol'],
|
||||
status=e['status'],
|
||||
body_bytes_sent=e['body_bytes_sent'],
|
||||
http_referer=e['http_referer'],
|
||||
http_user_agent=e['http_user_agent'],
|
||||
request_length=e['request_length'],
|
||||
request_time=e['request_time'],
|
||||
service=e['service'],
|
||||
upstream_addr=e['upstream_addr'],
|
||||
upstream_response_time=e['upstream_response_time'],
|
||||
upstream_status=e['upstream_status'],
|
||||
request_id=e['request_id'],
|
||||
))
|
||||
|
||||
if new_logs:
|
||||
with transaction.atomic():
|
||||
NginxAccessLog.objects.bulk_create(new_logs, ignore_conflicts=True)
|
||||
logger.info('nginxmon: inserted %d new log entries', len(new_logs))
|
||||
enrich_geo_batch(new_logs)
|
||||
|
||||
_touch(settings)
|
||||
return len(new_logs)
|
||||
|
||||
|
||||
def ingest_raw(text: str) -> int:
|
||||
"""
|
||||
Parse and store log lines from a raw string (used by the paste-logs UI
|
||||
and the management command). Returns the number of new rows inserted.
|
||||
"""
|
||||
entries = parse_lines(text)
|
||||
if not entries:
|
||||
return 0
|
||||
|
||||
existing_ids = set(
|
||||
NginxAccessLog.objects.values_list('request_id', flat=True)
|
||||
)
|
||||
new_logs, seen = [], set()
|
||||
for e in entries:
|
||||
key = e['request_id'] or (
|
||||
f"{e['timestamp'].isoformat()}|{e['remote_addr']}|{e['request_uri']}"
|
||||
)
|
||||
if key in existing_ids or key in seen:
|
||||
continue
|
||||
seen.add(key)
|
||||
new_logs.append(NginxAccessLog(
|
||||
timestamp=e['timestamp'],
|
||||
remote_addr=e['remote_addr'],
|
||||
method=e['method'],
|
||||
request_uri=e['request_uri'],
|
||||
protocol=e['protocol'],
|
||||
status=e['status'],
|
||||
body_bytes_sent=e['body_bytes_sent'],
|
||||
http_referer=e['http_referer'],
|
||||
http_user_agent=e['http_user_agent'],
|
||||
request_length=e['request_length'],
|
||||
request_time=e['request_time'],
|
||||
service=e['service'],
|
||||
upstream_addr=e['upstream_addr'],
|
||||
upstream_response_time=e['upstream_response_time'],
|
||||
upstream_status=e['upstream_status'],
|
||||
request_id=e['request_id'],
|
||||
))
|
||||
|
||||
if new_logs:
|
||||
with transaction.atomic():
|
||||
NginxAccessLog.objects.bulk_create(new_logs, ignore_conflicts=True)
|
||||
logger.info('nginxmon: ingested %d log entries from raw text', len(new_logs))
|
||||
enrich_geo_batch(new_logs)
|
||||
|
||||
return len(new_logs)
|
||||
|
||||
|
||||
def _kubectl_logs(settings: NginxSettings) -> str | None:
|
||||
since = settings.fetch_interval_seconds + _OVERLAP
|
||||
cmd = [
|
||||
'kubectl', 'logs',
|
||||
'-n', settings.namespace,
|
||||
'-l', settings.pod_label,
|
||||
'--container', settings.container,
|
||||
f'--since={since}s',
|
||||
'--timestamps=false',
|
||||
'--prefix=true',
|
||||
'--max-log-requests=10',
|
||||
]
|
||||
try:
|
||||
result = subprocess.run(cmd, capture_output=True, text=True, timeout=30)
|
||||
if result.returncode != 0:
|
||||
logger.error('kubectl logs failed: %s', result.stderr[:500])
|
||||
return None
|
||||
return result.stdout
|
||||
except FileNotFoundError:
|
||||
logger.error('kubectl not found in PATH')
|
||||
return None
|
||||
except subprocess.TimeoutExpired:
|
||||
logger.error('kubectl logs timed out')
|
||||
return None
|
||||
except Exception as exc:
|
||||
logger.error('kubectl logs error: %s', exc)
|
||||
return None
|
||||
|
||||
|
||||
def _read_file(settings: NginxSettings) -> str | None:
|
||||
try:
|
||||
with open(settings.log_file_path, 'r', encoding='utf-8', errors='replace') as f:
|
||||
return f.read()
|
||||
except FileNotFoundError:
|
||||
logger.error('nginxmon: log file not found: %s', settings.log_file_path)
|
||||
return None
|
||||
except Exception as exc:
|
||||
logger.error('nginxmon: error reading log file: %s', exc)
|
||||
return None
|
||||
|
||||
|
||||
def _touch(settings: NginxSettings):
|
||||
NginxSettings.objects.filter(pk=settings.pk).update(last_fetch_at=dj_tz.now())
|
||||
|
||||
|
||||
def cleanup_old_logs(days: int = 7):
|
||||
from datetime import timedelta
|
||||
cutoff = dj_tz.now() - timedelta(days=days)
|
||||
deleted, _ = NginxAccessLog.objects.filter(timestamp__lt=cutoff).delete()
|
||||
if deleted:
|
||||
logger.info('nginxmon: pruned %d old log entries (>%d days)', deleted, days)
|
||||
@@ -0,0 +1,47 @@
|
||||
from django import forms
|
||||
from .models import NginxSettings, NginxAlertProfile
|
||||
|
||||
|
||||
class NginxSettingsForm(forms.ModelForm):
|
||||
class Meta:
|
||||
model = NginxSettings
|
||||
fields = [
|
||||
'namespace',
|
||||
'pod_label',
|
||||
'container',
|
||||
'fetch_interval_seconds',
|
||||
'log_file_path',
|
||||
'enabled',
|
||||
]
|
||||
|
||||
|
||||
class NginxAlertProfileForm(forms.ModelForm):
|
||||
class Meta:
|
||||
model = NginxAlertProfile
|
||||
fields = [
|
||||
'name',
|
||||
'alert_window_seconds',
|
||||
'alert_max_requests',
|
||||
'alert_max_error_rate',
|
||||
'alert_min_requests',
|
||||
'telegram_bot_token',
|
||||
'telegram_chat_id',
|
||||
'enabled',
|
||||
]
|
||||
widgets = {
|
||||
'telegram_bot_token': forms.PasswordInput(render_value=True),
|
||||
}
|
||||
help_texts = {
|
||||
'alert_max_error_rate': 'Value between 0 and 1, e.g. 0.6 = 60% errors.',
|
||||
}
|
||||
|
||||
|
||||
class PasteLogsForm(forms.Form):
|
||||
log_text = forms.CharField(
|
||||
widget=forms.Textarea(attrs={
|
||||
'rows': 10,
|
||||
'placeholder': 'Paste raw nginx ingress log lines here…',
|
||||
'class': 'font-mono text-xs',
|
||||
}),
|
||||
label='Raw log lines',
|
||||
)
|
||||
+100
@@ -0,0 +1,100 @@
|
||||
"""
|
||||
Geo-IP enrichment using ip-api.com batch endpoint (free, no key required).
|
||||
Results are cached in IPGeoCache to minimise outbound requests.
|
||||
"""
|
||||
import ipaddress
|
||||
import logging
|
||||
|
||||
import requests
|
||||
|
||||
from .models import IPGeoCache, NginxAccessLog
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
_BATCH_URL = 'http://ip-api.com/batch'
|
||||
_BATCH_FIELDS = 'country,countryCode,regionName,city,lat,lon,isp,status,query'
|
||||
_PRIVATE_RANGES = (
|
||||
ipaddress.ip_network('10.0.0.0/8'),
|
||||
ipaddress.ip_network('172.16.0.0/12'),
|
||||
ipaddress.ip_network('192.168.0.0/16'),
|
||||
ipaddress.ip_network('127.0.0.0/8'),
|
||||
ipaddress.ip_network('::1/128'),
|
||||
ipaddress.ip_network('fc00::/7'),
|
||||
)
|
||||
|
||||
|
||||
def _is_private(ip: str) -> bool:
|
||||
try:
|
||||
addr = ipaddress.ip_address(ip)
|
||||
return any(addr in net for net in _PRIVATE_RANGES)
|
||||
except ValueError:
|
||||
return False
|
||||
|
||||
|
||||
def _lookup_batch(ips: list[str]) -> dict[str, dict]:
|
||||
"""Query ip-api.com for up to 100 IPs. Returns {ip: geo_dict}."""
|
||||
payload = [{'query': ip, 'fields': _BATCH_FIELDS} for ip in ips[:100]]
|
||||
try:
|
||||
resp = requests.post(_BATCH_URL, json=payload, timeout=10)
|
||||
resp.raise_for_status()
|
||||
results = {}
|
||||
for item in resp.json():
|
||||
q = item.get('query', '')
|
||||
if item.get('status') == 'success':
|
||||
results[q] = item
|
||||
return results
|
||||
except Exception as exc:
|
||||
logger.warning('ip-api.com batch lookup failed: %s', exc)
|
||||
return {}
|
||||
|
||||
|
||||
def enrich_geo_batch(log_objs: list[NginxAccessLog]):
|
||||
"""
|
||||
Look up geo data for IPs in `log_objs`, update cache and log records.
|
||||
Uses the local cache first; only queries the API for unknown IPs.
|
||||
"""
|
||||
unique_ips = {obj.remote_addr for obj in log_objs}
|
||||
|
||||
# Split private vs public
|
||||
private_ips = {ip for ip in unique_ips if _is_private(ip)}
|
||||
public_ips = unique_ips - private_ips
|
||||
|
||||
# Ensure private IPs are in cache
|
||||
for ip in private_ips:
|
||||
IPGeoCache.objects.get_or_create(ip=ip, defaults={'is_private': True})
|
||||
|
||||
# Load cached public IPs
|
||||
cached = {
|
||||
g.ip: g
|
||||
for g in IPGeoCache.objects.filter(ip__in=public_ips)
|
||||
}
|
||||
missing = [ip for ip in public_ips if ip not in cached]
|
||||
|
||||
# Fetch missing from API in batches of 100
|
||||
api_results: dict[str, dict] = {}
|
||||
for i in range(0, len(missing), 100):
|
||||
api_results.update(_lookup_batch(missing[i:i + 100]))
|
||||
|
||||
# Upsert cache
|
||||
for ip, data in api_results.items():
|
||||
obj, _ = IPGeoCache.objects.get_or_create(ip=ip)
|
||||
obj.country = data.get('country', '')
|
||||
obj.country_code = data.get('countryCode', '')
|
||||
obj.region = data.get('regionName', '')
|
||||
obj.city = data.get('city', '')
|
||||
obj.lat = data.get('lat')
|
||||
obj.lon = data.get('lon')
|
||||
obj.isp = data.get('isp', '')
|
||||
obj.save()
|
||||
cached[ip] = obj
|
||||
|
||||
# Apply geo back via per-IP queryset update (avoids needing PKs on in-memory objects)
|
||||
for ip, geo in cached.items():
|
||||
NginxAccessLog.objects.filter(
|
||||
remote_addr=ip, country=''
|
||||
).update(
|
||||
country=geo.country or '',
|
||||
country_code=geo.country_code or '',
|
||||
region=geo.region or '',
|
||||
city=geo.city or '',
|
||||
)
|
||||
@@ -0,0 +1,58 @@
|
||||
"""
|
||||
Management command to ingest nginx log lines into the database.
|
||||
|
||||
Usage examples:
|
||||
|
||||
# Read from a file
|
||||
python manage.py nginxmon_ingest --file /path/to/nginx.log
|
||||
|
||||
# Pipe from stdin
|
||||
kubectl logs -n ingress-nginx deploy/ingress-nginx-controller | python manage.py nginxmon_ingest
|
||||
|
||||
# Use the log file configured in NginxSettings
|
||||
python manage.py nginxmon_ingest --from-settings
|
||||
"""
|
||||
import sys
|
||||
|
||||
from django.core.management.base import BaseCommand
|
||||
|
||||
from nginxmon.fetcher import fetch_and_store, ingest_raw
|
||||
|
||||
|
||||
class Command(BaseCommand):
|
||||
help = 'Ingest nginx ingress log lines into the nginxmon database'
|
||||
|
||||
def add_arguments(self, parser):
|
||||
group = parser.add_mutually_exclusive_group()
|
||||
group.add_argument(
|
||||
'--file', '-f',
|
||||
metavar='PATH',
|
||||
help='Path to a nginx log file to ingest',
|
||||
)
|
||||
group.add_argument(
|
||||
'--from-settings',
|
||||
action='store_true',
|
||||
help='Run a full fetch using the source configured in NginxSettings '
|
||||
'(kubectl or log file)',
|
||||
)
|
||||
# No flag = read from stdin
|
||||
|
||||
def handle(self, *args, **options):
|
||||
if options['from_settings']:
|
||||
count = fetch_and_store()
|
||||
self.stdout.write(self.style.SUCCESS(f'Fetched and inserted {count} new log entries.'))
|
||||
return
|
||||
|
||||
if options['file']:
|
||||
try:
|
||||
with open(options['file'], 'r', encoding='utf-8', errors='replace') as fh:
|
||||
text = fh.read()
|
||||
except OSError as exc:
|
||||
self.stderr.write(self.style.ERROR(f'Cannot read file: {exc}'))
|
||||
return
|
||||
else:
|
||||
self.stdout.write('Reading from stdin… (Ctrl-D to finish)')
|
||||
text = sys.stdin.read()
|
||||
|
||||
count = ingest_raw(text)
|
||||
self.stdout.write(self.style.SUCCESS(f'Inserted {count} new log entries.'))
|
||||
@@ -0,0 +1,145 @@
|
||||
"""
|
||||
Management command that streams logs from stern and ingests them continuously.
|
||||
|
||||
Usage:
|
||||
python manage.py nginxmon_stern
|
||||
python manage.py nginxmon_stern --namespace ingress-nginx --selector app.kubernetes.io/name=ingress-nginx
|
||||
python manage.py nginxmon_stern --tail 200 --batch-size 20
|
||||
|
||||
The command checks NginxSettings.enabled every few seconds and pauses/resumes
|
||||
ingestion accordingly. Stop with Ctrl-C.
|
||||
"""
|
||||
import subprocess
|
||||
import time
|
||||
import signal
|
||||
import sys
|
||||
|
||||
from django.core.management.base import BaseCommand
|
||||
|
||||
from nginxmon.fetcher import ingest_raw
|
||||
from nginxmon.models import NginxSettings
|
||||
|
||||
|
||||
_STERN_BIN = '/opt/homebrew/bin/stern'
|
||||
_DEFAULT_NAMESPACE = 'ingress-nginx'
|
||||
_DEFAULT_SELECTOR = 'ingress-nginx-controller'
|
||||
_BATCH_SIZE = 30
|
||||
_ENABLED_CHECK_INTERVAL = 5 # seconds between enabled-flag checks
|
||||
_FLUSH_INTERVAL = 3 # seconds before flushing a partial batch
|
||||
|
||||
|
||||
class Command(BaseCommand):
|
||||
help = 'Stream nginx logs via stern and continuously ingest them into the database'
|
||||
|
||||
def add_arguments(self, parser):
|
||||
parser.add_argument('--namespace', '-n', default=_DEFAULT_NAMESPACE,
|
||||
help='Kubernetes namespace (default: ingress-nginx)')
|
||||
parser.add_argument('--selector', '-l', default=_DEFAULT_SELECTOR,
|
||||
help='Pod selector passed to stern (default: ingress-nginx-controller)')
|
||||
parser.add_argument('--tail', type=int, default=500,
|
||||
help='Number of historical lines to retrieve on start (default: 500)')
|
||||
parser.add_argument('--batch-size', type=int, default=_BATCH_SIZE,
|
||||
help='Number of lines to batch before ingesting (default: 30)')
|
||||
|
||||
def handle(self, *args, **options):
|
||||
namespace = options['namespace']
|
||||
selector = options['selector']
|
||||
tail = options['tail']
|
||||
batch_size = options['batch_size']
|
||||
|
||||
self._shutdown = False
|
||||
signal.signal(signal.SIGINT, self._handle_signal)
|
||||
signal.signal(signal.SIGTERM, self._handle_signal)
|
||||
|
||||
self.stdout.write(self.style.SUCCESS(
|
||||
f'Starting stern: selector={selector!r} namespace={namespace!r} tail={tail}'
|
||||
))
|
||||
|
||||
cmd = [
|
||||
_STERN_BIN,
|
||||
selector,
|
||||
'--namespace', namespace,
|
||||
'--tail', str(tail),
|
||||
'--color', 'never',
|
||||
'--template', '{{.PodName}} {{.ContainerName}} {{.Message}}\n',
|
||||
]
|
||||
|
||||
self.stdout.write(f'Command: {" ".join(cmd)}')
|
||||
|
||||
proc = None
|
||||
try:
|
||||
proc = subprocess.Popen(
|
||||
cmd,
|
||||
stdout=subprocess.PIPE,
|
||||
stderr=None, # pass stern's stderr straight to the terminal
|
||||
text=True,
|
||||
bufsize=1,
|
||||
)
|
||||
self._ingest_loop(proc, batch_size)
|
||||
except FileNotFoundError:
|
||||
self.stderr.write(self.style.ERROR(
|
||||
f'stern not found at {_STERN_BIN}. Install with: brew install stern'
|
||||
))
|
||||
sys.exit(1)
|
||||
finally:
|
||||
if proc and proc.poll() is None:
|
||||
proc.terminate()
|
||||
try:
|
||||
proc.wait(timeout=5)
|
||||
except subprocess.TimeoutExpired:
|
||||
proc.kill()
|
||||
|
||||
def _handle_signal(self, signum, frame):
|
||||
self.stdout.write('\nShutting down stern ingestion…')
|
||||
self._shutdown = True
|
||||
|
||||
def _ingest_loop(self, proc, batch_size):
|
||||
batch = []
|
||||
last_flush = time.monotonic()
|
||||
last_enabled_check = 0.0
|
||||
enabled = True
|
||||
|
||||
import select
|
||||
|
||||
while not self._shutdown:
|
||||
# Periodically re-check NginxSettings.enabled
|
||||
now = time.monotonic()
|
||||
if now - last_enabled_check >= _ENABLED_CHECK_INTERVAL:
|
||||
try:
|
||||
enabled = NginxSettings.get().enabled
|
||||
except Exception:
|
||||
pass
|
||||
last_enabled_check = now
|
||||
|
||||
if proc.poll() is not None:
|
||||
# stern process exited
|
||||
self.stderr.write(self.style.WARNING('stern process exited.'))
|
||||
break
|
||||
|
||||
# Non-blocking readline with a short select timeout
|
||||
ready, _, _ = select.select([proc.stdout], [], [], 0.5)
|
||||
if ready:
|
||||
line = proc.stdout.readline()
|
||||
if not line:
|
||||
break
|
||||
if enabled:
|
||||
batch.append(line)
|
||||
|
||||
# Flush when batch is full or flush interval elapsed
|
||||
if batch and (len(batch) >= batch_size or time.monotonic() - last_flush >= _FLUSH_INTERVAL):
|
||||
self._flush(batch)
|
||||
batch = []
|
||||
last_flush = time.monotonic()
|
||||
|
||||
# Final flush
|
||||
if batch:
|
||||
self._flush(batch)
|
||||
|
||||
def _flush(self, lines):
|
||||
text = ''.join(lines)
|
||||
try:
|
||||
count = ingest_raw(text)
|
||||
if count:
|
||||
self.stdout.write(f' [stern] Ingested {count} new log entries')
|
||||
except Exception as exc:
|
||||
self.stderr.write(self.style.ERROR(f' [stern] Ingest error: {exc}'))
|
||||
@@ -0,0 +1,116 @@
|
||||
# Generated by Django 5.2.12 on 2026-04-01 03:47
|
||||
|
||||
import django.db.models.deletion
|
||||
import netscan.fields
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
initial = True
|
||||
|
||||
dependencies = [
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.CreateModel(
|
||||
name='IPGeoCache',
|
||||
fields=[
|
||||
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
|
||||
('ip', models.GenericIPAddressField(db_index=True, unique=True)),
|
||||
('country', models.CharField(blank=True, max_length=100)),
|
||||
('country_code', models.CharField(blank=True, max_length=10)),
|
||||
('region', models.CharField(blank=True, max_length=100)),
|
||||
('city', models.CharField(blank=True, max_length=100)),
|
||||
('lat', models.FloatField(blank=True, null=True)),
|
||||
('lon', models.FloatField(blank=True, null=True)),
|
||||
('isp', models.CharField(blank=True, max_length=300)),
|
||||
('is_private', models.BooleanField(default=False)),
|
||||
('fetched_at', models.DateTimeField(auto_now=True)),
|
||||
],
|
||||
),
|
||||
migrations.CreateModel(
|
||||
name='NginxAlertProfile',
|
||||
fields=[
|
||||
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
|
||||
('name', models.CharField(max_length=200)),
|
||||
('alert_window_seconds', models.IntegerField(default=60, help_text='Rolling window in seconds for rate analysis')),
|
||||
('alert_max_requests', models.IntegerField(default=200, help_text='Max requests per IP per window before triggering a rate alert')),
|
||||
('alert_max_error_rate', models.FloatField(default=0.6, help_text='Error rate threshold (0–1) to flag an IP')),
|
||||
('alert_min_requests', models.IntegerField(default=15, help_text='Minimum requests before running error-rate check')),
|
||||
('telegram_bot_token', netscan.fields.EncryptedCharField(blank=True)),
|
||||
('telegram_chat_id', netscan.fields.EncryptedCharField(blank=True)),
|
||||
('enabled', models.BooleanField(default=True)),
|
||||
('created_at', models.DateTimeField(auto_now_add=True)),
|
||||
],
|
||||
),
|
||||
migrations.CreateModel(
|
||||
name='NginxSettings',
|
||||
fields=[
|
||||
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
|
||||
('namespace', models.CharField(default='ingress-nginx', max_length=200)),
|
||||
('pod_label', models.CharField(default='app.kubernetes.io/name=ingress-nginx', help_text='kubectl -l selector for the ingress-nginx pods', max_length=200)),
|
||||
('container', models.CharField(default='controller', max_length=100)),
|
||||
('fetch_interval_seconds', models.IntegerField(default=30, help_text='How often to pull new logs (seconds)')),
|
||||
('log_file_path', models.CharField(blank=True, help_text='Absolute path to a local nginx log file for testing. Leave empty to use kubectl in production.', max_length=500)),
|
||||
('enabled', models.BooleanField(default=True)),
|
||||
('last_fetch_at', models.DateTimeField(blank=True, null=True)),
|
||||
],
|
||||
options={
|
||||
'verbose_name': 'Nginx Settings',
|
||||
'verbose_name_plural': 'Nginx Settings',
|
||||
},
|
||||
),
|
||||
migrations.CreateModel(
|
||||
name='NginxAccessLog',
|
||||
fields=[
|
||||
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
|
||||
('timestamp', models.DateTimeField(db_index=True)),
|
||||
('remote_addr', models.GenericIPAddressField(db_index=True)),
|
||||
('method', models.CharField(max_length=20)),
|
||||
('request_uri', models.TextField()),
|
||||
('protocol', models.CharField(max_length=20)),
|
||||
('status', models.IntegerField(db_index=True)),
|
||||
('body_bytes_sent', models.IntegerField()),
|
||||
('http_referer', models.TextField(blank=True)),
|
||||
('http_user_agent', models.TextField(blank=True)),
|
||||
('request_length', models.IntegerField(default=0)),
|
||||
('request_time', models.FloatField()),
|
||||
('service', models.CharField(blank=True, db_index=True, max_length=200)),
|
||||
('upstream_addr', models.CharField(blank=True, max_length=200)),
|
||||
('upstream_response_time', models.FloatField(blank=True, null=True)),
|
||||
('upstream_status', models.IntegerField(blank=True, null=True)),
|
||||
('request_id', models.CharField(blank=True, db_index=True, max_length=100)),
|
||||
('country', models.CharField(blank=True, max_length=100)),
|
||||
('country_code', models.CharField(blank=True, max_length=10)),
|
||||
('region', models.CharField(blank=True, max_length=100)),
|
||||
('city', models.CharField(blank=True, max_length=100)),
|
||||
],
|
||||
options={
|
||||
'ordering': ['-timestamp'],
|
||||
'indexes': [models.Index(fields=['remote_addr', 'timestamp'], name='nginxmon_ng_remote__1ceb1d_idx')],
|
||||
},
|
||||
),
|
||||
migrations.CreateModel(
|
||||
name='ThreatAlert',
|
||||
fields=[
|
||||
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
|
||||
('alert_type', models.CharField(choices=[('rate_limit', 'High Request Rate'), ('error_rate', 'High Error Rate')], max_length=50)),
|
||||
('remote_addr', models.GenericIPAddressField(db_index=True)),
|
||||
('detected_at', models.DateTimeField(auto_now_add=True)),
|
||||
('window_start', models.DateTimeField()),
|
||||
('window_end', models.DateTimeField()),
|
||||
('request_count', models.IntegerField()),
|
||||
('error_count', models.IntegerField(default=0)),
|
||||
('detail', models.TextField()),
|
||||
('notified', models.BooleanField(default=False)),
|
||||
('dismissed', models.BooleanField(default=False)),
|
||||
('country', models.CharField(blank=True, max_length=100)),
|
||||
('city', models.CharField(blank=True, max_length=100)),
|
||||
('profile', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='alerts', to='nginxmon.nginxalertprofile')),
|
||||
],
|
||||
options={
|
||||
'ordering': ['-detected_at'],
|
||||
},
|
||||
),
|
||||
]
|
||||
@@ -0,0 +1,17 @@
|
||||
# Generated by Django 5.2.12 on 2026-04-01 04:09
|
||||
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('nginxmon', '0001_initial'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AddIndex(
|
||||
model_name='nginxaccesslog',
|
||||
index=models.Index(fields=['timestamp', 'status'], name='nginxmon_ng_timesta_978a40_idx'),
|
||||
),
|
||||
]
|
||||
@@ -0,0 +1,169 @@
|
||||
from django.db import models
|
||||
|
||||
from netscan.fields import EncryptedCharField
|
||||
|
||||
|
||||
class NginxSettings(models.Model):
|
||||
"""
|
||||
Singleton (pk=1) — the one-off cluster connection config.
|
||||
Use NginxSettings.get() everywhere instead of pk lookups.
|
||||
"""
|
||||
namespace = models.CharField(max_length=200, default='ingress-nginx')
|
||||
pod_label = models.CharField(
|
||||
max_length=200,
|
||||
default='app.kubernetes.io/name=ingress-nginx',
|
||||
help_text='kubectl -l selector for the ingress-nginx pods',
|
||||
)
|
||||
container = models.CharField(max_length=100, default='controller')
|
||||
fetch_interval_seconds = models.IntegerField(
|
||||
default=30,
|
||||
help_text='How often to pull new logs (seconds)',
|
||||
)
|
||||
# Local dev / testing: read from a file instead of kubectl
|
||||
log_file_path = models.CharField(
|
||||
max_length=500,
|
||||
blank=True,
|
||||
help_text=(
|
||||
'Absolute path to a local nginx log file for testing. '
|
||||
'Leave empty to use kubectl in production.'
|
||||
),
|
||||
)
|
||||
enabled = models.BooleanField(default=True)
|
||||
last_fetch_at = models.DateTimeField(null=True, blank=True)
|
||||
|
||||
class Meta:
|
||||
verbose_name = 'Nginx Settings'
|
||||
verbose_name_plural = 'Nginx Settings'
|
||||
|
||||
def __str__(self):
|
||||
return f'Nginx Settings ({self.namespace})'
|
||||
|
||||
@classmethod
|
||||
def get(cls):
|
||||
obj, _ = cls.objects.get_or_create(pk=1)
|
||||
return obj
|
||||
|
||||
@property
|
||||
def source_display(self):
|
||||
if self.log_file_path:
|
||||
return f'File: {self.log_file_path}'
|
||||
return f'kubectl -n {self.namespace} -l {self.pod_label}'
|
||||
|
||||
|
||||
class NginxAlertProfile(models.Model):
|
||||
"""
|
||||
One or more alert profiles — each with its own thresholds and Telegram config.
|
||||
"""
|
||||
name = models.CharField(max_length=200)
|
||||
alert_window_seconds = models.IntegerField(
|
||||
default=60,
|
||||
help_text='Rolling window in seconds for rate analysis',
|
||||
)
|
||||
alert_max_requests = models.IntegerField(
|
||||
default=200,
|
||||
help_text='Max requests per IP per window before triggering a rate alert',
|
||||
)
|
||||
alert_max_error_rate = models.FloatField(
|
||||
default=0.6,
|
||||
help_text='Error rate threshold (0–1) to flag an IP',
|
||||
)
|
||||
alert_min_requests = models.IntegerField(
|
||||
default=15,
|
||||
help_text='Minimum requests before running error-rate check',
|
||||
)
|
||||
telegram_bot_token = EncryptedCharField(blank=True)
|
||||
telegram_chat_id = EncryptedCharField(blank=True)
|
||||
enabled = models.BooleanField(default=True)
|
||||
created_at = models.DateTimeField(auto_now_add=True)
|
||||
|
||||
def __str__(self):
|
||||
return self.name
|
||||
|
||||
|
||||
class NginxAccessLog(models.Model):
|
||||
"""Stores parsed nginx access log entries (cluster-global, no profile FK)."""
|
||||
timestamp = models.DateTimeField(db_index=True)
|
||||
remote_addr = models.GenericIPAddressField(db_index=True)
|
||||
method = models.CharField(max_length=20)
|
||||
request_uri = models.TextField()
|
||||
protocol = models.CharField(max_length=20)
|
||||
status = models.IntegerField(db_index=True)
|
||||
body_bytes_sent = models.IntegerField()
|
||||
http_referer = models.TextField(blank=True)
|
||||
http_user_agent = models.TextField(blank=True)
|
||||
request_length = models.IntegerField(default=0)
|
||||
request_time = models.FloatField()
|
||||
service = models.CharField(max_length=200, blank=True, db_index=True)
|
||||
upstream_addr = models.CharField(max_length=200, blank=True)
|
||||
upstream_response_time = models.FloatField(null=True, blank=True)
|
||||
upstream_status = models.IntegerField(null=True, blank=True)
|
||||
request_id = models.CharField(max_length=100, blank=True, db_index=True)
|
||||
# Geo (populated after insert)
|
||||
country = models.CharField(max_length=100, blank=True)
|
||||
country_code = models.CharField(max_length=10, blank=True)
|
||||
region = models.CharField(max_length=100, blank=True)
|
||||
city = models.CharField(max_length=100, blank=True)
|
||||
|
||||
class Meta:
|
||||
ordering = ['-timestamp']
|
||||
indexes = [
|
||||
models.Index(fields=['remote_addr', 'timestamp']),
|
||||
models.Index(fields=['timestamp', 'status']), # covers chart aggregation queries
|
||||
]
|
||||
|
||||
@property
|
||||
def geo_display(self):
|
||||
parts = [p for p in [self.city, self.country] if p]
|
||||
return ', '.join(parts) if parts else '—'
|
||||
|
||||
|
||||
class IPGeoCache(models.Model):
|
||||
ip = models.GenericIPAddressField(unique=True, db_index=True)
|
||||
country = models.CharField(max_length=100, blank=True)
|
||||
country_code = models.CharField(max_length=10, blank=True)
|
||||
region = models.CharField(max_length=100, blank=True)
|
||||
city = models.CharField(max_length=100, blank=True)
|
||||
lat = models.FloatField(null=True, blank=True)
|
||||
lon = models.FloatField(null=True, blank=True)
|
||||
isp = models.CharField(max_length=300, blank=True)
|
||||
is_private = models.BooleanField(default=False)
|
||||
fetched_at = models.DateTimeField(auto_now=True)
|
||||
|
||||
def __str__(self):
|
||||
if self.is_private:
|
||||
return f'{self.ip} (private)'
|
||||
parts = [p for p in [self.city, self.country] if p]
|
||||
return f'{self.ip} — {", ".join(parts)}'
|
||||
|
||||
|
||||
class ThreatAlert(models.Model):
|
||||
ALERT_TYPES = [
|
||||
('rate_limit', 'High Request Rate'),
|
||||
('error_rate', 'High Error Rate'),
|
||||
]
|
||||
|
||||
profile = models.ForeignKey(
|
||||
NginxAlertProfile, on_delete=models.CASCADE, related_name='alerts'
|
||||
)
|
||||
alert_type = models.CharField(max_length=50, choices=ALERT_TYPES)
|
||||
remote_addr = models.GenericIPAddressField(db_index=True)
|
||||
detected_at = models.DateTimeField(auto_now_add=True)
|
||||
window_start = models.DateTimeField()
|
||||
window_end = models.DateTimeField()
|
||||
request_count = models.IntegerField()
|
||||
error_count = models.IntegerField(default=0)
|
||||
detail = models.TextField()
|
||||
notified = models.BooleanField(default=False)
|
||||
dismissed = models.BooleanField(default=False)
|
||||
country = models.CharField(max_length=100, blank=True)
|
||||
city = models.CharField(max_length=100, blank=True)
|
||||
|
||||
class Meta:
|
||||
ordering = ['-detected_at']
|
||||
|
||||
def __str__(self):
|
||||
return f'[{self.get_alert_type_display()}] {self.remote_addr} @ {self.detected_at:%Y-%m-%d %H:%M}'
|
||||
|
||||
@property
|
||||
def error_rate(self):
|
||||
return self.error_count / self.request_count if self.request_count else 0
|
||||
@@ -0,0 +1,52 @@
|
||||
import logging
|
||||
import requests
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
_ICONS = {'rate_limit': '🚨', 'error_rate': '⚠️'}
|
||||
|
||||
|
||||
def send_alert_telegram(profile, alert) -> bool:
|
||||
if not profile.telegram_bot_token or not profile.telegram_chat_id:
|
||||
return False
|
||||
|
||||
icon = _ICONS.get(alert.alert_type, '🔔')
|
||||
geo = f' ({alert.city}, {alert.country})' if alert.country else ''
|
||||
text = (
|
||||
f'{icon} *NginxMon Alert* — {profile.name}\n'
|
||||
f'Type: {alert.get_alert_type_display()}\n'
|
||||
f'IP: `{alert.remote_addr}`{geo}\n'
|
||||
f'{alert.detail}\n'
|
||||
f'_Detected at {alert.detected_at.strftime("%Y-%m-%d %H:%M:%S UTC") if alert.detected_at else "now"}_'
|
||||
)
|
||||
url = f'https://api.telegram.org/bot{profile.telegram_bot_token}/sendMessage'
|
||||
try:
|
||||
resp = requests.post(url, json={
|
||||
'chat_id': profile.telegram_chat_id,
|
||||
'text': text,
|
||||
'parse_mode': 'Markdown',
|
||||
}, timeout=10)
|
||||
resp.raise_for_status()
|
||||
alert.notified = True
|
||||
alert.save(update_fields=['notified'])
|
||||
logger.info('nginxmon: Telegram alert sent for alert #%s', alert.pk)
|
||||
return True
|
||||
except Exception as exc:
|
||||
logger.error('nginxmon: Telegram send failed: %s', exc)
|
||||
return False
|
||||
|
||||
|
||||
def send_test_telegram(profile) -> dict:
|
||||
if not profile.telegram_bot_token or not profile.telegram_chat_id:
|
||||
return {'ok': False, 'error': 'Bot token or chat ID not set.'}
|
||||
try:
|
||||
url = f'https://api.telegram.org/bot{profile.telegram_bot_token}/sendMessage'
|
||||
resp = requests.post(url, json={
|
||||
'chat_id': profile.telegram_chat_id,
|
||||
'text': f'✅ *NginxMon test* from profile _{profile.name}_. Alerts are working.',
|
||||
'parse_mode': 'Markdown',
|
||||
}, timeout=10)
|
||||
resp.raise_for_status()
|
||||
return {'ok': True}
|
||||
except Exception as e:
|
||||
return {'ok': False, 'error': str(e)}
|
||||
@@ -0,0 +1,83 @@
|
||||
"""
|
||||
Parse nginx ingress-controller log lines.
|
||||
|
||||
Log format:
|
||||
<pod> <container> <remote_addr> - - [<time_local>] "<method> <uri> <proto>" <status>
|
||||
<bytes> "<referer>" "<user_agent>" <req_len> <req_time> [<service>] []
|
||||
<upstream_addr> <upstream_resp_len> <upstream_resp_time> <upstream_status> <req_id>
|
||||
"""
|
||||
import re
|
||||
import logging
|
||||
from datetime import datetime, timezone
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
# The leading \S* tolerates stray chars (e.g. 'z') sometimes prepended by log shippers.
|
||||
_LOG_RE = re.compile(
|
||||
r'\S*ingress-nginx\S+\s+' # pod name
|
||||
r'(?P<container>\S+)\s+' # container
|
||||
r'(?P<remote_addr>[\d.a-fA-F:]+)\s+-\s+-\s+' # IP - -
|
||||
r'\[(?P<time_local>[^\]]+)\]\s+' # [timestamp]
|
||||
r'"(?P<method>\S+)\s+(?P<request_uri>\S+)\s+' # "METHOD /path
|
||||
r'(?P<protocol>[^"]+)"\s+' # PROTO"
|
||||
r'(?P<status>\d+)\s+' # status
|
||||
r'(?P<body_bytes_sent>\d+)\s+' # bytes
|
||||
r'"(?P<http_referer>[^"]*)"\s+' # "referer"
|
||||
r'"(?P<http_user_agent>[^"]*)"\s+' # "ua"
|
||||
r'(?P<request_length>\d+)\s+' # req_len
|
||||
r'(?P<request_time>[\d.]+)\s+' # req_time
|
||||
r'\[(?P<service>[^\]]*)\]\s+' # [service]
|
||||
r'\[(?P<extra>[^\]]*)\]\s+' # []
|
||||
r'(?P<upstream_addr>\S+)\s+' # upstream addr
|
||||
r'(?P<upstream_response_length>\d+)\s+' # upstream bytes
|
||||
r'(?P<upstream_response_time>[\d.]+)\s+' # upstream time
|
||||
r'(?P<upstream_status>\d+)\s+' # upstream status
|
||||
r'(?P<request_id>\S+)' # request id
|
||||
)
|
||||
|
||||
_TIME_FMT = '%d/%b/%Y:%H:%M:%S %z'
|
||||
|
||||
|
||||
def parse_line(line: str) -> dict | None:
|
||||
"""Return a parsed dict or None if the line doesn't match."""
|
||||
m = _LOG_RE.search(line)
|
||||
if not m:
|
||||
return None
|
||||
d = m.groupdict()
|
||||
try:
|
||||
ts = datetime.strptime(d['time_local'], _TIME_FMT)
|
||||
except ValueError:
|
||||
logger.debug('Bad timestamp: %s', d['time_local'])
|
||||
return None
|
||||
return {
|
||||
'timestamp': ts,
|
||||
'remote_addr': d['remote_addr'],
|
||||
'method': d['method'],
|
||||
'request_uri': d['request_uri'],
|
||||
'protocol': d['protocol'].strip(),
|
||||
'status': int(d['status']),
|
||||
'body_bytes_sent': int(d['body_bytes_sent']),
|
||||
'http_referer': d['http_referer'],
|
||||
'http_user_agent': d['http_user_agent'],
|
||||
'request_length': int(d['request_length']),
|
||||
'request_time': float(d['request_time']),
|
||||
'service': d['service'],
|
||||
'upstream_addr': d['upstream_addr'],
|
||||
'upstream_response_time': float(d['upstream_response_time']),
|
||||
'upstream_status': int(d['upstream_status']),
|
||||
'request_id': d['request_id'],
|
||||
}
|
||||
|
||||
|
||||
def parse_lines(text: str) -> list[dict]:
|
||||
results = []
|
||||
for line in text.splitlines():
|
||||
line = line.strip()
|
||||
if not line:
|
||||
continue
|
||||
parsed = parse_line(line)
|
||||
if parsed:
|
||||
results.append(parsed)
|
||||
else:
|
||||
logger.debug('Skipped unparseable line: %.120s', line)
|
||||
return results
|
||||
@@ -0,0 +1,56 @@
|
||||
import logging
|
||||
|
||||
from apscheduler.triggers.interval import IntervalTrigger
|
||||
from core.scheduler import scheduler
|
||||
from nginxmon.fetcher import fetch_and_store, cleanup_old_logs
|
||||
from nginxmon.detector import detect_threats
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
_FETCH_JOB = 'nginxmon_fetch'
|
||||
_CLEANUP_JOB = 'nginxmon_cleanup'
|
||||
|
||||
|
||||
def start_fetch_job(interval_seconds: int):
|
||||
scheduler.add_job(
|
||||
fetch_and_store,
|
||||
trigger=IntervalTrigger(seconds=interval_seconds),
|
||||
id=_FETCH_JOB,
|
||||
replace_existing=True,
|
||||
misfire_grace_time=60,
|
||||
)
|
||||
logger.info('nginxmon: scheduled fetch every %ds', interval_seconds)
|
||||
|
||||
|
||||
def stop_fetch_job():
|
||||
if scheduler.get_job(_FETCH_JOB):
|
||||
scheduler.remove_job(_FETCH_JOB)
|
||||
|
||||
|
||||
def schedule_profile(profile):
|
||||
job_id = f'nginxmon_detect_{profile.pk}'
|
||||
scheduler.add_job(
|
||||
detect_threats,
|
||||
trigger=IntervalTrigger(seconds=max(60, profile.alert_window_seconds)),
|
||||
id=job_id,
|
||||
args=[profile.pk],
|
||||
replace_existing=True,
|
||||
misfire_grace_time=120,
|
||||
)
|
||||
logger.info('nginxmon: scheduled detect for profile "%s"', profile.name)
|
||||
|
||||
|
||||
def unschedule_profile(profile):
|
||||
job_id = f'nginxmon_detect_{profile.pk}'
|
||||
if scheduler.get_job(job_id):
|
||||
scheduler.remove_job(job_id)
|
||||
|
||||
|
||||
def start_cleanup_job():
|
||||
if not scheduler.get_job(_CLEANUP_JOB):
|
||||
scheduler.add_job(
|
||||
cleanup_old_logs,
|
||||
trigger=IntervalTrigger(hours=24),
|
||||
id=_CLEANUP_JOB,
|
||||
replace_existing=True,
|
||||
)
|
||||
@@ -0,0 +1,50 @@
|
||||
{% if alerts %}
|
||||
<table class="min-w-full text-xs">
|
||||
<thead class="bg-gray-50">
|
||||
<tr>
|
||||
<th class="px-3 py-2 text-left text-gray-500 font-semibold">Detected</th>
|
||||
<th class="px-3 py-2 text-left text-gray-500 font-semibold">Type</th>
|
||||
<th class="px-3 py-2 text-left text-gray-500 font-semibold">IP</th>
|
||||
<th class="px-3 py-2 text-left text-gray-500 font-semibold hidden sm:table-cell">Location</th>
|
||||
<th class="px-3 py-2 text-left text-gray-500 font-semibold">Detail</th>
|
||||
<th class="px-3 py-2 text-left text-gray-500 font-semibold hidden sm:table-cell">Profile</th>
|
||||
<th class="px-3 py-2 text-right text-gray-500 font-semibold">Action</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody class="divide-y divide-gray-50">
|
||||
{% for alert in alerts %}
|
||||
<tr class="hover:bg-yellow-50/30">
|
||||
<td class="px-3 py-2 font-mono text-gray-500 whitespace-nowrap">{{ alert.detected_at|date:"M d H:i" }}</td>
|
||||
<td class="px-3 py-2">
|
||||
<span class="inline-flex items-center px-2 py-0.5 rounded text-xs font-medium
|
||||
{% if alert.alert_type == 'rate_limit' %}bg-red-100 text-red-700{% else %}bg-yellow-100 text-yellow-700{% endif %}">
|
||||
{% if alert.alert_type == 'rate_limit' %}🚨{% else %}⚠️{% endif %}
|
||||
{{ alert.get_alert_type_display }}
|
||||
</span>
|
||||
</td>
|
||||
<td class="px-3 py-2 font-mono text-gray-700">{{ alert.remote_addr }}</td>
|
||||
<td class="px-3 py-2 text-gray-500 hidden sm:table-cell">
|
||||
{% if alert.city or alert.country %}{{ alert.city }}{% if alert.city and alert.country %}, {% endif %}{{ alert.country }}{% else %}—{% endif %}
|
||||
</td>
|
||||
<td class="px-3 py-2 text-gray-600 max-w-xs truncate" title="{{ alert.detail }}">{{ alert.detail }}</td>
|
||||
<td class="px-3 py-2 text-gray-400 hidden sm:table-cell text-xs">{{ alert.profile.name }}</td>
|
||||
<td class="px-3 py-2 text-right">
|
||||
<button
|
||||
hx-post="{% url 'nginxmon-dismiss-alert' alert.pk %}"
|
||||
hx-confirm="Dismiss this alert?"
|
||||
hx-swap="outerHTML"
|
||||
hx-target="closest tr"
|
||||
class="inline-flex items-center px-2 py-1 bg-gray-100 text-gray-600 rounded hover:bg-gray-200 text-xs">
|
||||
Dismiss
|
||||
</button>
|
||||
</td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
{% else %}
|
||||
<div class="px-4 py-8 text-center text-sm text-gray-400">
|
||||
<i class="fas fa-check-circle text-green-400 text-xl mb-2 block"></i>
|
||||
No active threat alerts
|
||||
</div>
|
||||
{% endif %}
|
||||
@@ -0,0 +1,29 @@
|
||||
{% for log in logs %}
|
||||
<tr class="hover:bg-gray-50 transition-colors {% if log.status >= 500 %}bg-red-50{% elif log.status >= 400 %}bg-amber-50/60{% endif %}">
|
||||
<td class="px-3 py-1.5 font-mono text-gray-500 whitespace-nowrap">{{ log.timestamp|date:"m/d H:i:s" }}</td>
|
||||
<td class="px-3 py-1.5 font-mono">
|
||||
<button class="text-indigo-600 hover:underline" onclick="setFilter('ip','{{ log.remote_addr }}')">{{ log.remote_addr }}</button>
|
||||
</td>
|
||||
<td class="px-3 py-1.5 text-gray-500 hidden sm:table-cell whitespace-nowrap">{{ log.geo_display }}</td>
|
||||
<td class="px-3 py-1.5">
|
||||
<span class="font-mono font-medium
|
||||
{% if log.method == 'GET' %}text-green-700{% elif log.method == 'POST' %}text-blue-700
|
||||
{% elif log.method == 'DELETE' %}text-red-700{% else %}text-gray-600{% endif %}">{{ log.method }}</span>
|
||||
</td>
|
||||
<td class="px-3 py-1.5 font-mono text-gray-700 max-w-xs truncate" title="{{ log.request_uri }}">{{ log.request_uri }}</td>
|
||||
<td class="px-3 py-1.5 hidden md:table-cell font-mono">
|
||||
{% if log.service %}<button class="text-indigo-600 hover:underline" onclick="setFilter('service','{{ log.service|escapejs }}')">{{ log.service }}</button>{% else %}—{% endif %}
|
||||
</td>
|
||||
<td class="px-3 py-1.5 text-right">
|
||||
<button onclick="setFilter('status','{{ log.status }}')"
|
||||
class="inline-block px-1.5 py-0.5 rounded font-mono font-semibold text-xs hover:opacity-75
|
||||
{% if log.status < 300 %}bg-green-100 text-green-800{% elif log.status < 400 %}bg-blue-100 text-blue-800
|
||||
{% elif log.status < 500 %}bg-amber-100 text-amber-800{% else %}bg-red-100 text-red-800{% endif %}">
|
||||
{{ log.status }}
|
||||
</button>
|
||||
</td>
|
||||
<td class="px-3 py-1.5 text-right font-mono text-gray-500 hidden lg:table-cell">{{ log.request_time|floatformat:3 }}s</td>
|
||||
</tr>
|
||||
{% empty %}
|
||||
<tr><td colspan="8" class="px-3 py-8 text-center text-gray-400">No logs yet — use "Fetch Now" or wait for the scheduler.</td></tr>
|
||||
{% endfor %}
|
||||
@@ -0,0 +1,416 @@
|
||||
{% extends 'base.html' %}
|
||||
|
||||
{% block content %}
|
||||
<script src="https://cdn.jsdelivr.net/npm/echarts@5.6.0/dist/echarts.min.js"></script>
|
||||
|
||||
<div class="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-6 space-y-6">
|
||||
|
||||
<!-- Header -->
|
||||
<div class="flex items-center justify-between flex-wrap gap-3">
|
||||
<div>
|
||||
<h1 class="text-2xl font-bold text-gray-900 flex items-center gap-2">
|
||||
<i class="fas fa-eye text-indigo-500"></i>
|
||||
Nginx Monitor
|
||||
<span class="inline-flex items-center gap-1 text-xs font-medium px-2 py-0.5 rounded-full
|
||||
bg-green-100 text-green-700 animate-pulse">
|
||||
<span class="w-1.5 h-1.5 rounded-full bg-green-500 inline-block"></span> Live
|
||||
</span>
|
||||
</h1>
|
||||
<p class="text-sm text-gray-400 mt-0.5 font-mono">{{ nginx_settings.source_display }}</p>
|
||||
<div class="mt-1 flex items-center gap-2 text-xs text-gray-400">
|
||||
<span id="ingest-last-fetch">{% if nginx_settings.last_fetch_at %}Last: {{ nginx_settings.last_fetch_at|timesince }} ago{% else %}No ingestion yet{% endif %}</span>
|
||||
<span>·</span>
|
||||
<span id="ingest-total">—</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex flex-col items-end gap-2">
|
||||
<!-- Time range selector -->
|
||||
<div class="flex items-center gap-1 bg-gray-100 rounded-lg p-1">
|
||||
{% for rkey, rlabel in all_ranges %}
|
||||
<a href="?range={{ rkey }}"
|
||||
class="px-2.5 py-1 text-xs rounded-md font-medium transition-colors
|
||||
{% if rkey == current_range %}bg-white text-gray-900 shadow-sm{% else %}text-gray-500 hover:text-gray-700{% endif %}">
|
||||
{{ rlabel|slice:"5:" }}
|
||||
</a>
|
||||
{% endfor %}
|
||||
</div>
|
||||
<!-- Active filters chips -->
|
||||
<div id="active-filters" class="flex items-center gap-1.5 flex-wrap justify-end min-h-[1.25rem]"></div>
|
||||
<div class="flex items-center gap-2 flex-wrap justify-end">
|
||||
<button id="ingest-toggle"
|
||||
onclick="toggleIngestion()"
|
||||
class="inline-flex items-center px-3 py-1.5 text-xs rounded-md font-medium transition-colors {% if nginx_settings.enabled %}bg-green-100 text-green-700 hover:bg-green-200{% else %}bg-gray-200 text-gray-500 hover:bg-gray-300{% endif %}">
|
||||
<span id="toggle-dot" class="w-1.5 h-1.5 rounded-full mr-1.5 {% if nginx_settings.enabled %}bg-green-500{% else %}bg-gray-400{% endif %}"></span>
|
||||
<span id="toggle-label">{% if nginx_settings.enabled %}Ingestion On{% else %}Ingestion Off{% endif %}</span>
|
||||
</button>
|
||||
<form method="post" action="{% url 'nginxmon-fetch' %}">
|
||||
{% csrf_token %}
|
||||
<button type="submit" class="inline-flex items-center px-3 py-1.5 bg-indigo-100 text-indigo-700 text-xs rounded-md hover:bg-indigo-200 font-medium">
|
||||
<i class="fas fa-sync-alt mr-1.5"></i> Fetch Now
|
||||
</button>
|
||||
</form>
|
||||
<form method="post" action="{% url 'nginxmon-detect' %}">
|
||||
{% csrf_token %}
|
||||
<button type="submit" class="inline-flex items-center px-3 py-1.5 bg-yellow-100 text-yellow-700 text-xs rounded-md hover:bg-yellow-200 font-medium">
|
||||
<i class="fas fa-search mr-1.5"></i> Detect Threats
|
||||
</button>
|
||||
</form>
|
||||
<a href="{% url 'nginxmon-profile-create' %}"
|
||||
class="inline-flex items-center px-3 py-1.5 bg-indigo-600 text-white text-xs rounded-md hover:bg-indigo-700 font-medium">
|
||||
<i class="fas fa-plus mr-1.5"></i> New Alert Profile
|
||||
</a>
|
||||
<a href="{% url 'nginxmon-settings' %}"
|
||||
class="inline-flex items-center px-3 py-1.5 bg-gray-100 text-gray-600 text-xs rounded-md hover:bg-gray-200">
|
||||
<i class="fas fa-cog mr-1.5"></i> Settings
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Messages -->
|
||||
{% for msg in messages %}
|
||||
<div class="px-4 py-2 rounded-md text-sm border
|
||||
{% if msg.tags == 'success' %}bg-green-50 text-green-800 border-green-200
|
||||
{% elif msg.tags == 'error' %}bg-red-50 text-red-800 border-red-200
|
||||
{% else %}bg-blue-50 text-blue-800 border-blue-200{% endif %}">
|
||||
{{ msg }}
|
||||
</div>
|
||||
{% endfor %}
|
||||
|
||||
<!-- Stats row -->
|
||||
<div class="grid grid-cols-2 sm:grid-cols-4 gap-4">
|
||||
{% with s=stats %}
|
||||
<div class="bg-white rounded-lg shadow-sm border border-gray-100 p-4">
|
||||
<div class="text-xs text-gray-400 font-medium uppercase tracking-wide mb-1">Requests</div>
|
||||
<div class="text-2xl font-bold text-gray-900">{{ s.total }}</div>
|
||||
</div>
|
||||
<div class="bg-white rounded-lg shadow-sm border border-gray-100 p-4">
|
||||
<div class="text-xs text-gray-400 font-medium uppercase tracking-wide mb-1">Errors</div>
|
||||
<div class="text-2xl font-bold {% if s.errors %}text-red-600{% else %}text-gray-900{% endif %}">{{ s.errors }}</div>
|
||||
</div>
|
||||
<div class="bg-white rounded-lg shadow-sm border border-gray-100 p-4">
|
||||
<div class="text-xs text-gray-400 font-medium uppercase tracking-wide mb-1">Unique IPs</div>
|
||||
<div class="text-2xl font-bold text-gray-900">{{ s.unique_ips }}</div>
|
||||
</div>
|
||||
<div class="bg-white rounded-lg shadow-sm border border-gray-100 p-4">
|
||||
<div class="text-xs text-gray-400 font-medium uppercase tracking-wide mb-1">Avg Response</div>
|
||||
<div class="text-2xl font-bold text-gray-900">{{ s.avg_resp_ms }} ms</div>
|
||||
</div>
|
||||
{% endwith %}
|
||||
</div>
|
||||
|
||||
<!-- Charts -->
|
||||
<div class="grid grid-cols-1 lg:grid-cols-3 gap-4">
|
||||
<div class="lg:col-span-2 bg-white rounded-lg shadow-sm border border-gray-100 p-4">
|
||||
<div class="text-sm font-semibold text-gray-700 mb-3" id="chart-title">{{ range_label }}</div>
|
||||
<div id="timelineChart" style="height:220px;"></div>
|
||||
</div>
|
||||
<div class="bg-white rounded-lg shadow-sm border border-gray-100 p-4">
|
||||
<div class="text-sm font-semibold text-gray-700 mb-3">Status codes — {{ range_label }}</div>
|
||||
<div id="statusChart" style="height:220px;"></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Top tables -->
|
||||
<div class="grid grid-cols-1 lg:grid-cols-2 gap-4">
|
||||
<div class="bg-white rounded-lg shadow-sm border border-gray-100 overflow-hidden">
|
||||
<div class="px-4 py-3 border-b border-gray-100 text-sm font-semibold text-gray-700">Top IPs — {{ range_label }}</div>
|
||||
<table class="w-full text-xs">
|
||||
<thead class="bg-gray-50 text-gray-500">
|
||||
<tr>
|
||||
<th class="px-3 py-2 text-left">IP</th>
|
||||
<th class="px-3 py-2 text-left">Location</th>
|
||||
<th class="px-3 py-2 text-right">Reqs</th>
|
||||
<th class="px-3 py-2 text-right">Errors</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody class="divide-y divide-gray-50">
|
||||
{% for ip in top_ips %}
|
||||
<tr class="hover:bg-gray-50">
|
||||
<td class="px-3 py-2 font-mono">
|
||||
<button class="text-indigo-600 hover:underline font-mono" onclick="setFilter('ip','{{ ip.remote_addr }}')">{{ ip.remote_addr }}</button>
|
||||
</td>
|
||||
<td class="px-3 py-2 text-gray-500">
|
||||
{% if ip.city or ip.country %}{{ ip.city }}{% if ip.city and ip.country %}, {% endif %}{{ ip.country }}{% else %}—{% endif %}
|
||||
</td>
|
||||
<td class="px-3 py-2 text-right font-medium">{{ ip.count }}</td>
|
||||
<td class="px-3 py-2 text-right {% if ip.errors %}text-red-600{% else %}text-gray-400{% endif %}">{{ ip.errors }}</td>
|
||||
</tr>
|
||||
{% empty %}
|
||||
<tr><td colspan="4" class="px-3 py-6 text-center text-gray-400">No data yet</td></tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
<div class="bg-white rounded-lg shadow-sm border border-gray-100 overflow-hidden">
|
||||
<div class="px-4 py-3 border-b border-gray-100 text-sm font-semibold text-gray-700">Top Services — {{ range_label }}</div>
|
||||
<table class="w-full text-xs">
|
||||
<thead class="bg-gray-50 text-gray-500">
|
||||
<tr>
|
||||
<th class="px-3 py-2 text-left">Service</th>
|
||||
<th class="px-3 py-2 text-right">Requests</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody class="divide-y divide-gray-50">
|
||||
{% for svc in top_services %}
|
||||
<tr class="hover:bg-gray-50">
|
||||
<td class="px-3 py-2 font-mono text-gray-700">
|
||||
{% if svc.service %}<button class="text-indigo-600 hover:underline font-mono" onclick="setFilter('service','{{ svc.service|escapejs }}')">{{ svc.service }}</button>{% else %}—{% endif %}
|
||||
</td>
|
||||
<td class="px-3 py-2 text-right font-medium">{{ svc.count }}</td>
|
||||
</tr>
|
||||
{% empty %}
|
||||
<tr><td colspan="2" class="px-3 py-6 text-center text-gray-400">No data yet</td></tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Alert profiles -->
|
||||
{% if profiles %}
|
||||
<div class="bg-white rounded-lg shadow-sm border border-gray-100 overflow-hidden">
|
||||
<div class="px-4 py-3 border-b border-gray-100 flex items-center justify-between">
|
||||
<div class="text-sm font-semibold text-gray-700">Alert Profiles</div>
|
||||
<a href="{% url 'nginxmon-profile-create' %}" class="text-xs text-indigo-600 hover:underline">+ New</a>
|
||||
</div>
|
||||
<div class="divide-y divide-gray-50">
|
||||
{% for p in profiles %}
|
||||
<div class="px-4 py-3 flex items-center justify-between text-sm">
|
||||
<div>
|
||||
<span class="font-medium text-gray-800">{{ p.name }}</span>
|
||||
<span class="ml-2 text-xs text-gray-400">
|
||||
window {{ p.alert_window_seconds }}s • max {{ p.alert_max_requests }} req • {{ p.alert_max_error_rate|floatformat:0 }}% err rate
|
||||
</span>
|
||||
</div>
|
||||
<div class="flex items-center gap-3">
|
||||
<form method="post" action="{% url 'nginxmon-test-telegram' p.pk %}" class="inline">
|
||||
{% csrf_token %}
|
||||
<button type="submit" class="text-xs text-blue-600 hover:underline">Test Telegram</button>
|
||||
</form>
|
||||
<a href="{% url 'nginxmon-profile-edit' p.pk %}" class="text-xs text-gray-500 hover:underline">Edit</a>
|
||||
<a href="{% url 'nginxmon-profile-delete' p.pk %}" class="text-xs text-red-400 hover:underline">Delete</a>
|
||||
</div>
|
||||
</div>
|
||||
{% endfor %}
|
||||
</div>
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
<!-- Threat alerts -->
|
||||
<div class="bg-white rounded-lg shadow-sm border border-gray-100 overflow-hidden">
|
||||
<div class="px-4 py-3 border-b border-gray-100 flex items-center gap-2">
|
||||
<i class="fas fa-exclamation-triangle text-yellow-500 text-sm"></i>
|
||||
<span class="text-sm font-semibold text-gray-700">Threat Alerts</span>
|
||||
{% if active_alerts %}<span class="ml-1 bg-red-100 text-red-700 text-xs font-medium px-1.5 py-0.5 rounded">{{ active_alerts }}</span>{% endif %}
|
||||
</div>
|
||||
<div id="alerts-container"
|
||||
hx-get="{% url 'nginxmon-alerts-partial' %}"
|
||||
hx-trigger="every 15s"
|
||||
hx-swap="innerHTML">
|
||||
{% include 'nginxmon/_alerts.html' %}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Live log feed -->
|
||||
<div class="bg-white rounded-lg shadow-sm border border-gray-100 overflow-hidden">
|
||||
<div class="px-4 py-3 border-b border-gray-100 flex items-center justify-between">
|
||||
<div class="flex items-center gap-2 text-sm font-semibold text-gray-700">
|
||||
<span class="w-2 h-2 rounded-full bg-green-400 animate-pulse inline-block"></span>
|
||||
Live Access Log
|
||||
</div>
|
||||
<span class="text-xs text-gray-400">Auto-refreshes every 5s • last 60 entries</span>
|
||||
</div>
|
||||
<div class="overflow-x-auto">
|
||||
<table class="min-w-full text-xs">
|
||||
<thead class="bg-gray-50 border-b border-gray-100 select-none">
|
||||
<tr>
|
||||
<th data-sort="timestamp" onclick="setSort('timestamp')" class="px-3 py-2 text-left text-gray-500 font-semibold cursor-pointer hover:bg-gray-100 whitespace-nowrap">Time <span class="sort-icon text-gray-400 ml-0.5">▼</span></th>
|
||||
<th data-sort="ip" onclick="setSort('ip')" class="px-3 py-2 text-left text-gray-500 font-semibold cursor-pointer hover:bg-gray-100">IP <span class="sort-icon text-gray-300 ml-0.5">⇅</span></th>
|
||||
<th class="px-3 py-2 text-left text-gray-500 font-semibold hidden sm:table-cell">Location</th>
|
||||
<th data-sort="method" onclick="setSort('method')" class="px-3 py-2 text-left text-gray-500 font-semibold cursor-pointer hover:bg-gray-100">Method <span class="sort-icon text-gray-300 ml-0.5">⇅</span></th>
|
||||
<th class="px-3 py-2 text-left text-gray-500 font-semibold">Path</th>
|
||||
<th data-sort="service" onclick="setSort('service')" class="px-3 py-2 text-left text-gray-500 font-semibold cursor-pointer hover:bg-gray-100 hidden md:table-cell">Service <span class="sort-icon text-gray-300 ml-0.5">⇅</span></th>
|
||||
<th data-sort="status" onclick="setSort('status')" class="px-3 py-2 text-right text-gray-500 font-semibold cursor-pointer hover:bg-gray-100">Status <span class="sort-icon text-gray-300 ml-0.5">⇅</span></th>
|
||||
<th data-sort="time" onclick="setSort('time')" class="px-3 py-2 text-right text-gray-500 font-semibold cursor-pointer hover:bg-gray-100 hidden lg:table-cell">Time(s) <span class="sort-icon text-gray-300 ml-0.5">⇅</span></th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody id="live-log-body" class="divide-y divide-gray-50">
|
||||
{% include 'nginxmon/_live_logs.html' %}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
|
||||
<script>
|
||||
// ── Timeline chart ────────────────────────────────────────────────────────────
|
||||
const timelineChart = echarts.init(document.getElementById('timelineChart'));
|
||||
const _currentRange = '{{ current_range }}';
|
||||
|
||||
function loadTimeline() {
|
||||
fetch('{% url "nginxmon-chart-data" %}?range=' + _currentRange)
|
||||
.then(r => r.json())
|
||||
.then(data => {
|
||||
const titleEl = document.getElementById('chart-title');
|
||||
if (titleEl && data.label) titleEl.textContent = data.label;
|
||||
timelineChart.setOption({
|
||||
tooltip: { trigger: 'axis' },
|
||||
legend: { data: ['Total', 'Errors'], bottom: 0, textStyle: { fontSize: 11 } },
|
||||
grid: { top: 10, right: 10, bottom: 30, left: 40 },
|
||||
xAxis: { type: 'category', data: data.labels, axisLabel: { fontSize: 10, rotate: data.labels.length > 30 ? 30 : 0 } },
|
||||
yAxis: { type: 'value', axisLabel: { fontSize: 10 }, minInterval: 1 },
|
||||
series: [
|
||||
{ name: 'Total', type: 'line', data: data.total, smooth: true,
|
||||
areaStyle: { opacity: 0.1 }, lineStyle: { color: '#6366f1' }, itemStyle: { color: '#6366f1' } },
|
||||
{ name: 'Errors', type: 'line', data: data.errors, smooth: true,
|
||||
areaStyle: { opacity: 0.1 }, lineStyle: { color: '#ef4444' }, itemStyle: { color: '#ef4444' } },
|
||||
],
|
||||
});
|
||||
});
|
||||
}
|
||||
loadTimeline();
|
||||
setInterval(loadTimeline, 30000);
|
||||
window.addEventListener('resize', () => timelineChart.resize());
|
||||
|
||||
// ── Status pie chart ──────────────────────────────────────────────────────────
|
||||
const statusChart = echarts.init(document.getElementById('statusChart'));
|
||||
const _statusColors = { '2': '#22c55e', '3': '#3b82f6', '4': '#f59e0b', '5': '#ef4444' };
|
||||
statusChart.setOption({
|
||||
tooltip: { trigger: 'item', formatter: '{b}: {c} ({d}%)' },
|
||||
legend: { type: 'scroll', bottom: 0, textStyle: { fontSize: 10 } },
|
||||
series: [{
|
||||
type: 'pie', radius: ['35%', '65%'], center: ['50%', '45%'],
|
||||
data: {{ status_dist_json|safe }}.map(s => ({
|
||||
name: String(s.status), value: s.count,
|
||||
itemStyle: { color: _statusColors[String(s.status)[0]] || '#94a3b8' },
|
||||
})),
|
||||
label: { fontSize: 10 },
|
||||
cursor: 'pointer',
|
||||
}],
|
||||
});
|
||||
statusChart.on('click', p => setFilter('status', p.name));
|
||||
window.addEventListener('resize', () => statusChart.resize());
|
||||
</script>
|
||||
|
||||
<script>
|
||||
// ── Live-log filter & sort state ──────────────────────────────────────────────
|
||||
const _logBase = '{% url "nginxmon-live-logs" %}';
|
||||
const _f = { ip: '', status: '', service: '', sort: 'timestamp', order: 'desc' };
|
||||
|
||||
function _logsUrl() {
|
||||
const p = new URLSearchParams();
|
||||
if (_f.ip) p.set('ip', _f.ip);
|
||||
if (_f.status) p.set('status', _f.status);
|
||||
if (_f.service) p.set('service', _f.service);
|
||||
p.set('sort', _f.sort);
|
||||
p.set('order', _f.order);
|
||||
return _logBase + '?' + p;
|
||||
}
|
||||
|
||||
function _refreshLogs() {
|
||||
htmx.ajax('GET', _logsUrl(), {target: '#live-log-body', swap: 'innerHTML'});
|
||||
_renderChips();
|
||||
_updateSortIcons();
|
||||
}
|
||||
|
||||
function setFilter(type, value) {
|
||||
_f[type] = (_f[type] === String(value)) ? '' : String(value);
|
||||
_refreshLogs();
|
||||
}
|
||||
|
||||
function clearFilter(type) { _f[type] = ''; _refreshLogs(); }
|
||||
|
||||
function setSort(col) {
|
||||
if (_f.sort === col) { _f.order = _f.order === 'desc' ? 'asc' : 'desc'; }
|
||||
else { _f.sort = col; _f.order = 'desc'; }
|
||||
_refreshLogs();
|
||||
}
|
||||
|
||||
function _renderChips() {
|
||||
const c = document.getElementById('active-filters');
|
||||
if (!c) return;
|
||||
const items = [['ip', _f.ip, 'IP'], ['status', _f.status, 'Status'], ['service', _f.service, 'Service']].filter(([,v]) => v);
|
||||
c.innerHTML = items.map(([t, v]) =>
|
||||
`<span class="inline-flex items-center gap-1 px-2 py-0.5 bg-indigo-100 text-indigo-700 text-xs rounded-full font-medium">
|
||||
<span class="opacity-60 capitalize">${t}:</span><span class="font-mono">${v}</span>
|
||||
<button onclick="clearFilter('${t}')" class="text-indigo-400 hover:text-indigo-800 font-bold leading-none ml-0.5">×</button>
|
||||
</span>`
|
||||
).join('');
|
||||
}
|
||||
|
||||
function _updateSortIcons() {
|
||||
document.querySelectorAll('th[data-sort]').forEach(th => {
|
||||
const icon = th.querySelector('.sort-icon');
|
||||
if (!icon) return;
|
||||
if (_f.sort === th.dataset.sort) {
|
||||
icon.textContent = _f.order === 'asc' ? '▲' : '▼';
|
||||
icon.className = 'sort-icon text-indigo-500 ml-0.5';
|
||||
} else {
|
||||
icon.textContent = '⇅';
|
||||
icon.className = 'sort-icon text-gray-300 ml-0.5';
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// Auto-poll live logs every 5s using JS interval (no HTMX trigger needed)
|
||||
setInterval(_refreshLogs, 5000);
|
||||
</script>
|
||||
|
||||
<script>
|
||||
// ── Ingestion toggle ──────────────────────────────────────────────────────────
|
||||
const _csrfToken = '{{ csrf_token }}';
|
||||
|
||||
function toggleIngestion() {
|
||||
fetch('{% url "nginxmon-toggle" %}', {
|
||||
method: 'POST',
|
||||
headers: { 'X-CSRFToken': _csrfToken },
|
||||
})
|
||||
.then(r => r.json())
|
||||
.then(d => _applyToggleUI(d.enabled))
|
||||
.catch(console.error);
|
||||
}
|
||||
|
||||
function _applyToggleUI(enabled) {
|
||||
const base = 'inline-flex items-center px-3 py-1.5 text-xs rounded-md font-medium transition-colors';
|
||||
const btn = document.getElementById('ingest-toggle');
|
||||
const dot = document.getElementById('toggle-dot');
|
||||
const lbl = document.getElementById('toggle-label');
|
||||
if (!btn) return;
|
||||
if (enabled) {
|
||||
btn.className = base + ' bg-green-100 text-green-700 hover:bg-green-200';
|
||||
dot.className = 'w-1.5 h-1.5 rounded-full mr-1.5 bg-green-500';
|
||||
lbl.textContent = 'Ingestion On';
|
||||
} else {
|
||||
btn.className = base + ' bg-gray-200 text-gray-500 hover:bg-gray-300';
|
||||
dot.className = 'w-1.5 h-1.5 rounded-full mr-1.5 bg-gray-400';
|
||||
lbl.textContent = 'Ingestion Off';
|
||||
}
|
||||
}
|
||||
|
||||
// ── SSE status stream ─────────────────────────────────────────────────────────
|
||||
(function startStatusStream() {
|
||||
const es = new EventSource('{% url "nginxmon-status-stream" %}');
|
||||
es.onmessage = function(e) {
|
||||
const d = JSON.parse(e.data);
|
||||
_applyToggleUI(d.enabled);
|
||||
|
||||
const totalEl = document.getElementById('ingest-total');
|
||||
if (totalEl) totalEl.textContent = d.total_logs.toLocaleString() + ' logs';
|
||||
|
||||
if (d.last_fetch_at) {
|
||||
const diff = Math.round((Date.now() - new Date(d.last_fetch_at)) / 1000);
|
||||
let rel;
|
||||
if (diff < 60) rel = diff + 's ago';
|
||||
else if (diff < 3600) rel = Math.floor(diff / 60) + 'm ago';
|
||||
else rel = Math.floor(diff / 3600) + 'h ago';
|
||||
const lastEl = document.getElementById('ingest-last-fetch');
|
||||
if (lastEl) lastEl.textContent = 'Last: ' + rel;
|
||||
}
|
||||
};
|
||||
es.onerror = function() { es.close(); setTimeout(startStatusStream, 5000); };
|
||||
})();
|
||||
</script>
|
||||
{% endblock %}
|
||||
@@ -0,0 +1,28 @@
|
||||
{% extends 'base.html' %}
|
||||
|
||||
{% block content %}
|
||||
<div class="max-w-md mx-auto px-4 py-16">
|
||||
<div class="bg-white rounded-lg shadow-md overflow-hidden">
|
||||
<div class="px-6 py-5 bg-red-50 border-b border-red-100">
|
||||
<h1 class="text-lg font-bold text-red-800 flex items-center gap-2">
|
||||
<i class="fas fa-trash"></i> Delete Alert Profile
|
||||
</h1>
|
||||
</div>
|
||||
<div class="p-6">
|
||||
<p class="text-gray-700 mb-6">
|
||||
Delete alert profile <strong>{{ object.name }}</strong>?
|
||||
Its alerts history will also be removed.
|
||||
</p>
|
||||
<form method="post" class="flex gap-3">
|
||||
{% csrf_token %}
|
||||
<button type="submit" class="px-4 py-2 bg-red-600 text-white text-sm rounded-md hover:bg-red-700 font-medium">
|
||||
Yes, delete
|
||||
</button>
|
||||
<a href="{% url 'nginxmon-dashboard' %}" class="px-4 py-2 bg-gray-100 text-gray-700 text-sm rounded-md hover:bg-gray-200">
|
||||
Cancel
|
||||
</a>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{% endblock %}
|
||||
@@ -0,0 +1,36 @@
|
||||
{% extends 'base.html' %}
|
||||
|
||||
{% block content %}
|
||||
<div class="max-w-2xl mx-auto px-4 sm:px-6 lg:px-8 py-8">
|
||||
<div class="bg-white shadow-sm rounded-lg border border-gray-200 overflow-hidden">
|
||||
<div class="px-6 py-5 bg-gray-50 border-b border-gray-200">
|
||||
<h1 class="text-lg font-bold text-gray-900">
|
||||
{% if object %}Edit Alert Profile — {{ object.name }}{% else %}New Alert Profile{% endif %}
|
||||
</h1>
|
||||
<p class="text-xs text-gray-400 mt-0.5">
|
||||
Alert thresholds and Telegram notification settings for this monitoring rule.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<form method="post" class="p-6 space-y-5">
|
||||
{% csrf_token %}
|
||||
{% for field in form %}
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-gray-700 mb-1">{{ field.label }}</label>
|
||||
{{ field }}
|
||||
{% if field.help_text %}<p class="text-xs text-gray-400 mt-1">{{ field.help_text }}</p>{% endif %}
|
||||
{% for error in field.errors %}<p class="text-xs text-red-600 mt-1">{{ error }}</p>{% endfor %}
|
||||
</div>
|
||||
{% endfor %}
|
||||
|
||||
<div class="flex items-center gap-3 pt-2">
|
||||
<button type="submit"
|
||||
class="inline-flex items-center px-5 py-2 bg-indigo-600 text-white text-sm font-medium rounded-md hover:bg-indigo-700">
|
||||
<i class="fas fa-save mr-2"></i> Save Profile
|
||||
</button>
|
||||
<a href="{% url 'nginxmon-dashboard' %}" class="text-sm text-gray-500 hover:text-gray-700">Cancel</a>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
{% endblock %}
|
||||
@@ -0,0 +1,103 @@
|
||||
{% extends 'base.html' %}
|
||||
|
||||
{% block content %}
|
||||
<div class="max-w-3xl mx-auto px-4 sm:px-6 lg:px-8 py-8 space-y-6">
|
||||
|
||||
<!-- Header -->
|
||||
<div class="flex items-center gap-3">
|
||||
<a href="{% url 'nginxmon-dashboard' %}" class="text-gray-400 hover:text-gray-600">
|
||||
<i class="fas fa-arrow-left"></i>
|
||||
</a>
|
||||
<div>
|
||||
<h1 class="text-xl font-bold text-gray-900 flex items-center gap-2">
|
||||
<i class="fas fa-cog text-gray-500"></i> Nginx Monitor Settings
|
||||
</h1>
|
||||
<p class="text-sm text-gray-400 mt-0.5">Cluster connection & local testing</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{% for msg in messages %}
|
||||
<div class="px-4 py-2 rounded-md text-sm border
|
||||
{% if msg.tags == 'success' %}bg-green-50 text-green-800 border-green-200
|
||||
{% elif msg.tags == 'error' %}bg-red-50 text-red-800 border-red-200
|
||||
{% else %}bg-blue-50 text-blue-800 border-blue-200{% endif %}">
|
||||
{{ msg }}
|
||||
</div>
|
||||
{% endfor %}
|
||||
|
||||
<!-- K8s pod settings -->
|
||||
<div class="bg-white shadow-sm rounded-lg border border-gray-200 overflow-hidden">
|
||||
<div class="px-6 py-4 bg-gray-50 border-b border-gray-200">
|
||||
<h2 class="text-sm font-semibold text-gray-700">Cluster Connection</h2>
|
||||
<p class="text-xs text-gray-400 mt-0.5">
|
||||
Configured once. The scheduler uses these to run
|
||||
<code class="bg-gray-100 px-1 rounded">kubectl logs</code> on a timer.
|
||||
</p>
|
||||
</div>
|
||||
<form method="post" class="p-6 space-y-4">
|
||||
{% csrf_token %}
|
||||
{% for field in form %}
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-gray-700 mb-1">{{ field.label }}</label>
|
||||
{{ field }}
|
||||
{% if field.help_text %}<p class="text-xs text-gray-400 mt-1">{{ field.help_text }}</p>{% endif %}
|
||||
{% for error in field.errors %}<p class="text-xs text-red-600 mt-1">{{ error }}</p>{% endfor %}
|
||||
</div>
|
||||
{% endfor %}
|
||||
<div class="pt-1">
|
||||
<button type="submit" name="save_settings"
|
||||
class="inline-flex items-center px-5 py-2 bg-indigo-600 text-white text-sm font-medium rounded-md hover:bg-indigo-700">
|
||||
<i class="fas fa-save mr-2"></i> Save Settings
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
<!-- Local testing — paste logs -->
|
||||
<div class="bg-white shadow-sm rounded-lg border border-gray-200 overflow-hidden">
|
||||
<div class="px-6 py-4 bg-amber-50 border-b border-amber-200 flex items-start gap-3">
|
||||
<i class="fas fa-flask text-amber-500 mt-0.5"></i>
|
||||
<div>
|
||||
<h2 class="text-sm font-semibold text-amber-800">Test Locally — Paste Log Lines</h2>
|
||||
<p class="text-xs text-amber-700 mt-0.5">
|
||||
No kubectl needed. Paste raw nginx ingress log lines and they'll be parsed and stored
|
||||
immediately, so you can preview the dashboard with real data.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<form method="post" class="p-6 space-y-4">
|
||||
{% csrf_token %}
|
||||
{% for field in paste_form %}
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-gray-700 mb-1">{{ field.label }}</label>
|
||||
<div class="relative">{{ field }}</div>
|
||||
{% for error in field.errors %}<p class="text-xs text-red-600 mt-1">{{ error }}</p>{% endfor %}
|
||||
</div>
|
||||
{% endfor %}
|
||||
<div class="flex items-center gap-3">
|
||||
<button type="submit" name="paste_logs"
|
||||
class="inline-flex items-center px-5 py-2 bg-amber-600 text-white text-sm font-medium rounded-md hover:bg-amber-700">
|
||||
<i class="fas fa-upload mr-2"></i> Ingest Logs
|
||||
</button>
|
||||
<span class="text-xs text-gray-400">Duplicates are automatically skipped.</span>
|
||||
</div>
|
||||
</form>
|
||||
|
||||
<!-- CLI alternative hint -->
|
||||
<div class="px-6 pb-5">
|
||||
<p class="text-xs text-gray-500 font-medium mb-2">Or via the management command:</p>
|
||||
<pre class="bg-gray-900 text-green-300 text-xs rounded-md p-3 overflow-x-auto"><code># From a file
|
||||
python manage.py nginxmon_ingest --file /path/to/nginx.log
|
||||
|
||||
# Pipe from kubectl directly
|
||||
kubectl logs -n ingress-nginx \
|
||||
-l app.kubernetes.io/name=ingress-nginx \
|
||||
--container controller | python manage.py nginxmon_ingest
|
||||
|
||||
# Re-run the configured source (kubectl or file)
|
||||
python manage.py nginxmon_ingest --from-settings</code></pre>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
{% endblock %}
|
||||
@@ -0,0 +1,29 @@
|
||||
from django.urls import path
|
||||
from . import views
|
||||
|
||||
urlpatterns = [
|
||||
# Main dashboard
|
||||
path('', views.DashboardView.as_view(), name='nginxmon-dashboard'),
|
||||
|
||||
# One-off settings (k8s pod config + paste-logs test)
|
||||
path('settings/', views.SettingsView.as_view(), name='nginxmon-settings'),
|
||||
|
||||
# Alert profiles
|
||||
path('profiles/new/', views.ProfileCreateView.as_view(), name='nginxmon-profile-create'),
|
||||
path('profiles/<int:pk>/edit/', views.ProfileUpdateView.as_view(), name='nginxmon-profile-edit'),
|
||||
path('profiles/<int:pk>/delete/', views.ProfileDeleteView.as_view(), name='nginxmon-profile-delete'),
|
||||
path('profiles/<int:pk>/test-telegram/', views.TestTelegramView.as_view(), name='nginxmon-test-telegram'),
|
||||
|
||||
# Manual actions
|
||||
path('actions/fetch/', views.TriggerFetchView.as_view(), name='nginxmon-fetch'),
|
||||
path('actions/detect/', views.TriggerDetectView.as_view(), name='nginxmon-detect'),
|
||||
path('alert/<int:pk>/dismiss/', views.DismissAlertView.as_view(), name='nginxmon-dismiss-alert'),
|
||||
|
||||
# HTMX partials & APIs
|
||||
path('api/live-logs/', views.LiveLogsPartialView.as_view(), name='nginxmon-live-logs'),
|
||||
path('api/alerts/', views.AlertsPartialView.as_view(), name='nginxmon-alerts-partial'),
|
||||
path('api/chart-data/', views.ChartDataView.as_view(), name='nginxmon-chart-data'),
|
||||
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'),
|
||||
]
|
||||
@@ -0,0 +1,384 @@
|
||||
import json
|
||||
import logging
|
||||
import threading
|
||||
import time
|
||||
from datetime import timedelta
|
||||
|
||||
from django.contrib import messages
|
||||
from django.db.models import Avg, Count, Q
|
||||
from django.db.models.functions import TruncDay, TruncHour, TruncMinute
|
||||
from django.http import JsonResponse, StreamingHttpResponse
|
||||
from django.shortcuts import get_object_or_404, redirect, render
|
||||
from django.urls import reverse_lazy
|
||||
from django.utils import timezone
|
||||
from django.views.generic import CreateView, DeleteView, TemplateView, UpdateView, View
|
||||
|
||||
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 .notifications import send_test_telegram
|
||||
from .tasks import start_fetch_job, stop_fetch_job, schedule_profile, unschedule_profile
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
# ── Time range config ─────────────────────────────────────────────────────────
|
||||
|
||||
_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'},
|
||||
'90d': {'delta': timedelta(days=90), 'trunc': TruncDay, 'group_n': 3, 'label': 'Last 90 days', 'fmt': '%m/%d'},
|
||||
}
|
||||
_DEFAULT_RANGE = '6h'
|
||||
|
||||
|
||||
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):
|
||||
"""Return format string for the bucket boundary dt falls into."""
|
||||
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: # TruncDay
|
||||
if group_n <= 1:
|
||||
aligned = dt.replace(hour=0, 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_date = epoch + timedelta(days=(days // group_n) * group_n)
|
||||
aligned = dt.replace(
|
||||
year=floored_date.year, month=floored_date.month,
|
||||
day=floored_date.day, hour=0, minute=0, second=0, microsecond=0,
|
||||
)
|
||||
return aligned.strftime(fmt)
|
||||
|
||||
|
||||
# ── Dashboard — live monitoring ───────────────────────────────────────────────
|
||||
|
||||
class DashboardView(TemplateView):
|
||||
template_name = 'nginxmon/dashboard.html'
|
||||
|
||||
def get_context_data(self, **kwargs):
|
||||
ctx = super().get_context_data(**kwargs)
|
||||
range_key, cfg, since = _get_range(self.request)
|
||||
|
||||
logs = NginxAccessLog.objects.filter(timestamp__gte=since)
|
||||
stats = {
|
||||
'total': logs.count(),
|
||||
'errors': logs.filter(status__gte=400).count(),
|
||||
'unique_ips': logs.values('remote_addr').distinct().count(),
|
||||
'avg_resp_ms': round(
|
||||
(logs.aggregate(a=Avg('request_time'))['a'] or 0) * 1000, 1
|
||||
),
|
||||
}
|
||||
|
||||
top_services = list(
|
||||
logs.values('service')
|
||||
.annotate(count=Count('id'))
|
||||
.order_by('-count')[:10]
|
||||
)
|
||||
status_dist = list(
|
||||
logs.values('status')
|
||||
.annotate(count=Count('id'))
|
||||
.order_by('status')
|
||||
)
|
||||
top_ips = list(
|
||||
logs.values('remote_addr', 'country', 'city')
|
||||
.annotate(count=Count('id'), errors=Count('id', filter=Q(status__gte=400)))
|
||||
.order_by('-count')[:10]
|
||||
)
|
||||
active_alerts = ThreatAlert.objects.filter(dismissed=False).count()
|
||||
profiles = NginxAlertProfile.objects.filter(enabled=True)
|
||||
|
||||
ctx.update({
|
||||
'nginx_settings': NginxSettings.get(),
|
||||
'stats': stats,
|
||||
'top_services': top_services,
|
||||
'status_dist': status_dist,
|
||||
'top_ips': top_ips,
|
||||
'active_alerts': active_alerts,
|
||||
'profiles': profiles,
|
||||
'status_dist_json': json.dumps(status_dist),
|
||||
'current_range': range_key,
|
||||
'range_label': cfg['label'],
|
||||
'all_ranges': [(k, v['label']) for k, v in _RANGES.items()],
|
||||
})
|
||||
return ctx
|
||||
|
||||
|
||||
# ── Settings (one-off k8s config + paste test logs) ──────────────────────────
|
||||
|
||||
class SettingsView(View):
|
||||
template_name = 'nginxmon/settings.html'
|
||||
|
||||
def _get_settings(self):
|
||||
return NginxSettings.get()
|
||||
|
||||
def get(self, request):
|
||||
obj = self._get_settings()
|
||||
return render(request, self.template_name, {
|
||||
'form': NginxSettingsForm(instance=obj),
|
||||
'paste_form': PasteLogsForm(),
|
||||
'nginx_settings': obj,
|
||||
})
|
||||
|
||||
def post(self, request):
|
||||
obj = self._get_settings()
|
||||
if 'save_settings' in request.POST:
|
||||
form = NginxSettingsForm(request.POST, instance=obj)
|
||||
if form.is_valid():
|
||||
saved = form.save()
|
||||
stop_fetch_job()
|
||||
if saved.enabled:
|
||||
start_fetch_job(saved.fetch_interval_seconds)
|
||||
messages.success(request, 'Settings saved.')
|
||||
return redirect('nginxmon-settings')
|
||||
return render(request, self.template_name, {
|
||||
'form': form,
|
||||
'paste_form': PasteLogsForm(),
|
||||
'nginx_settings': obj,
|
||||
})
|
||||
|
||||
if 'paste_logs' in request.POST:
|
||||
paste_form = PasteLogsForm(request.POST)
|
||||
if paste_form.is_valid():
|
||||
count = ingest_raw(paste_form.cleaned_data['log_text'])
|
||||
messages.success(request, f'Ingested {count} new log entries.')
|
||||
return redirect('nginxmon-settings')
|
||||
return render(request, self.template_name, {
|
||||
'form': NginxSettingsForm(instance=obj),
|
||||
'paste_form': paste_form,
|
||||
'nginx_settings': obj,
|
||||
})
|
||||
|
||||
return redirect('nginxmon-settings')
|
||||
|
||||
|
||||
# ── Alert profiles ────────────────────────────────────────────────────────────
|
||||
|
||||
class ProfileCreateView(CreateView):
|
||||
model = NginxAlertProfile
|
||||
form_class = NginxAlertProfileForm
|
||||
template_name = 'nginxmon/profile_form.html'
|
||||
success_url = reverse_lazy('nginxmon-dashboard')
|
||||
|
||||
def form_valid(self, form):
|
||||
response = super().form_valid(form)
|
||||
if self.object.enabled:
|
||||
schedule_profile(self.object)
|
||||
messages.success(self.request, f'Alert profile "{self.object.name}" created.')
|
||||
return response
|
||||
|
||||
|
||||
class ProfileUpdateView(UpdateView):
|
||||
model = NginxAlertProfile
|
||||
form_class = NginxAlertProfileForm
|
||||
template_name = 'nginxmon/profile_form.html'
|
||||
success_url = reverse_lazy('nginxmon-dashboard')
|
||||
|
||||
def form_valid(self, form):
|
||||
response = super().form_valid(form)
|
||||
unschedule_profile(self.object)
|
||||
if self.object.enabled:
|
||||
schedule_profile(self.object)
|
||||
messages.success(self.request, f'Alert profile "{self.object.name}" updated.')
|
||||
return response
|
||||
|
||||
|
||||
class ProfileDeleteView(DeleteView):
|
||||
model = NginxAlertProfile
|
||||
template_name = 'nginxmon/profile_confirm_delete.html'
|
||||
success_url = reverse_lazy('nginxmon-dashboard')
|
||||
|
||||
def form_valid(self, form):
|
||||
unschedule_profile(self.object)
|
||||
return super().form_valid(form)
|
||||
|
||||
|
||||
class TestTelegramView(View):
|
||||
def post(self, request, pk):
|
||||
profile = get_object_or_404(NginxAlertProfile, pk=pk)
|
||||
result = send_test_telegram(profile)
|
||||
if result['ok']:
|
||||
messages.success(request, 'Telegram test message sent!')
|
||||
else:
|
||||
messages.error(request, f'Telegram error: {result["error"]}')
|
||||
return redirect('nginxmon-dashboard')
|
||||
|
||||
|
||||
# ── HTMX partials ─────────────────────────────────────────────────────────────
|
||||
|
||||
class LiveLogsPartialView(View):
|
||||
_SORT_MAP = {
|
||||
'timestamp': 'timestamp',
|
||||
'ip': 'remote_addr',
|
||||
'status': 'status',
|
||||
'method': 'method',
|
||||
'service': 'service',
|
||||
'time': 'request_time',
|
||||
'bytes': 'body_bytes_sent',
|
||||
}
|
||||
|
||||
def get(self, request):
|
||||
qs = NginxAccessLog.objects.all()
|
||||
|
||||
ip_f = request.GET.get('ip', '').strip()
|
||||
status_f = request.GET.get('status', '').strip()
|
||||
service_f = request.GET.get('service', '').strip()
|
||||
sort_col = request.GET.get('sort', 'timestamp')
|
||||
sort_ord = request.GET.get('order', 'desc')
|
||||
|
||||
if ip_f:
|
||||
qs = qs.filter(remote_addr=ip_f)
|
||||
if status_f:
|
||||
try:
|
||||
qs = qs.filter(status=int(status_f))
|
||||
except ValueError:
|
||||
pass
|
||||
if service_f:
|
||||
qs = qs.filter(service=service_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, 'nginxmon/_live_logs.html', {'logs': qs[:60]})
|
||||
|
||||
|
||||
class AlertsPartialView(View):
|
||||
def get(self, request):
|
||||
alerts = ThreatAlert.objects.filter(dismissed=False).select_related('profile')[:20]
|
||||
return render(request, 'nginxmon/_alerts.html', {'alerts': alerts})
|
||||
|
||||
|
||||
# ── Action views ──────────────────────────────────────────────────────────────
|
||||
|
||||
class TriggerFetchView(View):
|
||||
def post(self, request):
|
||||
threading.Thread(target=fetch_and_store, daemon=True).start()
|
||||
messages.success(request, 'Log fetch triggered in the background.')
|
||||
return redirect('nginxmon-dashboard')
|
||||
|
||||
|
||||
class TriggerDetectView(View):
|
||||
def post(self, request):
|
||||
for profile in NginxAlertProfile.objects.filter(enabled=True):
|
||||
threading.Thread(target=run_detect, args=[profile.pk], daemon=True).start()
|
||||
messages.success(request, 'Threat detection triggered for all profiles.')
|
||||
return redirect('nginxmon-dashboard')
|
||||
|
||||
|
||||
class DismissAlertView(View):
|
||||
def post(self, request, pk):
|
||||
alert = get_object_or_404(ThreatAlert, pk=pk)
|
||||
alert.dismissed = True
|
||||
alert.save(update_fields=['dismissed'])
|
||||
if request.headers.get('HX-Request'):
|
||||
return JsonResponse({'ok': True})
|
||||
return redirect('nginxmon-dashboard')
|
||||
|
||||
|
||||
# ── Chart & ingest API ────────────────────────────────────────────────────────
|
||||
|
||||
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']
|
||||
|
||||
# Aggregate in DB using the timestamp index + trunc function.
|
||||
# Returns at most ~360 rows (6h/minute) — then Python merges into final buckets.
|
||||
rows = list(
|
||||
NginxAccessLog.objects
|
||||
.filter(timestamp__gte=since)
|
||||
.annotate(bucket=trunc_fn('timestamp'))
|
||||
.values('bucket')
|
||||
.annotate(total=Count('id'), errors=Count('id', filter=Q(status__gte=400)))
|
||||
.order_by('bucket')
|
||||
)
|
||||
|
||||
# Merge adjacent DB buckets into the desired bucket size.
|
||||
# Dict insertion order is preserved (Python 3.7+) so labels stay sorted.
|
||||
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, 'errors': 0}
|
||||
buckets[key]['total'] += row['total']
|
||||
buckets[key]['errors'] += row['errors']
|
||||
|
||||
return JsonResponse({
|
||||
'labels': list(buckets),
|
||||
'total': [v['total'] for v in buckets.values()],
|
||||
'errors': [v['errors'] for v in buckets.values()],
|
||||
'range': range_key,
|
||||
'label': cfg['label'],
|
||||
})
|
||||
|
||||
|
||||
class IngestLogsView(View):
|
||||
"""POST endpoint for the paste-logs form (used from SettingsView)."""
|
||||
def post(self, request):
|
||||
text = request.POST.get('log_text', '')
|
||||
count = ingest_raw(text)
|
||||
return JsonResponse({'inserted': count})
|
||||
|
||||
|
||||
# ── Ingestion toggle & SSE status stream ─────────────────────────────────────
|
||||
|
||||
class ToggleIngestionView(View):
|
||||
"""POST — flip NginxSettings.enabled and restart/stop the fetch job."""
|
||||
def post(self, request):
|
||||
settings = NginxSettings.get()
|
||||
settings.enabled = not settings.enabled
|
||||
settings.save(update_fields=['enabled'])
|
||||
stop_fetch_job()
|
||||
if settings.enabled:
|
||||
start_fetch_job(settings.fetch_interval_seconds)
|
||||
return JsonResponse({'enabled': settings.enabled})
|
||||
|
||||
|
||||
class StatusStreamView(View):
|
||||
"""SSE endpoint — pushes ingestion status JSON every 2 seconds."""
|
||||
def get(self, request):
|
||||
def event_stream():
|
||||
while True:
|
||||
try:
|
||||
settings = NginxSettings.get()
|
||||
total = NginxAccessLog.objects.count()
|
||||
last_fetch = (
|
||||
settings.last_fetch_at.isoformat()
|
||||
if settings.last_fetch_at else None
|
||||
)
|
||||
payload = json.dumps({
|
||||
'enabled': settings.enabled,
|
||||
'last_fetch_at': last_fetch,
|
||||
'total_logs': total,
|
||||
})
|
||||
yield f'data: {payload}\n\n'
|
||||
except Exception:
|
||||
pass
|
||||
time.sleep(2)
|
||||
|
||||
response = StreamingHttpResponse(event_stream(), content_type='text/event-stream')
|
||||
response['Cache-Control'] = 'no-cache'
|
||||
response['X-Accel-Buffering'] = 'no'
|
||||
return response
|
||||
Reference in New Issue
Block a user