mirror of
https://github.com/openwrt/luci.git
synced 2026-10-12 05:07:26 +04:00
luci-app-banip: several map improvements
The setView() call ran inside the home marker loop, so on a dual stack setup only the last local IP decided the initial viewport. Center on a single home IP, fit the bounds of all of them otherwise, and fall back to the blocked IPs when no home IP could be resolved. Requiring a non-empty as, city and countryCode discarded every IP whose GeoIP lookup returned those fields empty, which happens regularly. Only the coordinates are mandatory now, missing fields render as "n/a". Coordinate validation relied on truthiness, so entries at latitude or longitude 0 were dropped as well. Coordinates are now range checked. Popups are built as DOM nodes instead of concatenated HTML, so the city and AS fields returned by the GeoIP service can no longer be interpreted as markup. Add a toolbar above the map holding a single select, listing every Set with mappable entries plus its point count. Selecting a Set renders that Set only and zooms to it. No cap is applied, so the rendering does not depend on how f_report() orders or truncates set_elements. Markers sharing a coordinate are collapsed per Set instead of globally. The previous deduplication compared lat/lon only, so the Set that reached a coordinate first swallowed the points of every later Set, which would make both the per-Set counts and the filter depend on the order of ban_map.jsn. Collapsing per Set keeps every Set complete, and the number of IPs behind a marker is reported in its popup, since stacked markers are indistinguishable and only the topmost one can be opened. The marker radius is raised from 2 to 3 to keep the denser plot readable, and the map renders with the canvas backend to keep the volume cheap. Home markers are raised to the front after every redraw, so the local uplink stays visible and clickable. The fixed 97vh map height is replaced by a flex column layout, so the map fills the space left by the toolbar inside the report modal. The toolbar uses the CSS system colors Canvas, CanvasText and ButtonBorder under color-scheme: light dark, which also keeps it consistent with the natively rendered select. Co-authored-by: Renato Yamane <renatoyamane@gmail.com> Signed-off-by: Dirk Brenken <dev@brenken.org>
This commit is contained in:
1 parent
96a255dbd9
commit
859f54738e
2 files changed
+346
-69
No files matched your search
@@ -7,7 +7,7 @@ LUCI_TITLE:=LuCI support for banIP
|
||||
LUCI_DEPENDS:=+luci-base +banip
|
||||
|
||||
PKG_VERSION:=1.8.10
|
||||
PKG_RELEASE:=3
|
||||
PKG_RELEASE:=4
|
||||
PKG_LICENSE:=Apache-2.0
|
||||
PKG_MAINTAINER:=Dirk Brenken <dev@brenken.org>
|
||||
|
||||
|
||||
@@ -9,11 +9,54 @@
|
||||
<link rel="stylesheet" href="https://unpkg.com/leaflet@1.9.4/dist/leaflet.css"
|
||||
integrity="sha256-p4NxAoJBhIIN+hmNHrzRCf9tD/miZyoHS5obTRR9BMY=" crossorigin="" />
|
||||
<style>
|
||||
html {
|
||||
color-scheme: light dark;
|
||||
}
|
||||
|
||||
html,
|
||||
body {
|
||||
height: 100%;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
body {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
#toolbar {
|
||||
flex: 0 0 auto;
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
align-items: center;
|
||||
gap: 0.6em;
|
||||
padding: 0.5em 0.8em;
|
||||
box-sizing: border-box;
|
||||
font-family: sans-serif;
|
||||
font-size: 14px;
|
||||
color: CanvasText;
|
||||
background: Canvas;
|
||||
border-bottom: 1px solid ButtonBorder;
|
||||
}
|
||||
|
||||
#setSelect {
|
||||
min-width: 16em;
|
||||
max-width: 70vw;
|
||||
padding: 0.25em;
|
||||
}
|
||||
|
||||
#map {
|
||||
height: 97vh;
|
||||
flex: 1 1 auto;
|
||||
min-height: 0;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.title {
|
||||
display: block;
|
||||
text-align: center;
|
||||
font-weight: bold;
|
||||
}
|
||||
|
||||
.mono {
|
||||
font-size: small;
|
||||
font-family: monospace;
|
||||
@@ -24,6 +67,11 @@
|
||||
</head>
|
||||
|
||||
<body>
|
||||
<div id="toolbar">
|
||||
<label for="setSelect">Set:</label>
|
||||
<select id="setSelect"></select>
|
||||
</div>
|
||||
|
||||
<div id="map"></div>
|
||||
|
||||
<script src="https://unpkg.com/leaflet@1.9.4/dist/leaflet.js"
|
||||
@@ -31,84 +79,313 @@
|
||||
<script>
|
||||
'use strict';
|
||||
|
||||
/* intialize map */
|
||||
let map = L.map('map', {
|
||||
/* maximum display length of the free form GeoIP fields */
|
||||
const MAX_IP_LEN = 38;
|
||||
const MAX_AS_LEN = 38;
|
||||
const MAX_CITY_LEN = 33;
|
||||
|
||||
/* zoom level applied when the view is centered on a single point */
|
||||
const POINT_ZOOM = 6;
|
||||
|
||||
/* mapData key holding the geo data of the local uplink(s) */
|
||||
const HOME_KEY = 'homeIP';
|
||||
|
||||
const HOME_STYLE = { color: '#378242', opacity: 1.0, fillColor: '#378242', fillOpacity: 0.5, radius: 6 };
|
||||
const BLOCKED_STYLE = { color: '#C22121', opacity: 1.0, fillColor: '#C22121', fillOpacity: 0.5, radius: 3 };
|
||||
|
||||
/* initialize map, canvas rendering keeps the per Set marker volume cheap */
|
||||
const map = L.map('map', {
|
||||
zoom: 3,
|
||||
minZoom: 2,
|
||||
maxZoom: 18,
|
||||
center: [50, 10]
|
||||
}).setView([50, 10]);
|
||||
L.tileLayer('https://{s}.basemaps.cartocdn.com/rastertiles/voyager/{z}/{x}/{y}.png', {
|
||||
attribution: '© <a href="https://www.openstreetmap.org/copyright">OpenStreetMap</a> contributors'
|
||||
center: [50, 10],
|
||||
worldCopyJump: true,
|
||||
preferCanvas: true
|
||||
});
|
||||
|
||||
L.tileLayer('https://{s}.basemaps.cartocdn.com/rastertiles/voyager/{z}/{x}/{y}.png', {
|
||||
attribution: '© <a href="https://www.openstreetmap.org/copyright">OpenStreetMap</a> contributors'
|
||||
}).addTo(map);
|
||||
|
||||
/* get mapData */
|
||||
/* feature groups, so redrawing is a single clearLayers() and getBounds() is available */
|
||||
const homeLayer = L.featureGroup().addTo(map);
|
||||
const blockedLayer = L.featureGroup().addTo(map);
|
||||
|
||||
const setSelect = document.getElementById('setSelect');
|
||||
|
||||
/* accept a coordinate only if it is a finite number in range */
|
||||
function toLatLng(geo) {
|
||||
const lat = Number(geo.lat);
|
||||
const lon = Number(geo.lon);
|
||||
|
||||
if (geo.lat === null || geo.lat === undefined || geo.lat === '' ||
|
||||
geo.lon === null || geo.lon === undefined || geo.lon === '')
|
||||
return null;
|
||||
|
||||
if (!Number.isFinite(lat) || !Number.isFinite(lon))
|
||||
return null;
|
||||
|
||||
if (lat < -90 || lat > 90 || lon < -180 || lon > 180)
|
||||
return null;
|
||||
|
||||
return [lat, lon];
|
||||
}
|
||||
|
||||
/* GeoIP lookups regularly return empty city/as fields, keep the entry anyway */
|
||||
function fieldText(value, maxLength) {
|
||||
const text = String(value === null || value === undefined ? '' : value).trim();
|
||||
|
||||
if (!text)
|
||||
return 'n/a';
|
||||
|
||||
return text.length > maxLength ? text.slice(0, maxLength - 1) + '\u2026' : text;
|
||||
}
|
||||
|
||||
/*
|
||||
* Popups are built as DOM nodes, not as HTML strings, so the third party
|
||||
* GeoIP fields cannot be interpreted as markup. Passing a function to
|
||||
* bindPopup() defers this until the popup is actually opened.
|
||||
*/
|
||||
function buildPopup(title, entry) {
|
||||
const content = document.createElement('div');
|
||||
const heading = document.createElement('span');
|
||||
const body = document.createElement('p');
|
||||
const geo = entry.geo;
|
||||
const latlng = entry.latlng;
|
||||
const more = entry.count > 1 ? ' (+' + (entry.count - 1) + ' more)' : '';
|
||||
const lines = [
|
||||
'City: ' + fieldText(geo.city, MAX_CITY_LEN) + ' (' + fieldText(geo.countryCode, 8) + ')',
|
||||
'Latitude : ' + latlng[0],
|
||||
'Longitude: ' + latlng[1],
|
||||
'IP: ' + fieldText(geo.query, MAX_IP_LEN) + more,
|
||||
'AS: ' + fieldText(geo.as, MAX_AS_LEN)
|
||||
];
|
||||
|
||||
heading.className = 'title';
|
||||
heading.textContent = title;
|
||||
body.className = 'mono';
|
||||
|
||||
lines.forEach(function (line, index) {
|
||||
if (index)
|
||||
body.appendChild(document.createElement('br'));
|
||||
|
||||
body.appendChild(document.createTextNode(line));
|
||||
});
|
||||
|
||||
content.appendChild(heading);
|
||||
content.appendChild(body);
|
||||
|
||||
return content;
|
||||
}
|
||||
|
||||
function addMarker(layer, title, entry, style) {
|
||||
L.circleMarker(entry.latlng, style)
|
||||
.bindPopup(function () {
|
||||
return buildPopup(title, entry);
|
||||
})
|
||||
.addTo(layer);
|
||||
}
|
||||
|
||||
/*
|
||||
* GeoIP resolves to city level, so several IPs of one Set regularly share a
|
||||
* coordinate. Stacked markers are indistinguishable and only the topmost one
|
||||
* can be opened, so collapse them into one marker and count the IPs behind it.
|
||||
* Collapsing happens per Set, otherwise the Set that reaches a coordinate first
|
||||
* would swallow the points of every later Set and make the counts depend on the
|
||||
* order of ban_map.jsn.
|
||||
*/
|
||||
function addEntry(list, index, geo, latlng) {
|
||||
const key = latlng[0] + ',' + latlng[1];
|
||||
const known = index.get(key);
|
||||
|
||||
if (known) {
|
||||
known.count++;
|
||||
return;
|
||||
}
|
||||
|
||||
const entry = { geo: geo, latlng: latlng, count: 1 };
|
||||
|
||||
index.set(key, entry);
|
||||
list.push(entry);
|
||||
}
|
||||
|
||||
/*
|
||||
* mapData is an array of single key objects, the key being the banIP Set name:
|
||||
*
|
||||
* [ {}, { "homeIP": {...} }, { "country_ir.v4": {...} }, ... ]
|
||||
*/
|
||||
function collectEntries(parsedData) {
|
||||
const result = { home: [], sets: {} };
|
||||
const homeIndex = new Map();
|
||||
const setIndex = new Map();
|
||||
|
||||
parsedData.forEach(function (item) {
|
||||
if (!item || typeof item !== 'object')
|
||||
return;
|
||||
|
||||
Object.keys(item).forEach(function (setName) {
|
||||
const geo = item[setName];
|
||||
|
||||
if (!geo || typeof geo !== 'object')
|
||||
return;
|
||||
|
||||
const latlng = toLatLng(geo);
|
||||
|
||||
if (!latlng)
|
||||
return;
|
||||
|
||||
if (setName === HOME_KEY) {
|
||||
addEntry(result.home, homeIndex, geo, latlng);
|
||||
return;
|
||||
}
|
||||
|
||||
if (!result.sets[setName]) {
|
||||
result.sets[setName] = [];
|
||||
setIndex.set(setName, new Map());
|
||||
}
|
||||
|
||||
addEntry(result.sets[setName], setIndex.get(setName), geo, latlng);
|
||||
});
|
||||
});
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
function countIPs(entries) {
|
||||
let total = 0;
|
||||
|
||||
entries.forEach(function (entry) {
|
||||
total += entry.count;
|
||||
});
|
||||
|
||||
return total;
|
||||
}
|
||||
|
||||
function setNames(sets) {
|
||||
return Object.keys(sets).sort();
|
||||
}
|
||||
|
||||
/*
|
||||
* An empty selection renders all Sets, otherwise the selected one only.
|
||||
* Every mapped location of a rendered Set is drawn, no cap is applied, so
|
||||
* the result does not depend on how f_report() truncates set_elements.
|
||||
*/
|
||||
function renderBlocked(sets, selected) {
|
||||
const names = selected ? [selected] : setNames(sets);
|
||||
|
||||
blockedLayer.clearLayers();
|
||||
|
||||
names.forEach(function (setName) {
|
||||
const entries = sets[setName] || [];
|
||||
|
||||
entries.forEach(function (entry) {
|
||||
addMarker(blockedLayer, 'blocked IP by ' + setName, entry, BLOCKED_STYLE);
|
||||
});
|
||||
});
|
||||
|
||||
/* the canvas renderer draws in insertion order, keep home on top */
|
||||
homeLayer.eachLayer(function (marker) {
|
||||
marker.bringToFront();
|
||||
});
|
||||
}
|
||||
|
||||
function fitLayer(layer) {
|
||||
const layers = layer.getLayers();
|
||||
|
||||
if (!layers.length)
|
||||
return false;
|
||||
|
||||
if (layers.length === 1)
|
||||
map.setView(layers[0].getLatLng(), POINT_ZOOM);
|
||||
else
|
||||
map.fitBounds(layer.getBounds(), { maxZoom: POINT_ZOOM, padding: [30, 30] });
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
/* center on the local uplink(s), fall back to the blocked IPs */
|
||||
function resetView() {
|
||||
if (!fitLayer(homeLayer))
|
||||
fitLayer(blockedLayer);
|
||||
}
|
||||
|
||||
function addOption(value, label) {
|
||||
const option = document.createElement('option');
|
||||
|
||||
option.value = value;
|
||||
option.textContent = label;
|
||||
setSelect.appendChild(option);
|
||||
}
|
||||
|
||||
function setNotice(label) {
|
||||
setSelect.textContent = '';
|
||||
addOption('', label);
|
||||
setSelect.disabled = true;
|
||||
}
|
||||
|
||||
function plural(count, noun) {
|
||||
return count + ' ' + noun + (count === 1 ? '' : 's');
|
||||
}
|
||||
|
||||
function optionLabel(name, entries) {
|
||||
return name + ' (' + plural(countIPs(entries), 'IP') +
|
||||
' at ' + plural(entries.length, 'location') + ')';
|
||||
}
|
||||
|
||||
function fillSelect(sets) {
|
||||
const names = setNames(sets);
|
||||
let all = [];
|
||||
|
||||
names.forEach(function (setName) {
|
||||
all = all.concat(sets[setName]);
|
||||
});
|
||||
|
||||
if (!names.length) {
|
||||
setNotice('No mappable Set entries');
|
||||
return;
|
||||
}
|
||||
|
||||
setSelect.textContent = '';
|
||||
setSelect.disabled = false;
|
||||
addOption('', optionLabel('All Sets', all));
|
||||
|
||||
names.forEach(function (setName) {
|
||||
addOption(setName, optionLabel(setName, sets[setName]));
|
||||
});
|
||||
}
|
||||
|
||||
/* load the data handed over by the banIP set report page */
|
||||
const mapData = sessionStorage.getItem('mapData');
|
||||
let parsedData = null;
|
||||
|
||||
if (mapData) {
|
||||
let uniqueCoordinates = [];
|
||||
let homeCoordinates = [];
|
||||
const parsedData = JSON.parse(mapData);
|
||||
parsedData.forEach(function (item, index) {
|
||||
if (Object.keys(item).length !== 0) {
|
||||
let keys = Object.keys(item);
|
||||
keys.forEach(function (key) {
|
||||
if (item[key] && item[key].lat && item[key].lon && item[key].query && item[key].as && item[key].city && item[key].countryCode) {
|
||||
let coordObj = { lat: item[key].lat, lon: item[key].lon };
|
||||
if (key === "homeIP" && !homeCoordinates.some(existingCoord => existingCoord.lat === coordObj.lat && existingCoord.lon === coordObj.lon)) {
|
||||
coordObj = { key: key, lat: item[key].lat, lon: item[key].lon, query: item[key].query, as: item[key].as, city: item[key].city, cc: item[key].countryCode };
|
||||
homeCoordinates.push(coordObj);
|
||||
}
|
||||
if (key !== "homeIP" && !uniqueCoordinates.some(existingCoord => existingCoord.lat === coordObj.lat && existingCoord.lon === coordObj.lon)) {
|
||||
coordObj = { key: key, lat: item[key].lat, lon: item[key].lon, query: item[key].query, as: item[key].as, city: item[key].city, cc: item[key].countryCode };
|
||||
uniqueCoordinates.push(coordObj);
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
try {
|
||||
parsedData = JSON.parse(mapData);
|
||||
} catch (error) {
|
||||
console.error('banIP: unable to parse mapData,', error);
|
||||
}
|
||||
}
|
||||
|
||||
if (!mapData) {
|
||||
setNotice('No banIP map data available');
|
||||
} else if (!Array.isArray(parsedData)) {
|
||||
setNotice('Unable to load banIP map data');
|
||||
} else {
|
||||
const entries = collectEntries(parsedData);
|
||||
|
||||
entries.home.forEach(function (entry) {
|
||||
addMarker(homeLayer, 'local IP', entry, HOME_STYLE);
|
||||
});
|
||||
|
||||
/* render markers for local IPs, reset focus and zoom level */
|
||||
homeCoordinates.forEach(function (coordObj) {
|
||||
let latHome = coordObj.lat;
|
||||
let lonHome = coordObj.lon;
|
||||
let ipHome = coordObj.query.slice(0, 38);
|
||||
let asHome = coordObj.as.slice(0, 38);
|
||||
let cityHome = coordObj.city.slice(0, 33);
|
||||
let ccHome = coordObj.cc;
|
||||
fillSelect(entries.sets);
|
||||
renderBlocked(entries.sets, '');
|
||||
resetView();
|
||||
|
||||
map.setView([latHome, lonHome], 6);
|
||||
setSelect.addEventListener('change', function () {
|
||||
renderBlocked(entries.sets, setSelect.value);
|
||||
|
||||
let circle = L.circleMarker([latHome, lonHome]).addTo(map);
|
||||
circle.setStyle({ color: '#378242', opacity: 1.0, fillColor: '#378242', fillOpacity: 0.5, radius: 6 });
|
||||
circle.bindPopup("<b><center>local IP</center></b>" +
|
||||
"<p class=\"mono\">" +
|
||||
"City: " + cityHome + " (" + ccHome + ")" +
|
||||
"<br />Latitude : " + latHome +
|
||||
"<br />Longitude: " + lonHome +
|
||||
"<br />IP: " + ipHome +
|
||||
"<br />AS: " + asHome +
|
||||
"</p>");
|
||||
});
|
||||
|
||||
/* render markers for blocked IPs */
|
||||
uniqueCoordinates.forEach(function (coordObj) {
|
||||
let key = coordObj.key.slice(0, -3);
|
||||
let lat = coordObj.lat;
|
||||
let lon = coordObj.lon;
|
||||
let ip = coordObj.query.slice(0, 38);
|
||||
let as = coordObj.as.slice(0, 38);
|
||||
let city = coordObj.city.slice(0, 33);
|
||||
let cc = coordObj.cc;
|
||||
let circle = L.circleMarker([lat, lon]).addTo(map);
|
||||
circle.setStyle({ color: '#C22121', opacity: 1.0, fillColor: '#C22121', fillOpacity: 0.5, radius: 2 });
|
||||
circle.bindPopup("<b><center>blocked IP by " + key + "</center></b>" +
|
||||
"<p class=\"mono\">" +
|
||||
"City: " + city + " (" + cc + ")" +
|
||||
"<br />Latitude : " + lat +
|
||||
"<br />Longitude: " + lon +
|
||||
"<br />IP: " + ip +
|
||||
"<br />AS: " + as +
|
||||
"</p>");
|
||||
if (!setSelect.value || !fitLayer(blockedLayer))
|
||||
resetView();
|
||||
});
|
||||
}
|
||||
</script>
|
||||
|
||||
Reference in new issue
Block a user