This commit is contained in:
2026-04-01 18:44:29 +11:00
parent ccee1f408e
commit cc4e66b945
3 changed files with 205 additions and 1 deletions
+167
View File
@@ -2,6 +2,8 @@
{% block content %}
<script src="https://cdn.jsdelivr.net/npm/echarts@5.6.0/dist/echarts.min.js"></script>
<script src="https://cdn.jsdelivr.net/npm/d3@7/dist/d3.min.js"></script>
<script src="https://cdn.jsdelivr.net/npm/topojson-client@3/dist/topojson-client.min.js"></script>
<div class="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-6 space-y-6">
@@ -111,6 +113,15 @@
</div>
</div>
<!-- Geo map -->
<div class="bg-white rounded-lg shadow-sm border border-gray-100 p-4">
<div class="flex items-center justify-between mb-3 flex-wrap gap-2">
<div class="text-sm font-semibold text-gray-700">Traffic by Region — {{ range_label }}</div>
<div id="geo-legend" class="flex items-center gap-3 text-xs text-gray-400 flex-wrap"></div>
</div>
<div id="geoMap" class="w-full" style="height:340px; position:relative; overflow:hidden;"></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">
@@ -413,4 +424,160 @@ function _applyToggleUI(enabled) {
es.onerror = function() { es.close(); setTimeout(startStatusStream, 5000); };
})();
</script>
<script>
// ── Geo map ───────────────────────────────────────────────────────────────────
(function() {
// ISO alpha-2 → ISO numeric (needed to join with world-atlas features)
const _a2n = {
AF:4,AL:8,DZ:12,AS:16,AD:20,AO:24,AG:28,AR:32,AM:51,AW:533,AU:36,AT:40,
AZ:31,BS:44,BH:48,BD:50,BB:52,BY:112,BE:56,BZ:84,BJ:204,BT:64,BO:68,
BA:70,BW:72,BR:76,BN:96,BG:100,BF:854,BI:108,KH:116,CM:120,CA:124,CV:132,
CF:140,TD:148,CL:152,CN:156,CO:170,KM:174,CG:178,CD:180,CR:188,CI:384,
HR:191,CU:192,CY:196,CZ:203,DK:208,DJ:262,DM:212,DO:214,EC:218,EG:818,
SV:222,GQ:226,ER:232,EE:233,ET:231,FJ:242,FI:246,FR:250,GA:266,GM:270,
GE:268,DE:276,GH:288,GR:300,GL:304,GD:308,GT:320,GN:324,GW:624,GY:328,
HT:332,HN:340,HK:344,HU:348,IS:352,IN:356,ID:360,IR:364,IQ:368,IE:372,
IL:376,IT:380,JM:388,JP:392,JO:400,KZ:398,KE:404,KI:296,KP:408,KR:410,
KW:414,KG:417,LA:418,LV:428,LB:422,LS:426,LR:430,LY:434,LI:438,LT:440,
LU:442,MO:446,MK:807,MG:450,MW:454,MY:458,MV:462,ML:466,MT:470,MH:584,
MR:478,MU:480,MX:484,FM:583,MD:498,MC:492,MN:496,ME:499,MA:504,MZ:508,
MM:104,NA:516,NR:520,NP:524,NL:528,NC:540,NZ:554,NI:558,NE:562,NG:566,
NO:578,OM:512,PK:586,PW:585,PS:275,PA:591,PG:598,PY:600,PE:604,PH:608,
PL:616,PT:620,PR:630,QA:634,RO:642,RU:643,RW:646,KN:659,LC:662,VC:670,
WS:882,SM:674,ST:678,SA:682,SN:686,RS:688,SC:690,SL:694,SG:702,SK:703,
SI:705,SB:90,SO:706,ZA:710,ES:724,LK:144,SD:736,SR:740,SZ:748,SE:752,
CH:756,SY:760,TW:158,TJ:762,TZ:834,TH:764,TL:626,TG:768,TO:776,TT:780,
TN:788,TR:792,TM:795,TV:798,UG:800,UA:804,AE:784,GB:826,US:840,UY:858,
UZ:860,VU:548,VE:862,VN:704,YE:887,ZM:894,ZW:716
};
const container = document.getElementById('geoMap');
if (!container) return;
const h = 340;
let w = container.clientWidth || 800;
const svg = d3.select('#geoMap').append('svg')
.attr('width', w).attr('height', h)
.style('background', '#f8fafc').style('border-radius', '4px');
const tooltip = d3.select('body').append('div')
.style('position', 'fixed').style('display', 'none').style('pointer-events', 'none')
.style('z-index', '9999').style('max-width', '200px')
.attr('class', 'bg-gray-900 text-white text-xs rounded px-2 py-1.5 shadow-lg');
const g = svg.append('g');
function makeProjection(width) {
return d3.geoNaturalEarth1()
.scale(width / 6.3)
.translate([width / 2, h / 2]);
}
let projection = makeProjection(w);
let pathGen = d3.geoPath(projection);
svg.call(d3.zoom().scaleExtent([1, 8])
.on('zoom', e => g.attr('transform', e.transform)));
const colorScale = d3.scaleSequentialLog(d3.interpolateBlues);
let cachedWorld = null, cachedGeo = null;
function render(world, geoData) {
cachedWorld = world; cachedGeo = geoData;
const features = topojson.feature(world, world.objects.countries).features;
const byNum = {};
geoData.countries.forEach(c => {
const n = _a2n[c.country_code];
if (n) byNum[n] = c;
});
const maxTotal = Math.max(1, ...geoData.countries.map(c => c.total));
colorScale.domain([1, maxTotal]);
// Countries
g.selectAll('.country').data(features).join('path')
.attr('class', 'country')
.attr('d', pathGen)
.attr('fill', d => { const cd = byNum[+d.id]; return cd ? colorScale(cd.total) : '#e5e7eb'; })
.attr('stroke', '#fff').attr('stroke-width', 0.4)
.on('mouseover', function(e, d) {
const cd = byNum[+d.id];
if (!cd) return;
d3.select(this).attr('stroke', '#6366f1').attr('stroke-width', 1.2);
tooltip.style('display', 'block')
.html(`<div class="font-semibold">${cd.country}</div>
<div>${cd.total.toLocaleString()} requests</div>
<div class="text-red-300">${cd.errors.toLocaleString()} errors</div>`);
})
.on('mousemove', e => {
tooltip.style('left', Math.min(e.clientX + 12, window.innerWidth - 180) + 'px')
.style('top', (e.clientY - 40) + 'px');
})
.on('mouseout', function() {
d3.select(this).attr('stroke', '#fff').attr('stroke-width', 0.4);
tooltip.style('display', 'none');
});
// Graticule
g.selectAll('.graticule').data([d3.geoGraticule()()]).join('path')
.attr('class', 'graticule').attr('d', pathGen)
.attr('fill', 'none').attr('stroke', '#e2e8f0').attr('stroke-width', 0.3);
// Bubbles
const maxBubble = Math.max(1, ...geoData.bubbles.map(b => b.total));
const rScale = d3.scaleSqrt().domain([1, maxBubble]).range([2, 18]);
g.selectAll('.bubble').data(geoData.bubbles).join('circle')
.attr('class', 'bubble')
.attr('cx', d => { const p = projection([d.lon, d.lat]); return p ? p[0] : -999; })
.attr('cy', d => { const p = projection([d.lon, d.lat]); return p ? p[1] : -999; })
.attr('r', d => rScale(d.total))
.attr('fill', d => d.errors > 0 ? 'rgba(239,68,68,0.45)' : 'rgba(99,102,241,0.45)')
.attr('stroke', d => d.errors > 0 ? '#ef4444' : '#6366f1')
.attr('stroke-width', 0.6)
.on('mouseover', function(e, d) {
d3.select(this).attr('stroke-width', 1.5);
tooltip.style('display', 'block')
.html(`<div class="font-semibold">${d.label}</div>
<div>${d.total.toLocaleString()} requests</div>
<div class="text-red-300">${d.errors.toLocaleString()} errors</div>`);
})
.on('mousemove', e => {
tooltip.style('left', Math.min(e.clientX + 12, window.innerWidth - 180) + 'px')
.style('top', (e.clientY - 40) + 'px');
})
.on('mouseout', function() {
d3.select(this).attr('stroke-width', 0.6);
tooltip.style('display', 'none');
});
// Legend
const legendEl = document.getElementById('geo-legend');
if (legendEl) {
legendEl.innerHTML =
`<span class="flex items-center gap-1"><span class="w-3 h-3 rounded-sm inline-block" style="background:#dbeafe;border:1px solid #93c5fd"></span>Low</span>` +
`<span class="flex items-center gap-1"><span class="w-3 h-3 rounded-sm inline-block" style="background:#1d4ed8"></span>High</span>` +
`<span class="ml-1 flex items-center gap-1"><span class="w-3 h-3 rounded-full inline-block" style="background:rgba(99,102,241,0.45);border:1px solid #6366f1"></span>Requests</span>` +
`<span class="flex items-center gap-1"><span class="w-3 h-3 rounded-full inline-block" style="background:rgba(239,68,68,0.45);border:1px solid #ef4444"></span>w/ Errors</span>`;
}
}
// Load world + geo data
Promise.all([
d3.json('https://cdn.jsdelivr.net/npm/world-atlas@2/countries-110m.json'),
fetch('{% url "nginxmon-geo-stats" %}?range={{ current_range }}').then(r => r.json()),
]).then(([world, geoData]) => render(world, geoData))
.catch(err => console.warn('Geo map load failed:', err));
// Resize
window.addEventListener('resize', () => {
w = container.clientWidth;
svg.attr('width', w);
projection = makeProjection(w);
pathGen = d3.geoPath(projection);
if (cachedWorld && cachedGeo) render(cachedWorld, cachedGeo);
});
})();
</script>
{% endblock %}
+1
View File
@@ -26,4 +26,5 @@ urlpatterns = [
path('api/ingest/', views.IngestLogsView.as_view(), name='nginxmon-ingest'),
path('api/toggle-ingestion/', views.ToggleIngestionView.as_view(), name='nginxmon-toggle'),
path('api/status-stream/', views.StatusStreamView.as_view(), name='nginxmon-status-stream'),
path('api/geo-stats/', views.GeoStatsView.as_view(), name='nginxmon-geo-stats'),
]
+37 -1
View File
@@ -16,7 +16,7 @@ from django.views.generic import CreateView, DeleteView, TemplateView, UpdateVie
from .detector import detect_threats as run_detect
from .fetcher import fetch_and_store, ingest_raw
from .forms import NginxSettingsForm, NginxAlertProfileForm, PasteLogsForm
from .models import NginxAccessLog, NginxAlertProfile, NginxSettings, ThreatAlert
from .models import IPGeoCache, NginxAccessLog, NginxAlertProfile, NginxSettings, ThreatAlert
from .notifications import send_test_telegram
from .tasks import start_fetch_job, stop_fetch_job, schedule_profile, unschedule_profile
@@ -334,6 +334,42 @@ class ChartDataView(View):
})
class GeoStatsView(View):
"""Returns country-level aggregates + IP bubble data for the geo map."""
def get(self, request):
range_key, cfg, since = _get_range(request)
countries = list(
NginxAccessLog.objects
.filter(timestamp__gte=since).exclude(country_code='')
.values('country_code', 'country')
.annotate(total=Count('id'), errors=Count('id', filter=Q(status__gte=400)))
.order_by('-total')
)
ip_agg = list(
NginxAccessLog.objects.filter(timestamp__gte=since)
.values('remote_addr')
.annotate(total=Count('id'), errors=Count('id', filter=Q(status__gte=400)))
.order_by('-total')[:300]
)
ip_set = [r['remote_addr'] for r in ip_agg]
geo_cache = {
c.ip: (c.lat, c.lon, c.country, c.city)
for c in IPGeoCache.objects.filter(ip__in=ip_set, lat__isnull=False, is_private=False)
}
bubbles = []
for row in ip_agg:
c = geo_cache.get(row['remote_addr'])
if c and c[0] is not None and c[1] is not None:
bubbles.append({
'lat': round(float(c[0]), 2),
'lon': round(float(c[1]), 2),
'total': row['total'],
'errors': row['errors'],
'label': c[3] or c[2] or row['remote_addr'],
})
return JsonResponse({'countries': countries, 'bubbles': bubbles, 'range': range_key})
class IngestLogsView(View):
"""POST endpoint for the paste-logs form (used from SettingsView)."""
def post(self, request):