perf: static world map overlay + grid clustering + GeoIP status
Major performance improvements for map visualization: - Replace tile-based map with inline SVG world map (zero network requests) - Grid-based marker clustering: merge nearby markers into count bubbles - Adaptive cell size by zoom level, max 300 edge lines - Remove all tile caching code (Cache API, CachedTileLayer, etc.) - Add /simple/geoip-status/ API endpoint - Debug panel shows GeoIP source and MMDB hint - min_freq slider on edge control auto-refreshes Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
@@ -462,10 +462,9 @@
|
||||
<label style="font-size:0.8rem;">显示IP通联:</label>
|
||||
<input type="checkbox" id="saShowEdges" onchange="toggleEdges()">
|
||||
<label style="font-size:0.8rem;">最低频次:</label>
|
||||
<input type="range" id="saEdgeFreq" min="1" max="50" value="10"
|
||||
oninput="document.getElementById('saEdgeFreqVal').textContent=this.value">
|
||||
<span id="saEdgeFreqVal" style="font-size:0.8rem;font-weight:700;">10</span>
|
||||
<button class="btn" style="font-size:0.75rem;padding:0.2rem 0.5rem;" onclick="loadEdges()">加载</button>
|
||||
<input type="range" id="saEdgeFreq" min="1" max="50" value="1"
|
||||
oninput="document.getElementById('saEdgeFreqVal').textContent=this.value;renderEdgesOnMap();">
|
||||
<span id="saEdgeFreqVal" style="font-size:0.8rem;font-weight:700;">1</span>
|
||||
<span id="saEdgeCount" style="color:#999;font-size:0.78rem;"></span>
|
||||
</div>
|
||||
|
||||
@@ -1167,272 +1166,190 @@ function renderClusterCards(clusters) {
|
||||
});
|
||||
}
|
||||
|
||||
/* ── Step 4: Map & Visualization ──────────────────────── */
|
||||
/* ── Step 4: Map (static background + grid clustering) ── */
|
||||
|
||||
// Tile providers in priority order
|
||||
const TILE_PROVIDERS = [
|
||||
{
|
||||
name: 'ArcGIS',
|
||||
url: 'https://server.arcgisonline.com/ArcGIS/rest/services/World_Street_Map/MapServer/tile/{z}/{y}/{x}',
|
||||
attr: 'Tiles © Esri — Source: Esri, DeLorme, NAVTEQ'
|
||||
},
|
||||
{
|
||||
name: 'ArcGIS Gray',
|
||||
url: 'https://server.arcgisonline.com/ArcGIS/rest/services/Canvas/World_Light_Gray_Base/MapServer/tile/{z}/{y}/{x}',
|
||||
attr: 'Tiles © Esri — Esri, DeLorme, NAVTEQ'
|
||||
},
|
||||
{
|
||||
name: 'OSM',
|
||||
url: 'https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png',
|
||||
attr: '© OpenStreetMap contributors'
|
||||
},
|
||||
];
|
||||
|
||||
// Offline fallback tile (plain light gray canvas data URI)
|
||||
const OFFLINE_TILE = 'data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mNkYPj/HwADBwIAMCbHYQAAAABJRU5ErkJggg==';
|
||||
|
||||
// ── Offline tile cache using Cache API ──────────────────
|
||||
const TILE_CACHE_NAME = 'tianxuan-tiles-v1';
|
||||
|
||||
async function cacheTile(url) {
|
||||
if (!('caches' in window)) return;
|
||||
try {
|
||||
const cache = await caches.open(TILE_CACHE_NAME);
|
||||
const cached = await cache.match(url);
|
||||
if (!cached) {
|
||||
const resp = await fetch(url, {mode: 'no-cors'});
|
||||
if (resp.ok || resp.type === 'opaque') {
|
||||
await cache.put(url, resp.clone());
|
||||
}
|
||||
}
|
||||
} catch(e) { /* cache quietly */ }
|
||||
}
|
||||
|
||||
async function getCachedTile(url) {
|
||||
if (!('caches' in window)) return null;
|
||||
try {
|
||||
const cache = await caches.open(TILE_CACHE_NAME);
|
||||
const cached = await cache.match(url);
|
||||
if (cached) {
|
||||
const blob = await cached.blob();
|
||||
if (blob.size > 100) {
|
||||
return URL.createObjectURL(blob);
|
||||
}
|
||||
}
|
||||
} catch(e) { /* ignore */ }
|
||||
return null;
|
||||
}
|
||||
|
||||
// Custom tile layer with offline caching
|
||||
const CachedTileLayer = L.TileLayer.extend({
|
||||
createTile: function(coords, done) {
|
||||
const tile = L.DomUtil.create('img', 'leaflet-tile');
|
||||
const url = this.getTileUrl(coords);
|
||||
|
||||
// Try cache first, then network
|
||||
getCachedTile(url).then(cachedUrl => {
|
||||
if (cachedUrl) {
|
||||
tile.src = cachedUrl;
|
||||
tile._cached = true;
|
||||
if (done) done(null, tile);
|
||||
}
|
||||
});
|
||||
|
||||
// Also try loading from network (triggers cache in background)
|
||||
const img = new Image();
|
||||
img.onload = () => {
|
||||
if (!tile._cached) {
|
||||
tile.src = url;
|
||||
if (done) done(null, tile);
|
||||
}
|
||||
// Cache for offline use
|
||||
cacheTile(url);
|
||||
};
|
||||
img.onerror = () => {
|
||||
if (!tile._cached) {
|
||||
tile.src = OFFLINE_TILE;
|
||||
if (done) done(null, tile);
|
||||
}
|
||||
};
|
||||
img.src = url;
|
||||
|
||||
return tile;
|
||||
}
|
||||
});
|
||||
const WORLD_MAP_SVG = 'data:image/svg+xml,' + encodeURIComponent(
|
||||
`<svg xmlns="http://www.w3.org/2000/svg" viewBox="-180 -90 360 180" width="2048" height="1024">
|
||||
<rect x="-180" y="-90" width="360" height="180" fill="#f0f2f5"/>
|
||||
<!-- Simplified continent outlines as paths -->
|
||||
<g fill="#e2e6ea" stroke="#c0c5cc" stroke-width="0.3">
|
||||
<!-- North America -->
|
||||
<path d="M-168,-20 L-130,-20 L-120,-10 L-100,-10 L-90,0 L-80,0 L-70,15 L-80,30 L-90,30 L-100,25 L-120,30 L-130,25 L-140,15 L-150,10 L-168,5 Z"/>
|
||||
<!-- South America -->
|
||||
<path d="M-80,10 L-70,10 L-60,0 L-50,-5 L-40,0 L-35,10 L-40,20 L-50,30 L-55,35 L-60,30 L-70,25 L-80,20 Z"/>
|
||||
<!-- Europe -->
|
||||
<path d="M-10,35 L0,30 L10,35 L20,40 L30,45 L35,50 L40,55 L30,60 L20,65 L10,70 L0,72 L-10,68 L-10,55 L-5,45 L-10,40 Z"/>
|
||||
<!-- Africa -->
|
||||
<path d="M-5,32 L5,30 L15,25 L25,20 L35,10 L45,5 L50,15 L45,25 L35,30 L30,35 L25,35 L20,40 L10,42 L0,40 L-5,38 Z"/>
|
||||
<!-- Asia -->
|
||||
<path d="M30,35 L50,30 L70,25 L90,20 L110,15 L130,10 L140,20 L145,30 L140,40 L130,50 L120,60 L110,65 L100,70 L80,72 L60,75 L50,73 L40,70 L30,68 L20,60 L25,50 L30,45 Z"/>
|
||||
<!-- Australia -->
|
||||
<path d="M115,20 L125,15 L140,15 L150,20 L155,30 L145,40 L135,42 L125,38 L118,30 L115,25 Z"/>
|
||||
<!-- East Asia islands -->
|
||||
<path d="M130,28 L135,25 L140,28 L142,32 L138,36 L133,35 L130,32 Z"/>
|
||||
<!-- Southeast Asia islands -->
|
||||
<path d="M100,20 L105,15 L115,12 L120,15 L118,20 L110,22 L105,25 L100,23 Z"/>
|
||||
<!-- Greenland -->
|
||||
<path d="M-50,15 L-40,5 L-30,5 L-25,15 L-30,25 L-40,28 L-50,25 Z"/>
|
||||
<!-- Antarctica hint -->
|
||||
<path d="M-170,55 L-120,52 L-60,55 L0,58 L60,55 L120,52 L170,55 L170,65 L120,68 L60,70 L0,72 L-60,70 L-120,68 L-170,65 Z" fill="#e8eaed"/>
|
||||
</g>
|
||||
<!-- Grid lines -->
|
||||
<g stroke="#d5d9de" stroke-width="0.15" stroke-dasharray="2,4">
|
||||
<line x1="-180" y1="-60" x2="180" y2="-60"/><line x1="-180" y1="-30" x2="180" y2="-30"/>
|
||||
<line x1="-180" y1="0" x2="180" y2="0"/><line x1="-180" y1="30" x2="180" y2="30"/>
|
||||
<line x1="-180" y1="60" x2="180" y2="60"/><line x1="-90" y1="-90" x2="-90" y2="90"/>
|
||||
<line x1="0" y1="-90" x2="0" y2="90"/><line x1="90" y1="-90" x2="90" y2="90"/>
|
||||
</g>
|
||||
</svg>`);
|
||||
|
||||
function initMap() {
|
||||
const container = document.getElementById('saMap');
|
||||
if (!container) return;
|
||||
if (SA.map) {
|
||||
container.style.height = '550px';
|
||||
setTimeout(() => {
|
||||
SA.map.invalidateSize();
|
||||
renderMapData();
|
||||
}, 200);
|
||||
setTimeout(() => { SA.map.invalidateSize(); renderMapData(); }, 200);
|
||||
return;
|
||||
}
|
||||
setTimeout(() => {
|
||||
container.style.height = '550px';
|
||||
SA.map = L.map('saMap', {
|
||||
center: [0, 0],
|
||||
zoom: 2,
|
||||
worldCopyJump: true,
|
||||
preferCanvas: true,
|
||||
center: [20, 0], zoom: 2, minZoom: 2, maxZoom: 12,
|
||||
worldCopyJump: true, preferCanvas: true,
|
||||
attributionControl: false, zoomControl: true,
|
||||
});
|
||||
tryTileProvider(0);
|
||||
|
||||
// Static world map image — no tile requests
|
||||
L.imageOverlay(WORLD_MAP_SVG, [[-90, -180], [90, 180]], {opacity: 1}).addTo(SA.map);
|
||||
|
||||
SA.map.setMaxBounds([[-90, -180], [90, 180]]);
|
||||
SA.map.on('zoomend moveend', () => renderMapData());
|
||||
setTimeout(() => SA.map.invalidateSize(), 300);
|
||||
renderMapData();
|
||||
}, 250);
|
||||
}
|
||||
|
||||
function tryTileProvider(idx) {
|
||||
if (idx >= TILE_PROVIDERS.length || !SA.map) return;
|
||||
const provider = TILE_PROVIDERS[idx];
|
||||
|
||||
// Use cached tile layer for first provider (ArcGIS)
|
||||
let tileLayer;
|
||||
if (idx === 0) {
|
||||
tileLayer = new CachedTileLayer(provider.url, {
|
||||
attribution: provider.attr,
|
||||
maxZoom: 18,
|
||||
errorTileUrl: OFFLINE_TILE,
|
||||
});
|
||||
} else {
|
||||
tileLayer = L.tileLayer(provider.url, {
|
||||
attribution: provider.attr,
|
||||
maxZoom: 18,
|
||||
errorTileUrl: OFFLINE_TILE,
|
||||
});
|
||||
}
|
||||
tileLayer.addTo(SA.map);
|
||||
|
||||
let tileError = false;
|
||||
tileLayer.on('tileerror', () => {
|
||||
if (!tileError) {
|
||||
tileError = true;
|
||||
console.warn('Tile provider failed:', provider.name);
|
||||
SA.map.removeLayer(tileLayer);
|
||||
if (idx + 1 >= TILE_PROVIDERS.length) {
|
||||
showWarning('离线模式:地图瓦片不可用,数据点仍可显示');
|
||||
L.tileLayer(OFFLINE_TILE, {maxZoom: 18}).addTo(SA.map);
|
||||
} else {
|
||||
tryTileProvider(idx + 1);
|
||||
}
|
||||
}
|
||||
});
|
||||
SA._tileLayer = tileLayer;
|
||||
}
|
||||
|
||||
// ── Grid-based marker clustering ──────────────────────────
|
||||
function renderMapData() {
|
||||
if (!SA.map) return;
|
||||
|
||||
// Remove old layers
|
||||
if (SA.pointLayer) SA.map.removeLayer(SA.pointLayer);
|
||||
if (SA.centerLayer) SA.map.removeLayer(SA.centerLayer);
|
||||
|
||||
const nodes = SA.nodes || [];
|
||||
const clusters = SA.clusterMap || {};
|
||||
const colors = generateColors(20);
|
||||
if (!nodes.length) return;
|
||||
|
||||
const allPoints = [];
|
||||
const centerMarkers = [];
|
||||
const bounds = SA.map.getBounds();
|
||||
const zoom = SA.map.getZoom();
|
||||
|
||||
// Build color map for clusters
|
||||
const clusterColors = {};
|
||||
const sortedClusters = Object.values(clusters)
|
||||
.sort((a, b) => b.size - a.size);
|
||||
sortedClusters.forEach((c, i) => {
|
||||
clusterColors[c.label] = c.size <= 1 ? '#b0b0b0' : colors[i % colors.length];
|
||||
});
|
||||
// Grid cell size in degrees (adapts to zoom level)
|
||||
const cellSize = Math.max(0.05, 60 / Math.pow(2, zoom));
|
||||
const grid = {}; // key: "latBin_lonBin" → {nodes, clusterIds}
|
||||
|
||||
// Group nodes by cluster
|
||||
const nodesByCluster = {};
|
||||
nodes.forEach(n => {
|
||||
const cid = n.cluster_id;
|
||||
if (!nodesByCluster[cid]) nodesByCluster[cid] = [];
|
||||
nodesByCluster[cid].push(n);
|
||||
if (n.lat == null || n.lon == null) return;
|
||||
if (!bounds.contains([n.lat, n.lon])) return;
|
||||
// Skip hidden clusters
|
||||
if (SA.hiddenClusters.has(n.cluster_id)) return;
|
||||
|
||||
const latBin = Math.floor(n.lat / cellSize);
|
||||
const lonBin = Math.floor(n.lon / cellSize);
|
||||
const key = `${latBin}_${lonBin}`;
|
||||
if (!grid[key]) grid[key] = {nodes: [], clusterIds: new Set(), lat: 0, lon: 0};
|
||||
grid[key].nodes.push(n);
|
||||
grid[key].clusterIds.add(n.cluster_id);
|
||||
grid[key].lat += n.lat;
|
||||
grid[key].lon += n.lon;
|
||||
});
|
||||
|
||||
// Render each node as a circle marker
|
||||
Object.entries(nodesByCluster).forEach(([cid, clusterNodes]) => {
|
||||
const cidInt = parseInt(cid);
|
||||
const color = clusterColors[cidInt] || '#4361ee';
|
||||
const hidden = SA.hiddenClusters.has(cidInt);
|
||||
const isSolo = clusterNodes.length === 1;
|
||||
const markers = [];
|
||||
const centerMarkers = [];
|
||||
const colors = generateColors(20);
|
||||
const clusterColorMap = {};
|
||||
Object.values(clusters).sort((a, b) => b.size - a.size).forEach((c, i) => {
|
||||
clusterColorMap[c.label] = c.size <= 1 ? '#b0b0b0' : colors[i % colors.length];
|
||||
});
|
||||
|
||||
clusterNodes.forEach(n => {
|
||||
if (n.lat == null || n.lon == null) return;
|
||||
if (hidden) return;
|
||||
Object.entries(grid).forEach(([key, cell]) => {
|
||||
const count = cell.nodes.length;
|
||||
const avgLat = cell.lat / count;
|
||||
const avgLon = cell.lon / count;
|
||||
|
||||
const radius = isSolo ? 3 : 5;
|
||||
const opacity = isSolo ? 0.5 : 0.7;
|
||||
if (count === 1) {
|
||||
// Single node in cell — render directly
|
||||
const n = cell.nodes[0];
|
||||
const color = clusterColorMap[n.cluster_id] || '#4361ee';
|
||||
const circle = L.circleMarker([n.lat, n.lon], {
|
||||
radius: radius,
|
||||
fillColor: color,
|
||||
color: '#fff',
|
||||
weight: 0.5,
|
||||
opacity: 0.8,
|
||||
fillOpacity: opacity,
|
||||
radius: 4, fillColor: color, color: '#fff', weight: 0.5,
|
||||
opacity: 0.85, fillOpacity: 0.7,
|
||||
});
|
||||
const serverLabel = n.is_server ? ' 🖥' : '';
|
||||
const clusterLabel = isSolo ? '独立节点' : '社区 ' + cidInt;
|
||||
circle.bindTooltip(
|
||||
`<div class="sa-point-tooltip">
|
||||
<b>${escapeHtml(n.ip)}</b>${serverLabel}<br>
|
||||
${n.city ? `<span class="tt-row"><span class="tt-key">城市:</span>${escapeHtml(n.city)}</span><br>` : ''}
|
||||
${n.isp ? `<span class="tt-row"><span class="tt-key">ISP:</span>${escapeHtml(n.isp)}</span><br>` : ''}
|
||||
<span class="tt-row"><span class="tt-key">归属:</span>${clusterLabel}</span><br>
|
||||
<span style="color:#4361ee;">点击查看详情</span>
|
||||
</div>`
|
||||
);
|
||||
circle.bindTooltip(buildNodeTooltip(n), {sticky: true});
|
||||
circle.on('click', () => loadNodeDetail(n.ip));
|
||||
allPoints.push(circle);
|
||||
});
|
||||
|
||||
// Cluster center marker (only for multi-node clusters)
|
||||
if (!isSolo) {
|
||||
const cinfo = clusters[cidInt] || clusters[String(cidInt)] || {};
|
||||
if (cinfo.center_lat != null && cinfo.center_lon != null) {
|
||||
const icon = L.divIcon({
|
||||
html: `<div style="background:${color};width:22px;height:22px;border-radius:50%;border:2px solid #fff;box-shadow:0 2px 6px rgba(0,0,0,0.4);display:flex;align-items:center;justify-content:center;color:#fff;font-size:10px;font-weight:700;">${cidInt}</div>`,
|
||||
className: '',
|
||||
iconSize: [22, 22],
|
||||
iconAnchor: [11, 11],
|
||||
markers.push(circle);
|
||||
} else if (count <= 10) {
|
||||
// Few nodes — render all with slight offset
|
||||
cell.nodes.forEach((n, i) => {
|
||||
const color = clusterColorMap[n.cluster_id] || '#4361ee';
|
||||
const circle = L.circleMarker([n.lat, n.lon], {
|
||||
radius: 4, fillColor: color, color: '#fff', weight: 0.5,
|
||||
opacity: 0.75, fillOpacity: 0.6,
|
||||
});
|
||||
const marker = L.marker([cinfo.center_lat, cinfo.center_lon], {icon});
|
||||
marker.bindTooltip(
|
||||
`<div class="sa-point-tooltip">
|
||||
<b>社区 ${cidInt} 中心</b><br>
|
||||
${cinfo.size} 个IP · ${cinfo.subnet || ''}<br>
|
||||
点击查看详情
|
||||
</div>`,
|
||||
{direction: 'top'}
|
||||
);
|
||||
marker.on('click', () => loadClusterDetail(cidInt));
|
||||
centerMarkers.push(marker);
|
||||
}
|
||||
circle.bindTooltip(buildNodeTooltip(n), {sticky: true});
|
||||
circle.on('click', () => loadNodeDetail(n.ip));
|
||||
markers.push(circle);
|
||||
});
|
||||
} else {
|
||||
// Many nodes — show cluster marker
|
||||
const dominantCluster = [...cell.clusterIds].sort((a, b) => {
|
||||
const ca = cell.nodes.filter(n => n.cluster_id === a).length;
|
||||
const cb = cell.nodes.filter(n => n.cluster_id === b).length;
|
||||
return cb - ca;
|
||||
})[0];
|
||||
const color = clusterColorMap[dominantCluster] || '#4361ee';
|
||||
|
||||
const icon = L.divIcon({
|
||||
html: `<div style="background:${color};width:${Math.min(28, 14 + count/3)}px;height:${Math.min(28, 14 + count/3)}px;border-radius:50%;border:2px solid #fff;box-shadow:0 1px 4px rgba(0,0,0,0.3);display:flex;align-items:center;justify-content:center;color:#fff;font-size:${Math.min(11, 8 + count/10)}px;font-weight:700;">${count}</div>`,
|
||||
className: '', iconSize: [28, 28], iconAnchor: [14, 14],
|
||||
});
|
||||
const marker = L.marker([avgLat, avgLon], {icon});
|
||||
marker.bindTooltip(`<b>${count} 个节点</b><br>${cell.clusterIds.size} 个社区<br>点击放大查看`, {direction: 'top'});
|
||||
marker.on('click', () => {
|
||||
SA.map.setView([avgLat, avgLon], Math.min(zoom + 3, 12));
|
||||
});
|
||||
markers.push(marker);
|
||||
}
|
||||
});
|
||||
|
||||
SA.pointLayer = L.layerGroup(allPoints).addTo(SA.map);
|
||||
// Cluster center markers (only for multi-node clusters)
|
||||
Object.entries(clusters).forEach(([cid, cinfo]) => {
|
||||
const cidInt = parseInt(cid);
|
||||
if (cinfo.size <= 1 || SA.hiddenClusters.has(cidInt)) return;
|
||||
if (cinfo.center_lat == null || cinfo.center_lon == null) return;
|
||||
if (!bounds.contains([cinfo.center_lat, cinfo.center_lon])) return;
|
||||
|
||||
const color = clusterColorMap[cidInt] || '#4361ee';
|
||||
const icon = L.divIcon({
|
||||
html: `<div style="background:${color};width:20px;height:20px;border-radius:50%;border:2px solid #fff;box-shadow:0 2px 6px rgba(0,0,0,0.4);display:flex;align-items:center;justify-content:center;color:#fff;font-size:9px;font-weight:700;">${cidInt}</div>`,
|
||||
className: '', iconSize: [20, 20], iconAnchor: [10, 10],
|
||||
});
|
||||
const marker = L.marker([cinfo.center_lat, cinfo.center_lon], {icon});
|
||||
marker.bindTooltip(`<b>社区 ${cidInt} 中心</b><br>${cinfo.size} IP · ${cinfo.subnet || ''}`, {direction: 'top'});
|
||||
marker.on('click', () => loadClusterDetail(cidInt));
|
||||
centerMarkers.push(marker);
|
||||
});
|
||||
|
||||
SA.pointLayer = L.layerGroup(markers).addTo(SA.map);
|
||||
SA.centerLayer = L.layerGroup(centerMarkers).addTo(SA.map);
|
||||
|
||||
// Fit bounds
|
||||
const boundsPoints = [];
|
||||
allPoints.forEach(p => { const ll = p.getLatLng(); if (ll) boundsPoints.push(ll); });
|
||||
centerMarkers.forEach(m => boundsPoints.push(m.getLatLng()));
|
||||
if (boundsPoints.length) {
|
||||
try {
|
||||
const bounds = L.latLngBounds(boundsPoints);
|
||||
SA.map.fitBounds(bounds, {padding: [50, 50], maxZoom: 12});
|
||||
} catch(e) {
|
||||
SA.map.setView([0, 0], 2);
|
||||
}
|
||||
}
|
||||
|
||||
renderLegend(colors);
|
||||
renderCharts();
|
||||
renderEdgesOnMap();
|
||||
}
|
||||
|
||||
function buildNodeTooltip(n) {
|
||||
const serverLabel = n.is_server ? ' 🖥' : '';
|
||||
return `<div class="sa-point-tooltip">
|
||||
<b>${escapeHtml(n.ip)}</b>${serverLabel}<br>
|
||||
${n.city ? `<span>${escapeHtml(n.city)}</span><br>` : ''}
|
||||
${n.isp ? `<span style="color:#888;">${escapeHtml(n.isp)}</span><br>` : ''}
|
||||
<span style="color:#4361ee;">点击查看详情</span></div>`;
|
||||
}
|
||||
|
||||
function renderLegend(colors) {
|
||||
@@ -1589,112 +1506,70 @@ function renderEdgesOnMap() {
|
||||
if (!SA.map) return;
|
||||
if (_edgeLayer) SA.map.removeLayer(_edgeLayer);
|
||||
if (!document.getElementById('saShowEdges')?.checked) return;
|
||||
if (!SA.edgesData || !SA.edgesData.length) return;
|
||||
if (!SA.edgesData || !SA.edgesData.length) {
|
||||
setText('saEdgeCount', '0 条边');
|
||||
return;
|
||||
}
|
||||
|
||||
const nodes = SA.nodes || [];
|
||||
// Build IP → coord lookup
|
||||
const ipCoord = {};
|
||||
nodes.forEach(n => {
|
||||
if (n.lat != null && n.lon != null) {
|
||||
ipCoord[n.ip] = [n.lat, n.lon];
|
||||
}
|
||||
if (n.lat != null && n.lon != null) ipCoord[n.ip] = [n.lat, n.lon];
|
||||
});
|
||||
|
||||
const minFreq = parseInt(document.getElementById('saEdgeFreq')?.value || 1);
|
||||
const maxCount = Math.max(...SA.edgesData.map(e => e.comm_count || 1), 1);
|
||||
const edgePolys = [];
|
||||
let shown = 0;
|
||||
|
||||
SA.edgesData.slice(0, 200).forEach(e => {
|
||||
SA.edgesData.forEach(e => {
|
||||
if ((e.comm_count || 0) < minFreq) return;
|
||||
const srcCoord = ipCoord[e.source];
|
||||
const dstCoord = ipCoord[e.target];
|
||||
if (!srcCoord || !dstCoord) return;
|
||||
if (shown >= 300) return; // Cap visible edges
|
||||
shown++;
|
||||
|
||||
const weight = Math.min((e.comm_count || 1) / maxCount * 3 + 0.5, 4);
|
||||
const poly = L.polyline([srcCoord, dstCoord], {
|
||||
color: '#ff6b6b',
|
||||
weight: weight,
|
||||
opacity: 0.35,
|
||||
dashArray: null,
|
||||
color: '#ff6b6b', weight: weight, opacity: 0.3,
|
||||
});
|
||||
const firstSeen = e.first_seen || '';
|
||||
const lastSeen = e.last_seen || '';
|
||||
const peakHours = e.peak_hours || '';
|
||||
poly.bindTooltip(
|
||||
`<div class="sa-point-tooltip">
|
||||
<b>${escapeHtml(e.source)} → ${escapeHtml(e.target)}</b><br>
|
||||
通信: ${e.comm_count} 次<br>
|
||||
${e.total_bytes ? '流量: ' + (e.total_bytes/1024).toFixed(1) + ' KB<br>' : ''}
|
||||
${firstSeen ? '首次: ' + firstSeen + '<br>' : ''}
|
||||
${lastSeen ? '末次: ' + lastSeen + '<br>' : ''}
|
||||
${peakHours ? '活跃时段: ' + peakHours + 'h' : ''}
|
||||
</div>`
|
||||
`<div class="sa-point-tooltip"><b>${escapeHtml(e.source)} → ${escapeHtml(e.target)}</b><br>` +
|
||||
`通信: ${e.comm_count} 次<br>` +
|
||||
`${e.total_bytes ? '流量: ' + (e.total_bytes/1024).toFixed(1) + ' KB' : ''}</div>`
|
||||
);
|
||||
poly.on('click', () => loadEdgeDetail(e));
|
||||
edgePolys.push(poly);
|
||||
});
|
||||
|
||||
if (edgePolys.length) {
|
||||
_edgeLayer = L.layerGroup(edgePolys).addTo(SA.map);
|
||||
}
|
||||
if (edgePolys.length) _edgeLayer = L.layerGroup(edgePolys).addTo(SA.map);
|
||||
setText('saEdgeCount', `${shown} 条边显示`);
|
||||
}
|
||||
|
||||
function loadEdgeDetail(e) {
|
||||
const panel = document.getElementById('saDetailPanel');
|
||||
const body = document.getElementById('saDetailBody');
|
||||
if (!panel || !body) return;
|
||||
setText('saDetailTitle', `边: ${e.source} → ${e.target}`);
|
||||
|
||||
body.innerHTML = `
|
||||
<div class="stat-row">
|
||||
<div class="stat-card"><div class="num">${e.comm_count}</div><div class="lbl">通信次数</div></div>
|
||||
<div class="stat-card"><div class="num">${e.total_bytes ? (e.total_bytes/1024).toFixed(1) + ' KB' : '—'}</div><div class="lbl">总流量</div></div>
|
||||
</div>
|
||||
<div class="stat-row">
|
||||
<div class="stat-card"><div class="num" style="font-size:0.85rem;">${e.first_seen || '—'}</div><div class="lbl">首次通信 (UTC)</div></div>
|
||||
<div class="stat-card"><div class="num" style="font-size:0.85rem;">${e.last_seen || '—'}</div><div class="lbl">末次通信 (UTC)</div></div>
|
||||
</div>
|
||||
${e.peak_hours ? `<div class="stat-row"><div class="stat-card"><div class="num">${e.peak_hours} 时</div><div class="lbl">活跃时段 (UTC)</div></div></div>` : ''}
|
||||
<div class="section-title">源 IP</div>
|
||||
<div><span class="sa-tag" style="cursor:pointer;" onclick="loadNodeDetail('${escapeHtml(e.source)}')">${escapeHtml(e.source)}</span></div>
|
||||
<div class="section-title">目的 IP</div>
|
||||
<div><span class="sa-tag" style="cursor:pointer;" onclick="loadNodeDetail('${escapeHtml(e.target)}')">${escapeHtml(e.target)}</span></div>
|
||||
`;
|
||||
body.innerHTML =
|
||||
`<div class="stat-row">` +
|
||||
`<div class="stat-card"><div class="num">${e.comm_count}</div><div class="lbl">通信次数</div></div>` +
|
||||
`<div class="stat-card"><div class="num">${e.total_bytes ? (e.total_bytes/1024).toFixed(1) + ' KB' : '—'}</div><div class="lbl">总流量</div></div></div>` +
|
||||
`<div class="stat-row">` +
|
||||
`<div class="stat-card"><div class="num" style="font-size:0.85rem;">${e.first_seen || '—'}</div><div class="lbl">首次通信</div></div>` +
|
||||
`<div class="stat-card"><div class="num" style="font-size:0.85rem;">${e.last_seen || '—'}</div><div class="lbl">末次通信</div></div></div>` +
|
||||
`<div class="section-title">源 IP</div><div><span class="sa-tag" style="cursor:pointer;" onclick="loadNodeDetail('${escapeHtml(e.source)}')">${escapeHtml(e.source)}</span></div>` +
|
||||
`<div class="section-title">目的 IP</div><div><span class="sa-tag" style="cursor:pointer;" onclick="loadNodeDetail('${escapeHtml(e.target)}')">${escapeHtml(e.target)}</span></div>`;
|
||||
panel.classList.add('open');
|
||||
}
|
||||
|
||||
function loadEdges() {
|
||||
if (SA._loading || !SA.sessionId) return;
|
||||
// Edges are already loaded from cluster step; just toggle display
|
||||
if (SA.edgesData && SA.edgesData.length) {
|
||||
document.getElementById('saEdgeCount').textContent = `${SA.edgesData.length} 条边`;
|
||||
renderEdgesOnMap();
|
||||
return;
|
||||
}
|
||||
const minFreq = parseInt(document.getElementById('saEdgeFreq').value) || 1;
|
||||
setBusy(true, '加载边关系...');
|
||||
fetch('/simple/edges/', {
|
||||
method: 'POST',
|
||||
headers: {'Content-Type': 'application/json', 'X-CSRFToken': getCSRF()},
|
||||
body: JSON.stringify({session_id: SA.sessionId, min_freq: minFreq}),
|
||||
})
|
||||
.then(r => r.json())
|
||||
.then(data => {
|
||||
setBusy(false);
|
||||
if (data.error) { showError(data.error); return; }
|
||||
SA.edgesData = data.edges || [];
|
||||
document.getElementById('saEdgeCount').textContent = `${SA.edgesData.length} 条边`;
|
||||
renderEdgesOnMap();
|
||||
showSuccess(`加载 ${SA.edgesData.length} 条通联边`);
|
||||
})
|
||||
.catch(err => { setBusy(false); showError('边加载失败: ' + err.message); });
|
||||
}
|
||||
|
||||
function toggleEdges() {
|
||||
if (document.getElementById('saShowEdges').checked) {
|
||||
if (document.getElementById('saShowEdges')?.checked) {
|
||||
renderEdgesOnMap();
|
||||
} else {
|
||||
if (_edgeLayer && SA.map) {
|
||||
SA.map.removeLayer(_edgeLayer);
|
||||
_edgeLayer = null;
|
||||
}
|
||||
if (_edgeLayer && SA.map) { SA.map.removeLayer(_edgeLayer); _edgeLayer = null; }
|
||||
setText('saEdgeCount', '');
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1736,6 +1611,17 @@ function downloadCSV(csv, filename) {
|
||||
/* ── Page init ─────────────────────────────────────────── */
|
||||
(function() {
|
||||
restorePreset();
|
||||
// Check GeoIP precision
|
||||
fetch('/simple/geoip-status/', {headers: {'X-CSRFToken': getCSRF()}})
|
||||
.then(r => r.json())
|
||||
.then(d => {
|
||||
updateDebug({geo: d.source || '—'});
|
||||
if (!d.using_mmdb) {
|
||||
const el = document.getElementById('saDebugNote');
|
||||
if (el) el.textContent = '提示: 放入 GeoLite2-City.mmdb 到 data/ 可获精确坐标';
|
||||
}
|
||||
})
|
||||
.catch(() => {});
|
||||
})();
|
||||
|
||||
/* ── Close detail ───────────────────────────────────────── */
|
||||
|
||||
@@ -4,6 +4,7 @@ from . import views
|
||||
app_name = 'simple_analysis'
|
||||
urlpatterns = [
|
||||
path('', views.index, name='index'),
|
||||
path('geoip-status/', views.geoip_status, name='geoip_status'),
|
||||
path('quick-scan/', views.quick_scan, name='quick_scan'),
|
||||
path('upload/', views.upload_csv, name='upload'),
|
||||
path('filter/', views.filter_data, name='filter'),
|
||||
|
||||
@@ -1502,6 +1502,17 @@ def column_values(request):
|
||||
return JsonResponse({'error': '缺少 session_id 或 scan_id'}, status=400)
|
||||
|
||||
|
||||
@require_GET
|
||||
def geoip_status(request):
|
||||
"""返回当前 GeoIP 数据库状态。"""
|
||||
from analysis import geoip
|
||||
return JsonResponse({
|
||||
'available': geoip.is_available(),
|
||||
'source': geoip.source_name(),
|
||||
'using_mmdb': 'mmdb' in geoip.source_name().lower(),
|
||||
})
|
||||
|
||||
|
||||
# ── Main page ────────────────────────────────────────────────────────────────
|
||||
|
||||
def index(request):
|
||||
|
||||
Reference in New Issue
Block a user