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:
TianXuan Developer
2026-07-23 20:32:21 +08:00
parent 15e4c5e699
commit f065efab26
3 changed files with 198 additions and 300 deletions
@@ -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 &copy; Esri &mdash; 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 &copy; Esri &mdash; Esri, DeLorme, NAVTEQ'
},
{
name: 'OSM',
url: 'https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png',
attr: '&copy; 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 ───────────────────────────────────────── */
+1
View File
@@ -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'),
+11
View File
@@ -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):