mirror of
https://github.com/wahyd4/links.git
synced 2026-08-09 05:06:16 +10:00
Add geo
This commit is contained in:
@@ -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 %}
|
||||
|
||||
@@ -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
@@ -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):
|
||||
|
||||
Reference in New Issue
Block a user