diff --git a/src/templates/static/css/dashboard.css b/src/templates/static/css/dashboard.css
index 8a32b80..c7cd3a5 100644
--- a/src/templates/static/css/dashboard.css
+++ b/src/templates/static/css/dashboard.css
@@ -626,6 +626,11 @@ tbody {
.marker-unknown:hover {
box-shadow: 0 0 15px rgba(139, 148, 158, 1), inset 0 0 6px rgba(139, 148, 158, 0.7);
}
+/* Custom pie-chart cluster icons */
+.ip-cluster-icon {
+ background: none !important;
+ border: none !important;
+}
.leaflet-bottom.leaflet-right {
display: none !important;
}
diff --git a/src/templates/static/js/map.js b/src/templates/static/js/map.js
index 6dfaf02..5181295 100644
--- a/src/templates/static/js/map.js
+++ b/src/templates/static/js/map.js
@@ -3,8 +3,9 @@
let attackerMap = null;
let allIps = [];
-let mapMarkers = [];
-let markerLayers = {};
+let mapMarkers = []; // all marker objects, each tagged with .options.category
+let clusterGroup = null; // single shared MarkerClusterGroup
+let hiddenCategories = new Set();
const categoryColors = {
attacker: '#f85149',
@@ -14,13 +15,351 @@ const categoryColors = {
unknown: '#8b949e'
};
-async function initializeAttackerMap() {
+// Build a conic-gradient pie icon showing the category mix inside a cluster
+function createClusterIcon(cluster) {
+ const children = cluster.getAllChildMarkers();
+ const counts = {};
+ children.forEach(m => {
+ const cat = m.options.category || 'unknown';
+ counts[cat] = (counts[cat] || 0) + 1;
+ });
+
+ const total = children.length;
+ const sorted = Object.entries(counts).sort((a, b) => b[1] - a[1]);
+ let gradientStops = [];
+ let cumulative = 0;
+ sorted.forEach(([cat, count]) => {
+ const start = (cumulative / total) * 360;
+ cumulative += count;
+ const end = (cumulative / total) * 360;
+ const color = categoryColors[cat] || '#8b949e';
+ gradientStops.push(`${color} ${start.toFixed(1)}deg ${end.toFixed(1)}deg`);
+ });
+
+ const size = Math.max(32, Math.min(50, 32 + Math.log2(total) * 5));
+ const inner = size - 10;
+ const offset = 5; // (size - inner) / 2
+
+ return L.divIcon({
+ html: `
` +
+ `
` +
+ `
${total}
` +
+ `
`,
+ className: 'ip-cluster-icon',
+ iconSize: L.point(size, size)
+ });
+}
+
+// City coordinates database (major cities worldwide)
+const cityCoordinates = {
+ // United States
+ 'New York': [40.7128, -74.0060], 'Los Angeles': [34.0522, -118.2437],
+ 'San Francisco': [37.7749, -122.4194], 'Chicago': [41.8781, -87.6298],
+ 'Seattle': [47.6062, -122.3321], 'Miami': [25.7617, -80.1918],
+ 'Boston': [42.3601, -71.0589], 'Atlanta': [33.7490, -84.3880],
+ 'Dallas': [32.7767, -96.7970], 'Houston': [29.7604, -95.3698],
+ 'Denver': [39.7392, -104.9903], 'Phoenix': [33.4484, -112.0740],
+ // Europe
+ 'London': [51.5074, -0.1278], 'Paris': [48.8566, 2.3522],
+ 'Berlin': [52.5200, 13.4050], 'Amsterdam': [52.3676, 4.9041],
+ 'Moscow': [55.7558, 37.6173], 'Rome': [41.9028, 12.4964],
+ 'Madrid': [40.4168, -3.7038], 'Barcelona': [41.3874, 2.1686],
+ 'Milan': [45.4642, 9.1900], 'Vienna': [48.2082, 16.3738],
+ 'Stockholm': [59.3293, 18.0686], 'Oslo': [59.9139, 10.7522],
+ 'Copenhagen': [55.6761, 12.5683], 'Warsaw': [52.2297, 21.0122],
+ 'Prague': [50.0755, 14.4378], 'Budapest': [47.4979, 19.0402],
+ 'Athens': [37.9838, 23.7275], 'Lisbon': [38.7223, -9.1393],
+ 'Brussels': [50.8503, 4.3517], 'Dublin': [53.3498, -6.2603],
+ 'Zurich': [47.3769, 8.5417], 'Geneva': [46.2044, 6.1432],
+ 'Helsinki': [60.1699, 24.9384], 'Bucharest': [44.4268, 26.1025],
+ 'Saint Petersburg': [59.9343, 30.3351], 'Manchester': [53.4808, -2.2426],
+ 'Roubaix': [50.6942, 3.1746], 'Frankfurt': [50.1109, 8.6821],
+ 'Munich': [48.1351, 11.5820], 'Hamburg': [53.5511, 9.9937],
+ // Asia
+ 'Tokyo': [35.6762, 139.6503], 'Beijing': [39.9042, 116.4074],
+ 'Shanghai': [31.2304, 121.4737], 'Singapore': [1.3521, 103.8198],
+ 'Mumbai': [19.0760, 72.8777], 'Delhi': [28.7041, 77.1025],
+ 'Bangalore': [12.9716, 77.5946], 'Seoul': [37.5665, 126.9780],
+ 'Hong Kong': [22.3193, 114.1694], 'Bangkok': [13.7563, 100.5018],
+ 'Jakarta': [6.2088, 106.8456], 'Manila': [14.5995, 120.9842],
+ 'Hanoi': [21.0285, 105.8542], 'Ho Chi Minh City': [10.8231, 106.6297],
+ 'Taipei': [25.0330, 121.5654], 'Kuala Lumpur': [3.1390, 101.6869],
+ 'Karachi': [24.8607, 67.0011], 'Islamabad': [33.6844, 73.0479],
+ 'Dhaka': [23.8103, 90.4125], 'Colombo': [6.9271, 79.8612],
+ // South America
+ 'São Paulo': [-23.5505, -46.6333], 'Rio de Janeiro': [-22.9068, -43.1729],
+ 'Buenos Aires': [-34.6037, -58.3816], 'Bogotá': [4.7110, -74.0721],
+ 'Lima': [-12.0464, -77.0428], 'Santiago': [-33.4489, -70.6693],
+ // Middle East & Africa
+ 'Cairo': [30.0444, 31.2357], 'Dubai': [25.2048, 55.2708],
+ 'Istanbul': [41.0082, 28.9784], 'Tel Aviv': [32.0853, 34.7818],
+ 'Johannesburg': [26.2041, 28.0473], 'Lagos': [6.5244, 3.3792],
+ 'Nairobi': [-1.2921, 36.8219], 'Cape Town': [-33.9249, 18.4241],
+ // Australia & Oceania
+ 'Sydney': [-33.8688, 151.2093], 'Melbourne': [-37.8136, 144.9631],
+ 'Brisbane': [-27.4698, 153.0251], 'Perth': [-31.9505, 115.8605],
+ 'Auckland': [-36.8485, 174.7633],
+ // Additional cities
+ 'Unknown': null
+};
+
+// Country center coordinates (fallback when city not found)
+const countryCoordinates = {
+ 'US': [37.1, -95.7], 'GB': [55.4, -3.4], 'CN': [35.9, 104.1], 'RU': [61.5, 105.3],
+ 'JP': [36.2, 138.3], 'DE': [51.2, 10.5], 'FR': [46.6, 2.2], 'IN': [20.6, 78.96],
+ 'BR': [-14.2, -51.9], 'CA': [56.1, -106.3], 'AU': [-25.3, 133.8], 'MX': [23.6, -102.6],
+ 'ZA': [-30.6, 22.9], 'KR': [35.9, 127.8], 'IT': [41.9, 12.6], 'ES': [40.5, -3.7],
+ 'NL': [52.1, 5.3], 'SE': [60.1, 18.6], 'CH': [46.8, 8.2], 'PL': [51.9, 19.1],
+ 'SG': [1.4, 103.8], 'HK': [22.4, 114.1], 'TW': [23.7, 120.96], 'TH': [15.9, 100.9],
+ 'VN': [14.1, 108.8], 'ID': [-0.8, 113.2], 'PH': [12.9, 121.8], 'MY': [4.2, 101.7],
+ 'PK': [30.4, 69.2], 'BD': [23.7, 90.4], 'NG': [9.1, 8.7], 'EG': [26.8, 30.8],
+ 'TR': [38.9, 35.2], 'IR': [32.4, 53.7], 'AE': [23.4, 53.8], 'KZ': [48.0, 66.9],
+ 'UA': [48.4, 31.2], 'BG': [42.7, 25.5], 'RO': [45.9, 24.97], 'CZ': [49.8, 15.5],
+ 'HU': [47.2, 19.5], 'AT': [47.5, 14.6], 'BE': [50.5, 4.5], 'DK': [56.3, 9.5],
+ 'FI': [61.9, 25.8], 'NO': [60.5, 8.5], 'GR': [39.1, 21.8], 'PT': [39.4, -8.2],
+ 'AR': [-38.4161, -63.6167], 'CO': [4.5709, -74.2973], 'CL': [-35.6751, -71.5430],
+ 'PE': [-9.1900, -75.0152], 'VE': [6.4238, -66.5897], 'LS': [40.0, -100.0]
+};
+
+// Helper function to get coordinates for an IP
+function getIPCoordinates(ip) {
+ if (ip.latitude != null && ip.longitude != null) {
+ return [ip.latitude, ip.longitude];
+ }
+ if (ip.city && cityCoordinates[ip.city]) {
+ return cityCoordinates[ip.city];
+ }
+ if (ip.country_code && countryCoordinates[ip.country_code]) {
+ return countryCoordinates[ip.country_code];
+ }
+ return null;
+}
+
+// Fetch IPs from the API, handling pagination for "all"
+async function fetchIpsForMap(limit) {
const DASHBOARD_PATH = window.__DASHBOARD_PATH__ || '';
+ const headers = { 'Cache-Control': 'no-cache', 'Pragma': 'no-cache' };
+
+ if (limit === 'all') {
+ // Fetch in pages of 1000 until we have everything
+ let collected = [];
+ let page = 1;
+ const pageSize = 1000;
+ while (true) {
+ const response = await fetch(
+ `${DASHBOARD_PATH}/api/all-ips?page=${page}&page_size=${pageSize}&sort_by=total_requests&sort_order=desc`,
+ { cache: 'no-store', headers }
+ );
+ if (!response.ok) throw new Error('Failed to fetch IPs');
+ const data = await response.json();
+ collected = collected.concat(data.ips || []);
+ if (page >= data.pagination.total_pages) break;
+ page++;
+ }
+ return collected;
+ }
+
+ const pageSize = parseInt(limit, 10);
+ const response = await fetch(
+ `${DASHBOARD_PATH}/api/all-ips?page=1&page_size=${pageSize}&sort_by=total_requests&sort_order=desc`,
+ { cache: 'no-store', headers }
+ );
+ if (!response.ok) throw new Error('Failed to fetch IPs');
+ const data = await response.json();
+ return data.ips || [];
+}
+
+// Build markers from an IP list and add them to the map
+function buildMapMarkers(ips) {
+ // Clear existing cluster group
+ if (clusterGroup) {
+ attackerMap.removeLayer(clusterGroup);
+ clusterGroup.clearLayers();
+ }
+ mapMarkers = [];
+
+ // Single cluster group with custom pie-chart icons
+ clusterGroup = L.markerClusterGroup({
+ maxClusterRadius: 20,
+ spiderfyOnMaxZoom: true,
+ showCoverageOnHover: false,
+ zoomToBoundsOnClick: true,
+ disableClusteringAtZoom: 10,
+ iconCreateFunction: createClusterIcon
+ });
+
+ // Track used coordinates to add small offsets for overlapping markers
+ const usedCoordinates = {};
+ function getUniqueCoordinates(baseCoords) {
+ const key = `${baseCoords[0].toFixed(4)},${baseCoords[1].toFixed(4)}`;
+ if (!usedCoordinates[key]) {
+ usedCoordinates[key] = 0;
+ }
+ usedCoordinates[key]++;
+
+ if (usedCoordinates[key] === 1) {
+ return baseCoords;
+ }
+
+ const angle = (usedCoordinates[key] * 137.5) % 360;
+ const distance = 0.05 * Math.sqrt(usedCoordinates[key]);
+ const latOffset = distance * Math.cos(angle * Math.PI / 180);
+ const lngOffset = distance * Math.sin(angle * Math.PI / 180);
+
+ return [
+ baseCoords[0] + latOffset,
+ baseCoords[1] + lngOffset
+ ];
+ }
+
+ const DASHBOARD_PATH = window.__DASHBOARD_PATH__ || '';
+
+ ips.forEach(ip => {
+ if (!ip.country_code || !ip.category) return;
+
+ const baseCoords = getIPCoordinates(ip);
+ if (!baseCoords) return;
+
+ const coords = getUniqueCoordinates(baseCoords);
+ const category = ip.category.toLowerCase();
+ if (!categoryColors[category]) return;
+
+ const requestsForScale = Math.min(ip.total_requests, 10000);
+ const sizeRatio = Math.pow(requestsForScale / 10000, 0.5);
+ const markerSize = Math.max(10, Math.min(30, 10 + (sizeRatio * 20)));
+
+ const markerElement = document.createElement('div');
+ markerElement.className = `ip-marker marker-${category}`;
+ markerElement.style.width = markerSize + 'px';
+ markerElement.style.height = markerSize + 'px';
+ markerElement.style.fontSize = (markerSize * 0.5) + 'px';
+ markerElement.textContent = '\u25CF';
+
+ const marker = L.marker(coords, {
+ icon: L.divIcon({
+ html: markerElement.outerHTML,
+ iconSize: [markerSize, markerSize],
+ className: `ip-custom-marker category-${category}`
+ }),
+ category: category
+ });
+
+ const categoryColor = categoryColors[category] || '#8b949e';
+ const categoryLabels = {
+ attacker: 'Attacker',
+ bad_crawler: 'Bad Crawler',
+ good_crawler: 'Good Crawler',
+ regular_user: 'Regular User',
+ unknown: 'Unknown'
+ };
+
+ marker.bindPopup('', {
+ maxWidth: 550,
+ className: 'ip-detail-popup'
+ });
+
+ marker.on('click', async function(e) {
+ const loadingPopup = `
+
+
+ ${ip.ip}
+
+ ${categoryLabels[category]}
+
+
+
+
+ `;
+
+ marker.setPopupContent(loadingPopup);
+ marker.openPopup();
+
+ try {
+ const response = await fetch(`${DASHBOARD_PATH}/api/ip-stats/${ip.ip}`);
+ if (!response.ok) throw new Error('Failed to fetch IP stats');
+
+ const stats = await response.json();
+
+ let popupContent = `
+
+
+ ${ip.ip}
+
+ ${categoryLabels[category]}
+
+
+
+ ${ip.city ? (ip.country_code ? `${ip.city}, ${ip.country_code}` : ip.city) : (ip.country_code || 'Unknown')}
+
+
+
Requests: ${ip.total_requests}
+
First Seen: ${formatTimestamp(ip.first_seen)}
+
Last Seen: ${formatTimestamp(ip.last_seen)}
+
+ `;
+
+ if (stats.category_scores && Object.keys(stats.category_scores).length > 0) {
+ const chartHtml = generateMapPanelRadarChart(stats.category_scores);
+ popupContent += `
+
+ ${chartHtml}
+
+ `;
+ }
+
+ popupContent += '
';
+ marker.setPopupContent(popupContent);
+ } catch (err) {
+ console.error('Error fetching IP stats:', err);
+ const errorPopup = `
+
+
+ ${ip.ip}
+
+ ${categoryLabels[category]}
+
+
+
+ ${ip.city ? (ip.country_code ? `${ip.city}, ${ip.country_code}` : ip.city) : (ip.country_code || 'Unknown')}
+
+
+
Requests: ${ip.total_requests}
+
First Seen: ${formatTimestamp(ip.first_seen)}
+
Last Seen: ${formatTimestamp(ip.last_seen)}
+
+
+ Failed to load chart: ${err.message}
+
+
+ `;
+ marker.setPopupContent(errorPopup);
+ }
+ });
+
+ mapMarkers.push(marker);
+ // Only add to cluster if category is not hidden
+ if (!hiddenCategories.has(category)) {
+ clusterGroup.addLayer(marker);
+ }
+ });
+
+ attackerMap.addLayer(clusterGroup);
+
+ // Fit map to visible markers
+ const visibleMarkers = mapMarkers.filter(m => !hiddenCategories.has(m.options.category));
+ if (visibleMarkers.length > 0) {
+ const bounds = L.featureGroup(visibleMarkers).getBounds();
+ attackerMap.fitBounds(bounds, { padding: [50, 50] });
+ }
+}
+
+async function initializeAttackerMap() {
const mapContainer = document.getElementById('attacker-map');
if (!mapContainer || attackerMap) return;
try {
- // Initialize map
attackerMap = L.map('attacker-map', {
center: [20, 0],
zoom: 2,
@@ -33,314 +372,20 @@ async function initializeAttackerMap() {
]
});
- // Fetch all IPs (not just attackers)
- const response = await fetch(DASHBOARD_PATH + '/api/all-ips?page=1&page_size=100&sort_by=total_requests&sort_order=desc', {
- cache: 'no-store',
- headers: {
- 'Cache-Control': 'no-cache',
- 'Pragma': 'no-cache'
- }
- });
+ // Get the selected limit from the dropdown (default 100)
+ const limitSelect = document.getElementById('map-ip-limit');
+ const limit = limitSelect ? limitSelect.value : '100';
- if (!response.ok) throw new Error('Failed to fetch IPs');
-
- const data = await response.json();
- allIps = data.ips || [];
+ allIps = await fetchIpsForMap(limit);
if (allIps.length === 0) {
mapContainer.innerHTML = 'No IP location data available
';
return;
}
- // Get max request count for scaling
- const maxRequests = Math.max(...allIps.map(ip => ip.total_requests || 0));
-
- // City coordinates database (major cities worldwide)
- const cityCoordinates = {
- // United States
- 'New York': [40.7128, -74.0060], 'Los Angeles': [34.0522, -118.2437],
- 'San Francisco': [37.7749, -122.4194], 'Chicago': [41.8781, -87.6298],
- 'Seattle': [47.6062, -122.3321], 'Miami': [25.7617, -80.1918],
- 'Boston': [42.3601, -71.0589], 'Atlanta': [33.7490, -84.3880],
- 'Dallas': [32.7767, -96.7970], 'Houston': [29.7604, -95.3698],
- 'Denver': [39.7392, -104.9903], 'Phoenix': [33.4484, -112.0740],
- // Europe
- 'London': [51.5074, -0.1278], 'Paris': [48.8566, 2.3522],
- 'Berlin': [52.5200, 13.4050], 'Amsterdam': [52.3676, 4.9041],
- 'Moscow': [55.7558, 37.6173], 'Rome': [41.9028, 12.4964],
- 'Madrid': [40.4168, -3.7038], 'Barcelona': [41.3874, 2.1686],
- 'Milan': [45.4642, 9.1900], 'Vienna': [48.2082, 16.3738],
- 'Stockholm': [59.3293, 18.0686], 'Oslo': [59.9139, 10.7522],
- 'Copenhagen': [55.6761, 12.5683], 'Warsaw': [52.2297, 21.0122],
- 'Prague': [50.0755, 14.4378], 'Budapest': [47.4979, 19.0402],
- 'Athens': [37.9838, 23.7275], 'Lisbon': [38.7223, -9.1393],
- 'Brussels': [50.8503, 4.3517], 'Dublin': [53.3498, -6.2603],
- 'Zurich': [47.3769, 8.5417], 'Geneva': [46.2044, 6.1432],
- 'Helsinki': [60.1699, 24.9384], 'Bucharest': [44.4268, 26.1025],
- 'Saint Petersburg': [59.9343, 30.3351], 'Manchester': [53.4808, -2.2426],
- 'Roubaix': [50.6942, 3.1746], 'Frankfurt': [50.1109, 8.6821],
- 'Munich': [48.1351, 11.5820], 'Hamburg': [53.5511, 9.9937],
- // Asia
- 'Tokyo': [35.6762, 139.6503], 'Beijing': [39.9042, 116.4074],
- 'Shanghai': [31.2304, 121.4737], 'Singapore': [1.3521, 103.8198],
- 'Mumbai': [19.0760, 72.8777], 'Delhi': [28.7041, 77.1025],
- 'Bangalore': [12.9716, 77.5946], 'Seoul': [37.5665, 126.9780],
- 'Hong Kong': [22.3193, 114.1694], 'Bangkok': [13.7563, 100.5018],
- 'Jakarta': [6.2088, 106.8456], 'Manila': [14.5995, 120.9842],
- 'Hanoi': [21.0285, 105.8542], 'Ho Chi Minh City': [10.8231, 106.6297],
- 'Taipei': [25.0330, 121.5654], 'Kuala Lumpur': [3.1390, 101.6869],
- 'Karachi': [24.8607, 67.0011], 'Islamabad': [33.6844, 73.0479],
- 'Dhaka': [23.8103, 90.4125], 'Colombo': [6.9271, 79.8612],
- // South America
- 'S\u00e3o Paulo': [-23.5505, -46.6333], 'Rio de Janeiro': [-22.9068, -43.1729],
- 'Buenos Aires': [-34.6037, -58.3816], 'Bogot\u00e1': [4.7110, -74.0721],
- 'Lima': [-12.0464, -77.0428], 'Santiago': [-33.4489, -70.6693],
- // Middle East & Africa
- 'Cairo': [30.0444, 31.2357], 'Dubai': [25.2048, 55.2708],
- 'Istanbul': [41.0082, 28.9784], 'Tel Aviv': [32.0853, 34.7818],
- 'Johannesburg': [26.2041, 28.0473], 'Lagos': [6.5244, 3.3792],
- 'Nairobi': [-1.2921, 36.8219], 'Cape Town': [-33.9249, 18.4241],
- // Australia & Oceania
- 'Sydney': [-33.8688, 151.2093], 'Melbourne': [-37.8136, 144.9631],
- 'Brisbane': [-27.4698, 153.0251], 'Perth': [-31.9505, 115.8605],
- 'Auckland': [-36.8485, 174.7633],
- // Additional cities
- 'Unknown': null
- };
-
- // Country center coordinates (fallback when city not found)
- const countryCoordinates = {
- 'US': [37.1, -95.7], 'GB': [55.4, -3.4], 'CN': [35.9, 104.1], 'RU': [61.5, 105.3],
- 'JP': [36.2, 138.3], 'DE': [51.2, 10.5], 'FR': [46.6, 2.2], 'IN': [20.6, 78.96],
- 'BR': [-14.2, -51.9], 'CA': [56.1, -106.3], 'AU': [-25.3, 133.8], 'MX': [23.6, -102.6],
- 'ZA': [-30.6, 22.9], 'KR': [35.9, 127.8], 'IT': [41.9, 12.6], 'ES': [40.5, -3.7],
- 'NL': [52.1, 5.3], 'SE': [60.1, 18.6], 'CH': [46.8, 8.2], 'PL': [51.9, 19.1],
- 'SG': [1.4, 103.8], 'HK': [22.4, 114.1], 'TW': [23.7, 120.96], 'TH': [15.9, 100.9],
- 'VN': [14.1, 108.8], 'ID': [-0.8, 113.2], 'PH': [12.9, 121.8], 'MY': [4.2, 101.7],
- 'PK': [30.4, 69.2], 'BD': [23.7, 90.4], 'NG': [9.1, 8.7], 'EG': [26.8, 30.8],
- 'TR': [38.9, 35.2], 'IR': [32.4, 53.7], 'AE': [23.4, 53.8], 'KZ': [48.0, 66.9],
- 'UA': [48.4, 31.2], 'BG': [42.7, 25.5], 'RO': [45.9, 24.97], 'CZ': [49.8, 15.5],
- 'HU': [47.2, 19.5], 'AT': [47.5, 14.6], 'BE': [50.5, 4.5], 'DK': [56.3, 9.5],
- 'FI': [61.9, 25.8], 'NO': [60.5, 8.5], 'GR': [39.1, 21.8], 'PT': [39.4, -8.2],
- 'AR': [-38.4161, -63.6167], 'CO': [4.5709, -74.2973], 'CL': [-35.6751, -71.5430],
- 'PE': [-9.1900, -75.0152], 'VE': [6.4238, -66.5897], 'LS': [40.0, -100.0]
- };
-
- // Helper function to get coordinates for an IP
- function getIPCoordinates(ip) {
- // Use actual latitude and longitude if available
- if (ip.latitude != null && ip.longitude != null) {
- return [ip.latitude, ip.longitude];
- }
- // Fall back to city lookup
- if (ip.city && cityCoordinates[ip.city]) {
- return cityCoordinates[ip.city];
- }
- // Fall back to country
- if (ip.country_code && countryCoordinates[ip.country_code]) {
- return countryCoordinates[ip.country_code];
- }
- return null;
- }
-
- // Track used coordinates to add small offsets for overlapping markers
- const usedCoordinates = {};
- function getUniqueCoordinates(baseCoords) {
- const key = `${baseCoords[0].toFixed(4)},${baseCoords[1].toFixed(4)}`;
- if (!usedCoordinates[key]) {
- usedCoordinates[key] = 0;
- }
- usedCoordinates[key]++;
-
- // If this is the first marker at this location, use exact coordinates
- if (usedCoordinates[key] === 1) {
- return baseCoords;
- }
-
- // Add small random offset for subsequent markers
- // Offset increases with each marker to create a spread pattern
- const angle = (usedCoordinates[key] * 137.5) % 360; // Golden angle for even distribution
- const distance = 0.05 * Math.sqrt(usedCoordinates[key]); // Increase distance with more markers
- const latOffset = distance * Math.cos(angle * Math.PI / 180);
- const lngOffset = distance * Math.sin(angle * Math.PI / 180);
-
- return [
- baseCoords[0] + latOffset,
- baseCoords[1] + lngOffset
- ];
- }
-
- // Create layer groups for each category
- markerLayers = {
- attacker: L.featureGroup(),
- bad_crawler: L.featureGroup(),
- good_crawler: L.featureGroup(),
- regular_user: L.featureGroup(),
- unknown: L.featureGroup()
- };
-
- // Add markers for each IP
- allIps.slice(0, 100).forEach(ip => {
- if (!ip.country_code || !ip.category) return;
-
- // Get coordinates (city first, then country)
- const baseCoords = getIPCoordinates(ip);
- if (!baseCoords) return;
-
- // Get unique coordinates with offset to prevent overlap
- const coords = getUniqueCoordinates(baseCoords);
-
- const category = ip.category.toLowerCase();
- if (!markerLayers[category]) return;
-
- // Calculate marker size based on request count with more dramatic scaling
- // Scale up to 10,000 requests, then cap it
- const requestsForScale = Math.min(ip.total_requests, 10000);
- const sizeRatio = Math.pow(requestsForScale / 10000, 0.5); // Square root for better visual scaling
- const markerSize = Math.max(10, Math.min(30, 10 + (sizeRatio * 20)));
-
- // Create custom marker element with category-specific class
- const markerElement = document.createElement('div');
- markerElement.className = `ip-marker marker-${category}`;
- markerElement.style.width = markerSize + 'px';
- markerElement.style.height = markerSize + 'px';
- markerElement.style.fontSize = (markerSize * 0.5) + 'px';
- markerElement.textContent = '\u25CF';
-
- const marker = L.marker(coords, {
- icon: L.divIcon({
- html: markerElement.outerHTML,
- iconSize: [markerSize, markerSize],
- className: `ip-custom-marker category-${category}`
- })
- });
-
- // Create popup with category badge and chart
- const categoryColor = categoryColors[category] || '#8b949e';
- const categoryLabels = {
- attacker: 'Attacker',
- bad_crawler: 'Bad Crawler',
- good_crawler: 'Good Crawler',
- regular_user: 'Regular User',
- unknown: 'Unknown'
- };
-
- // Bind popup once when marker is created
- marker.bindPopup('', {
- maxWidth: 550,
- className: 'ip-detail-popup'
- });
-
- // Add click handler to fetch data and show popup
- marker.on('click', async function(e) {
- // Show loading popup first
- const loadingPopup = `
-
-
- ${ip.ip}
-
- ${categoryLabels[category]}
-
-
-
-
- `;
-
- marker.setPopupContent(loadingPopup);
- marker.openPopup();
-
- try {
- console.log('Fetching IP stats for:', ip.ip);
- const response = await fetch(`${DASHBOARD_PATH}/api/ip-stats/${ip.ip}`);
- if (!response.ok) throw new Error('Failed to fetch IP stats');
-
- const stats = await response.json();
- console.log('Received stats:', stats);
-
- // Build complete popup content with chart
- let popupContent = `
-
-
- ${ip.ip}
-
- ${categoryLabels[category]}
-
-
-
- ${ip.city ? (ip.country_code ? `${ip.city}, ${ip.country_code}` : ip.city) : (ip.country_code || 'Unknown')}
-
-
-
Requests: ${ip.total_requests}
-
First Seen: ${formatTimestamp(ip.first_seen)}
-
Last Seen: ${formatTimestamp(ip.last_seen)}
-
- `;
-
- // Add chart if category scores exist
- if (stats.category_scores && Object.keys(stats.category_scores).length > 0) {
- console.log('Category scores found:', stats.category_scores);
- const chartHtml = generateMapPanelRadarChart(stats.category_scores);
- console.log('Generated chart HTML length:', chartHtml.length);
- popupContent += `
-
- ${chartHtml}
-
- `;
- }
-
- popupContent += '
';
-
- // Update popup content
- console.log('Updating popup content');
- marker.setPopupContent(popupContent);
- } catch (err) {
- console.error('Error fetching IP stats:', err);
- const errorPopup = `
-
-
- ${ip.ip}
-
- ${categoryLabels[category]}
-
-
-
- ${ip.city ? (ip.country_code ? `${ip.city}, ${ip.country_code}` : ip.city) : (ip.country_code || 'Unknown')}
-
-
-
Requests: ${ip.total_requests}
-
First Seen: ${formatTimestamp(ip.first_seen)}
-
Last Seen: ${formatTimestamp(ip.last_seen)}
-
-
- Failed to load chart: ${err.message}
-
-
- `;
- marker.setPopupContent(errorPopup);
- }
- });
-
- markerLayers[category].addLayer(marker);
- });
-
- // Add all marker layers to map initially
- Object.values(markerLayers).forEach(layer => attackerMap.addLayer(layer));
-
- // Fit map to all markers
- const allMarkers = Object.values(markerLayers).reduce((acc, layer) => {
- acc.push(...layer.getLayers());
- return acc;
- }, []);
-
- if (allMarkers.length > 0) {
- const bounds = L.featureGroup(allMarkers).getBounds();
- attackerMap.fitBounds(bounds, { padding: [50, 50] });
- }
+ buildMapMarkers(allIps);
// Force Leaflet to recalculate container size after the tab becomes visible.
- // Without this, tiles may not render correctly when the container was hidden.
setTimeout(() => {
if (attackerMap) attackerMap.invalidateSize();
}, 300);
@@ -351,30 +396,44 @@ async function initializeAttackerMap() {
}
}
-// Update map filters based on checkbox selection
-function updateMapFilters() {
+// Reload map markers when the user changes the IP limit selector
+async function reloadMapWithLimit(limit) {
if (!attackerMap) return;
- const filters = {};
+ // Show loading state
+ const mapContainer = document.getElementById('attacker-map');
+ const overlay = document.createElement('div');
+ overlay.id = 'map-loading-overlay';
+ overlay.style.cssText = 'position:absolute;top:0;left:0;right:0;bottom:0;background:rgba(13,17,23,0.7);display:flex;align-items:center;justify-content:center;z-index:1000;color:#8b949e;font-size:14px;';
+ overlay.textContent = 'Loading IPs...';
+ mapContainer.style.position = 'relative';
+ mapContainer.appendChild(overlay);
+
+ try {
+ allIps = await fetchIpsForMap(limit);
+ buildMapMarkers(allIps);
+ } catch (err) {
+ console.error('Error reloading map:', err);
+ } finally {
+ const existing = document.getElementById('map-loading-overlay');
+ if (existing) existing.remove();
+ }
+}
+
+// Update map filters based on checkbox selection
+function updateMapFilters() {
+ if (!attackerMap || !clusterGroup) return;
+
+ hiddenCategories.clear();
document.querySelectorAll('.map-filter').forEach(cb => {
const category = cb.getAttribute('data-category');
- if (category) filters[category] = cb.checked;
+ if (category && !cb.checked) hiddenCategories.add(category);
});
- // Update marker and circle layers visibility
- Object.entries(filters).forEach(([category, show]) => {
- if (markerLayers[category]) {
- if (show) {
- if (!attackerMap.hasLayer(markerLayers[category])) {
- attackerMap.addLayer(markerLayers[category]);
- }
- } else {
- if (attackerMap.hasLayer(markerLayers[category])) {
- attackerMap.removeLayer(markerLayers[category]);
- }
- }
- }
- });
+ // Rebuild cluster group with only visible markers
+ clusterGroup.clearLayers();
+ const visible = mapMarkers.filter(m => !hiddenCategories.has(m.options.category));
+ clusterGroup.addLayers(visible);
}
// Generate radar chart SVG for map panel popups