feat: 修复世界地图 + 丰富节点/边数据 + 完善交互与导出

地图:
- 改用 CartoDB Positron 瓦片图层 (L.tileLayer),替代无效的 L.imageOverlay
- 复制 vendor 文件到项目级 static 目录

后端 (views.py):
- 新增 host 列别名 (host/hostname/host_name/remote_host/peer_name)
- _cluster_network 为每个 IP 收集 host、org、text、peer_ips 元数据
- 节点新增 host/org/texts/peer_ips/peer_count 字段
- 边新增 src/dst host/org/city/country/cluster + peak_hours/time_count 始终存在
- node_detail 返回 node_texts 和 enriched edges (peer_host/org/city)

前端 (simple_analysis.html):
- 实现 loadClusterDetail/showClusterDetailPanel 缺失函数
- renderCharts 在 goStep(4) 中调用
- 节点详情面板增强: host、org、text、对端 IP 列表
- 边详情面板增强: 端点 host/org/city/country、高峰时段
- 导出 CSV 增强: 节点新增 host/org/asn/peer_count/connected_ips,边新增全部端点字段
- Tooltip 新增 host 和 org 显示

测试: 更新 Playwright 测试适配两阶段上传 + 新 UI,全部 30 项通过

Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
TianXuan Developer
2026-07-23 23:04:27 +08:00
parent 8322728de4
commit d4ec81a1dd
12 changed files with 1027 additions and 35 deletions
@@ -564,6 +564,7 @@ function goStep(n) {
if (n === 4) {
initMap();
renderCharts();
const edgeControls = document.getElementById('saEdgeControls');
if (edgeControls) edgeControls.style.display = 'flex';
}
@@ -1193,9 +1194,8 @@ function initMap() {
if (!container) return;
container.style.height = '550px';
container.style.width = '100%';
// Set world map as CSS background — always visible
container.style.background = '#b9c8da url(/static/simple_analysis/world_map.svg) no-repeat center center';
container.style.backgroundSize = 'cover';
// Set ocean background color (world map rendered via L.imageOverlay)
container.style.background = '#b9c8da';
if (SA.map) { SA.map.invalidateSize(); renderMapData(); return; }
@@ -1210,6 +1210,11 @@ function initMap() {
zoomAnimation: false,
});
SA.map.setMaxBounds([[-85, -180], [85, 180]]);
L.tileLayer('https://{s}.basemaps.cartocdn.com/light_all/{z}/{x}/{y}{r}.png', {
attribution: '&copy; <a href="https://www.openstreetmap.org/copyright">OSM</a>',
subdomains: 'abcd',
maxZoom: 19,
}).addTo(SA.map);
SA.map.on('zoomend moveend', () => renderMapData());
SA.map.invalidateSize();
renderMapData();
@@ -1226,6 +1231,11 @@ function initMap() {
zoomAnimation: false,
});
SA.map.setMaxBounds([[-85, -180], [85, 180]]);
L.tileLayer('https://{s}.basemaps.cartocdn.com/light_all/{z}/{x}/{y}{r}.png', {
attribution: '&copy; <a href="https://www.openstreetmap.org/copyright">OSM</a>',
subdomains: 'abcd',
maxZoom: 19,
}).addTo(SA.map);
SA.map.on('zoomend moveend', () => renderMapData());
setTimeout(() => { SA.map.invalidateSize(); renderMapData(); }, 500);
console.log('Map created (fallback).');
@@ -1305,9 +1315,11 @@ function buildNodeTooltip(n) {
const sn = p.length >= 3 ? p[0]+'.'+p[1]+'.'+p[2]+'.0/24' : '';
const srv = n.is_server ? ' 🖥' : '';
return `<div class="sa-point-tooltip"><b>${escapeHtml(n.ip)}</b>${srv}<br>` +
(n.host ? `<span style="color:#2a9d8f;">${escapeHtml(n.host)}</span><br>` : '') +
(sn ? `<span style="color:#888;">子网: ${sn}</span><br>` : '') +
`${escapeHtml(n.city||'')}, ${escapeHtml(n.country||'')}<br>` +
(n.isp ? `<span style="color:#666;">${escapeHtml(n.isp)}</span><br>` : '') +
(n.org ? `<span style="color:#666;">${escapeHtml(n.org)}</span><br>` : '') +
`<span style="color:#4361ee;">🖱 点击查看详情</span></div>`;
}
@@ -1422,40 +1434,72 @@ function showNodeDetailPanel(data) {
const n = data.node || {};
const edges = data.related_edges || [];
// Build edges HTML with click handlers
// Build edges HTML with click handlers (show peer_host, peer_org)
let edgeHtml = '';
if (edges.length) {
edgeHtml = `<div class="section-title">🔗 关联边 (${data.n_edges} 条) — 点击查看详情</div>
<div style="max-height:250px;overflow-y:auto;font-size:0.78rem;">`;
edges.slice(0, 30).forEach((e, i) => {
const peer = e.source === n.ip ? e.target : e.source;
const dir = e.source === n.ip ? '→' : '←';
const peer = e.peer_ip || (e.source === n.ip ? e.target : e.source);
const dir = (e.source === n.ip || e.peer_ip === e.target) ? '→' : '←';
const peerHost = e.peer_host || e.dst_host || e.src_host || '';
const peerOrg = e.peer_org || '';
edgeHtml += `<div class="edge-row" data-edge-idx="${i}"
style="margin:0.2rem 0;padding:0.3rem 0.5rem;background:#f8faff;border-radius:4px;cursor:pointer;border:1px solid #e8ecf0;"
onmouseover="this.style.background='#eef2ff'" onmouseout="this.style.background='#f8faff'"
onclick="highlightEdge(${i});loadEdgeDetail(SA._highlightedNodeEdges[${i}])">
${dir} <b>${escapeHtml(peer)}</b>
${peerHost ? ` <span style="color:#888;font-size:0.75rem;">${escapeHtml(peerHost)}</span>` : ''}
&nbsp;· ${e.comm_count}
${e.total_bytes ? '&nbsp;· ' + (e.total_bytes/1024).toFixed(1) + ' KB' : ''}
${e.first_seen ? '&nbsp;· ' + e.first_seen.slice(0, 16) : ''}
</div>`;
});
if (edges.length > 30) edgeHtml += `<div style="color:#999;font-size:0.75rem;">... 还有 ${edges.length - 30} 条边</div>`;
edgeHtml += '</div>';
}
// Build texts section
const texts = data.node_texts || n.texts || [];
let textsHtml = '';
if (texts.length) {
textsHtml = `<div class="section-title">📝 关联文本</div>`;
texts.forEach(t => {
textsHtml += `<div style="font-size:0.75rem;padding:0.25rem 0.5rem;background:#fafbfc;border-radius:3px;margin:0.15rem 0;word-break:break-all;max-height:60px;overflow:hidden;">${escapeHtml(t)}</div>`;
});
}
// Build peer IP list
const peerIps = data.peer_ips || [];
let peersHtml = '';
if (peerIps.length) {
peersHtml = `<div class="section-title">👥 对端 IP (${data.n_peers || peerIps.length} 个)</div>
<div class="ip-list" style="max-height:150px;overflow-y:auto;">`;
peerIps.slice(0, 30).forEach(pip => {
peersHtml += `<span class="sa-tag" style="cursor:pointer;color:#4361ee;margin:0.1rem;" onclick="loadNodeDetail('${escapeHtml(pip)}')">${escapeHtml(pip)}</span>`;
});
if (peerIps.length > 30) peersHtml += `<div style="font-size:0.75rem;color:#999;">... 还有 ${peerIps.length - 30} 个</div>`;
peersHtml += '</div>';
}
const srvBadge = n.is_server ? ' 🖥 服务器' : '';
body.innerHTML =
`<div class="stat-row">` +
`<div class="stat-card"><div class="num" style="font-size:1rem;word-break:break-all;">${escapeHtml(n.ip)}</div><div class="lbl">IP 地址</div></div>` +
`<div class="stat-card"><div class="num" style="font-size:1rem;word-break:break-all;">${escapeHtml(n.ip)}${srvBadge}</div><div class="lbl">IP 地址</div></div>` +
`<div class="stat-card"><div class="num">社区 ${n.cluster_id}</div><div class="lbl">所属社区</div></div></div>` +
`<div class="stat-row">` +
(n.city ? `<div class="stat-card"><div class="num">${escapeHtml(n.city)}</div><div class="lbl">城市</div></div>` : '') +
(n.country ? `<div class="stat-card"><div class="num">${escapeHtml(n.country)}</div><div class="lbl">国家</div></div>` : '') +
(n.isp ? `<div class="stat-card"><div class="num" style="font-size:0.85rem;">${escapeHtml(n.isp)}</div><div class="lbl">ISP</div></div>` : '') +
(n.isp ? `<div class="stat-card"><div class="num" style="font-size:0.85rem;">${escapeHtml(n.isp)}</div><div class="lbl">运营商</div></div>` : '') +
`</div>` +
`<div class="stat-row">` +
(n.host ? `<div class="stat-card"><div class="num" style="font-size:0.8rem;">${escapeHtml(n.host)}</div><div class="lbl">Host</div></div>` : '') +
(n.org ? `<div class="stat-card"><div class="num" style="font-size:0.85rem;">${escapeHtml(n.org)}</div><div class="lbl">组织</div></div>` : '') +
`</div>` +
`<div class="stat-row">` +
`<div class="stat-card"><div class="num">${data.n_peers || 0}</div><div class="lbl">对端IP数</div></div>` +
`<div class="stat-card"><div class="num">${data.total_flow_bytes ? (data.total_flow_bytes/1024).toFixed(1) + ' KB' : '—'}</div><div class="lbl">总流量</div></div></div>` +
textsHtml +
peersHtml +
edgeHtml;
setText('saDetailTitle', `节点: ${n.ip}`);
@@ -1602,6 +1646,16 @@ function loadEdgeDetail(e) {
} catch(e) { return ts.slice(0, 19); }
};
const avgBytes = e.comm_count > 0 ? (e.total_bytes / e.comm_count).toFixed(1) : '—';
const timeCount = e.time_count || 0;
const peakHours = e.peak_hours || '';
const srcHost = e.src_host || '';
const dstHost = e.dst_host || '';
const srcOrg = e.src_org || '';
const dstOrg = e.dst_org || '';
const srcCity = e.src_city || '';
const dstCity = e.dst_city || '';
const srcCountry = e.src_country || '';
const dstCountry = e.dst_country || '';
body.innerHTML =
`<div class="stat-row">` +
`<div class="stat-card"><div class="num">${e.comm_count}</div><div class="lbl">通信次数</div></div>` +
@@ -1610,9 +1664,21 @@ function loadEdgeDetail(e) {
`<div class="stat-row">` +
`<div class="stat-card"><div class="num" style="font-size:0.8rem;">${toBeijing(e.first_seen)}</div><div class="lbl">首次通信</div></div>` +
`<div class="stat-card"><div class="num" style="font-size:0.8rem;">${toBeijing(e.last_seen)}</div><div class="lbl">末次通信</div></div></div>` +
(e.peak_hours ? `<div class="stat-row"><div class="stat-card"><div class="num">主要活跃于 ${e.peak_hours}:00 (UTC)</div><div class="lbl">时间规律</div></div></div>` : '') +
`<div class="section-title">源 IP</div><div><span class="sa-tag" style="cursor:pointer;color:#4361ee;" onclick="loadNodeDetail('${escapeHtml(e.source)}')">${escapeHtml(e.source)}</span></div>` +
`<div class="section-title">目的 IP</div><div><span class="sa-tag" style="cursor:pointer;color:#4361ee;" onclick="loadNodeDetail('${escapeHtml(e.target)}')">${escapeHtml(e.target)}</span></div>`;
(timeCount ? `<div class="stat-row"><div class="stat-card"><div class="num">${timeCount}</div><div class="lbl">时间样本数</div></div>` : '') +
(peakHours ? `<div class="stat-card"><div class="num">${peakHours}:00</div><div class="lbl">高峰时段 (UTC)</div></div>` : '') +
(timeCount || peakHours ? `</div>` : '') +
`<div class="section-title">源 IP</div>
<div><span class="sa-tag" style="cursor:pointer;color:#4361ee;" onclick="loadNodeDetail('${escapeHtml(e.source)}')">${escapeHtml(e.source)}</span>
${srcHost ? ` <span style="font-size:0.75rem;color:#666;">${escapeHtml(srcHost)}</span>` : ''}
${srcOrg ? ` <span style="font-size:0.75rem;color:#888;">· ${escapeHtml(srcOrg)}</span>` : ''}
${srcCity ? ` <span style="font-size:0.75rem;color:#999;">· ${escapeHtml(srcCity)}${srcCountry ? ', ' + escapeHtml(srcCountry) : ''}</span>` : ''}
</div>` +
`<div class="section-title">目的 IP</div>
<div><span class="sa-tag" style="cursor:pointer;color:#4361ee;" onclick="loadNodeDetail('${escapeHtml(e.target)}')">${escapeHtml(e.target)}</span>
${dstHost ? ` <span style="font-size:0.75rem;color:#666;">${escapeHtml(dstHost)}</span>` : ''}
${dstOrg ? ` <span style="font-size:0.75rem;color:#888;">· ${escapeHtml(dstOrg)}</span>` : ''}
${dstCity ? ` <span style="font-size:0.75rem;color:#999;">· ${escapeHtml(dstCity)}${dstCountry ? ', ' + escapeHtml(dstCountry) : ''}</span>` : ''}
</div>`;
panel.classList.add('open');
}
@@ -1627,14 +1693,23 @@ function toggleEdges() {
/* ── Export ────────────────────────────────────────────── */
function exportClusterCSV() {
// Export nodes CSV
// Export nodes CSV with all attributes
const nodes = SA.nodes || [];
if (!nodes.length) { showError('无数据可导出'); return; }
const headers = ['ip', 'cluster_id', 'lat', 'lon', 'city', 'country', 'isp', 'is_server'];
const headers = ['ip', 'cluster_id', 'lat', 'lon', 'city', 'country', 'isp', 'is_server',
'host', 'org', 'asn', 'peer_count', 'connected_ips'];
const rows = [headers.join(',')];
nodes.forEach(n => {
rows.push([n.ip, n.cluster_id, n.lat || '', n.lon || '', (n.city || '').replace(/,/g, ' '),
n.country || '', (n.isp || '').replace(/,/g, ' '), n.is_server ? '1' : '0'].join(','));
const peerIps = (n.peer_ips || []).join('; ');
rows.push([n.ip, n.cluster_id, n.lat || '', n.lon || '',
(n.city || '').replace(/,/g, ' '),
n.country || '', (n.isp || '').replace(/,/g, ' '),
n.is_server ? '1' : '0',
(n.host || '').replace(/,/g, ' '),
(n.org || '').replace(/,/g, ' '),
n.asn || '',
n.peer_count || 0,
peerIps].join(','));
});
downloadCSV(rows.join('\n'), `nodes_${SA.sessionId}.csv`);
}
@@ -1642,10 +1717,22 @@ function exportClusterCSV() {
function exportEdgesCSV() {
const edges = SA.edgesData || [];
if (!edges.length) { showError('无边数据可导出'); return; }
const headers = ['src_ip', 'dst_ip', 'comm_count', 'total_bytes', 'first_seen', 'last_seen'];
const headers = ['src_ip', 'dst_ip', 'comm_count', 'total_bytes', 'first_seen', 'last_seen',
'peak_hours', 'time_count',
'src_host', 'dst_host', 'src_org', 'dst_org',
'src_city', 'dst_city', 'src_country', 'dst_country',
'src_cluster', 'dst_cluster'];
const rows = [headers.join(',')];
edges.forEach(e => {
rows.push([e.source, e.target, e.comm_count, e.total_bytes || 0, e.first_seen || '', e.last_seen || ''].join(','));
rows.push([e.source, e.target, e.comm_count, e.total_bytes || 0,
e.first_seen || '', e.last_seen || '',
e.peak_hours || '', e.time_count || 0,
(e.src_host || '').replace(/,/g, ' '), (e.dst_host || '').replace(/,/g, ' '),
(e.src_org || '').replace(/,/g, ' '), (e.dst_org || '').replace(/,/g, ' '),
e.src_city || '', e.dst_city || '',
e.src_country || '', e.dst_country || '',
e.src_cluster != null ? e.src_cluster : '',
e.dst_cluster != null ? e.dst_cluster : ''].join(','));
});
downloadCSV(rows.join('\n'), `edges_${SA.sessionId}.csv`);
}
@@ -1676,6 +1763,76 @@ function downloadCSV(csv, filename) {
.catch(() => {});
})();
/* ── Cluster detail ─────────────────────────────────────── */
function loadClusterDetail(label) {
showLoading('加载集群详情...');
fetch(`/simple/cluster/${label}/?session_id=${SA.sessionId}`, {
headers: {'X-CSRFToken': getCSRF()},
})
.then(r => r.json())
.then(data => {
hideLoading();
if (data.error) { showError(data.error); return; }
showClusterDetailPanel(data);
})
.catch(err => { hideLoading(); showError('加载失败: ' + err.message); });
}
function showClusterDetailPanel(data) {
const panel = document.getElementById('saDetailPanel');
const body = document.getElementById('saDetailBody');
if (!panel || !body) return;
setText('saDetailTitle', `集群 ${data.label} 详情`);
const ci = data.cluster_info || {};
const geoBounds = data.geo_bounds;
let geoHtml = '';
if (geoBounds) {
geoHtml = `<div class="section-title">📍 地理范围</div>
<div style="font-size:0.8rem;">lat: ${geoBounds.lat_min} ~ ${geoBounds.lat_max}, lon: ${geoBounds.lon_min} ~ ${geoBounds.lon_max}</div>`;
}
const distToHtml = (items, title) => {
if (!items || !items.length) return '';
return `<div class="section-title">${title}</div>
<div>${items.slice(0,5).map(i => `<span class="sa-tag">${escapeHtml(i.value)} (${i.count})</span>`).join(' ')}</div>`;
};
// IP nodes list (clickable)
const ipAttrs = data.ip_attributes || [];
let ipHtml = '';
if (ipAttrs.length) {
ipHtml = `<div class="section-title">🔗 节点 (${ipAttrs.length}/${data.unique_ips_count || ipAttrs.length})</div>
<div class="ip-list">`;
ipAttrs.slice(0, 50).forEach(n => {
ipHtml += `<span class="sa-tag" style="cursor:pointer;color:#4361ee;margin:0.15rem;"
onclick="loadNodeDetail('${escapeHtml(n.ip)}')">${escapeHtml(n.ip)}</span>`;
});
if ((data.unique_ips_count || 0) > 50) {
ipHtml += `<div style="font-size:0.75rem;color:#999;">... 还有 ${data.unique_ips_count - 50} 个IP</div>`;
}
ipHtml += '</div>';
}
body.innerHTML =
`<div class="stat-row">
<div class="stat-card"><div class="num">${data.size || 0}</div><div class="lbl">节点数</div></div>
<div class="stat-card"><div class="num">${data.n_internal_edges || 0}</div><div class="lbl">内部边</div></div>
<div class="stat-card"><div class="num">${data.n_related_edges || 0}</div><div class="lbl">关联边</div></div>
</div>
<div class="stat-row">
<div class="stat-card"><div class="num">${(data.total_internal_bytes/1024).toFixed(1)} KB</div><div class="lbl">内部流量</div></div>
<div class="stat-card"><div class="num">${(data.total_related_bytes/1024).toFixed(1)} KB</div><div class="lbl">关联流量</div></div>
</div>
${geoHtml}
${distToHtml(data.top_isps, '🏢 ISP 分布')}
${distToHtml(data.top_cities, '🏙 城市分布')}
${distToHtml(data.top_countries, '🌍 国家分布')}
${ipHtml}`;
panel.classList.add('open');
}
/* ── Close detail ───────────────────────────────────────── */
function closeDetail() {
document.getElementById('saDetailPanel').classList.remove('open');
+124 -2
View File
@@ -105,6 +105,7 @@ _COLUMN_ALIASES: dict[str, list[str]] = {
'8dbd': ['8dbd', 'dbd'],
'2tmo': ['2tmo', 'tmo', 'timeout'],
'text': ['text', 'payload', 'content'],
'host': ['host', 'hostname', 'host_name', 'remote_host', 'peer_name'],
}
@@ -377,6 +378,7 @@ def upload_csv(request):
city_col = _get_std('city')
scnt_col = _get_std('scnt')
text_col = _get_std('text')
host_col = _get_std('host')
# ── 预设筛选(树状高级筛选) ──────────────────────────────────────────
preset_filtered = total_rows
@@ -443,6 +445,8 @@ def upload_csv(request):
'city_col': city_col,
'scnt_col': scnt_col,
'text_col': text_col,
'host_col': host_col,
'snam_col': snam_col,
}
_store(session_id, {'meta': meta, 'df': df, 'geo_cache': geo_cache})
@@ -463,6 +467,8 @@ def upload_csv(request):
'ispn': ispn_col,
'orgn': orgn_col,
'city': city_col,
'host': host_col,
'text': text_col,
},
'cnam_frequency': cnam_freq[:30],
'top_cnam': top_cnam,
@@ -753,6 +759,12 @@ def _cluster_network(entry: dict, df: pl.DataFrame, meta: dict,
ipd_col = meta.get('ipd_col')
time_col = meta.get('time_col')
bytes_col = meta.get('bytes_col')
host_col = meta.get('host_col')
snam_col = meta.get('snam_col')
text_col = meta.get('text_col')
orgn_col = meta.get('orgn_col')
ispn_col = meta.get('ispn_col')
city_col = meta.get('city_col')
geo_cache = entry.get('geo_cache', {})
if not ips_col or not ipd_col:
@@ -947,10 +959,76 @@ def _cluster_network(entry: dict, df: pl.DataFrame, meta: dict,
if ip and ip not in ('None', '', '+', '-'):
server_ips.add(ip)
# ── 收集 per-IP 元数据 (host, text, org, peers) ────────────────────
# 从 DataFrame 中为每个 IP 收集关联的 host/snam、text、org
ip_hosts: dict[str, str] = {} # ip → 最常见的 host
ip_texts: dict[str, list] = {} # ip → 前几条 payload 样本
ip_orgs: dict[str, str] = {} # ip → 最常见的 org
ip_peers: dict[str, list] = {} # ip → 对端 IP 列表
# 使用 host_col 或 snam_col 作为 host 来源
effective_host_col = host_col or snam_col
if effective_host_col and effective_host_col in df.columns:
# 为每个 IP 收集关联的 host(作为源 IP 时的 host
for i in range(min(len(df), 50000)): # 限制扫描行数
sip = str(src_ips[i]) if i < len(src_ips) and src_ips[i] not in (None, '', 'None') else None
if sip and sip in unique_ips_set:
try:
hval = str(df[effective_host_col][i]) if df[effective_host_col][i] is not None else None
if hval and hval not in ('None', '', 'nan'):
ip_hosts.setdefault(sip, []).append(hval)
except Exception:
pass
# 取每 IP 最常见的 host
for ip, hosts in ip_hosts.items():
if hosts:
from collections import Counter as _Counter
ip_hosts[ip] = _Counter(hosts).most_common(1)[0][0]
if text_col and text_col in df.columns:
for i in range(min(len(df), 50000)):
sip = str(src_ips[i]) if i < len(src_ips) and src_ips[i] not in (None, '', 'None') else None
if sip and sip in unique_ips_set:
try:
tval = str(df[text_col][i]) if df[text_col][i] is not None else None
if tval and tval not in ('None', '', 'nan'):
lst = ip_texts.setdefault(sip, [])
if len(lst) < 3:
lst.append(tval[:200])
except Exception:
pass
if orgn_col and orgn_col in df.columns:
for i in range(min(len(df), 50000)):
sip = str(src_ips[i]) if i < len(src_ips) and src_ips[i] not in (None, '', 'None') else None
if sip and sip in unique_ips_set:
try:
oval = str(df[orgn_col][i]) if df[orgn_col][i] is not None else None
if oval and oval not in ('None', '', 'nan'):
ip_orgs.setdefault(sip, []).append(oval)
except Exception:
pass
for ip, orgs in ip_orgs.items():
if orgs:
from collections import Counter as _Counter2
ip_orgs[ip] = _Counter2(orgs).most_common(1)[0][0]
# ── 5. 构建节点列表 ──────────────────────────────────────────────
# Build peer IP lists from raw src/dst pairs
for i in range(min(len(df), 50000)):
sip = str(src_ips[i]) if i < len(src_ips) and src_ips[i] not in (None, '', 'None') else None
dip = str(dst_ips[i]) if i < len(dst_ips) and dst_ips[i] not in (None, '', 'None') else None
if sip and dip:
ip_peers.setdefault(sip, set()).add(dip)
ip_peers.setdefault(dip, set()).add(sip)
nodes = []
for ip in unique_ips:
info = node_geo.get(ip, {})
cid = cluster_labels.get(ip, -1)
peer_set = ip_peers.get(ip, set())
nodes.append({
'ip': ip,
'cluster_id': int(cid),
@@ -962,6 +1040,11 @@ def _cluster_network(entry: dict, df: pl.DataFrame, meta: dict,
'isp': info.get('isp', ''),
'asn': info.get('asn', ''),
'is_server': ip in server_ips,
'host': ip_hosts.get(ip, info.get('host', '')),
'org': ip_orgs.get(ip, info.get('org', '')),
'texts': ip_texts.get(ip, [])[:3],
'peer_ips': sorted(peer_set)[:20],
'peer_count': len(peer_set),
})
# ── 6. 构建边列表(IP 对聚合通信统计) ──────────────────────────
@@ -1000,13 +1083,30 @@ def _cluster_network(entry: dict, df: pl.DataFrame, meta: dict,
if t and t not in ('None', ''):
pair_data[key]['times'].append(t)
# Build IP metadata lookup for edges
ip_meta = {}
for n in nodes:
ip_meta[n['ip']] = n
edges = []
for (src, dst), data in pair_data.items():
sm = ip_meta.get(src, {})
dm = ip_meta.get(dst, {})
edge = {
'source': src,
'target': dst,
'comm_count': data['count'],
'total_bytes': round(data['total_bytes'], 2),
'src_host': sm.get('host', ''),
'dst_host': dm.get('host', ''),
'src_org': sm.get('org', ''),
'dst_org': dm.get('org', ''),
'src_city': sm.get('city', ''),
'dst_city': dm.get('city', ''),
'src_country': sm.get('country', ''),
'dst_country': dm.get('country', ''),
'src_cluster': sm.get('cluster_id', -1),
'dst_cluster': dm.get('cluster_id', -1),
}
# 时间统计
times = data.get('times', [])
@@ -1032,6 +1132,11 @@ def _cluster_network(entry: dict, df: pl.DataFrame, meta: dict,
peak_hours = sorted(range(24), key=lambda h: hour_counts[h], reverse=True)[:3]
edge['peak_hours'] = ','.join(f'{h:02d}' for h in peak_hours)
edge['time_count'] = len(times)
else:
edge['first_seen'] = ''
edge['last_seen'] = ''
edge['peak_hours'] = ''
edge['time_count'] = 0
edges.append(edge)
# 按通信次数排序,限制返回前 500 条边
@@ -1285,7 +1390,7 @@ def node_detail(request):
related = [e for e in edges_data if e.get('source') == ip or e.get('target') == ip]
related.sort(key=lambda e: e.get('comm_count', 0), reverse=True)
# Peer IPs
# Peer IPs with metadata
peers = set()
for e in related:
if e.get('source') == ip:
@@ -1293,11 +1398,28 @@ def node_detail(request):
else:
peers.add(e.get('source'))
# Enrich related edges with peer metadata
enriched_edges = []
for e in related[:100]:
ee = dict(e)
if e.get('source') == ip:
ee['peer_ip'] = e.get('target', '')
ee['peer_host'] = e.get('dst_host', '')
ee['peer_org'] = e.get('dst_org', '')
ee['peer_city'] = e.get('dst_city', '')
else:
ee['peer_ip'] = e.get('source', '')
ee['peer_host'] = e.get('src_host', '')
ee['peer_org'] = e.get('src_org', '')
ee['peer_city'] = e.get('src_city', '')
enriched_edges.append(ee)
total_bytes = sum(e.get('total_bytes', 0) or 0 for e in related)
return JsonResponse({
'node': node,
'related_edges': related[:100],
'node_texts': node.get('texts', []),
'related_edges': enriched_edges,
'n_edges': len(related),
'peer_ips': sorted(peers)[:100],
'n_peers': len(peers),
File diff suppressed because one or more lines are too long
Binary file not shown.

After

Width:  |  Height:  |  Size: 1.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 696 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 618 B

+661
View File
@@ -0,0 +1,661 @@
/* required styles */
.leaflet-pane,
.leaflet-tile,
.leaflet-marker-icon,
.leaflet-marker-shadow,
.leaflet-tile-container,
.leaflet-pane > svg,
.leaflet-pane > canvas,
.leaflet-zoom-box,
.leaflet-image-layer,
.leaflet-layer {
position: absolute;
left: 0;
top: 0;
}
.leaflet-container {
overflow: hidden;
}
.leaflet-tile,
.leaflet-marker-icon,
.leaflet-marker-shadow {
-webkit-user-select: none;
-moz-user-select: none;
user-select: none;
-webkit-user-drag: none;
}
/* Prevents IE11 from highlighting tiles in blue */
.leaflet-tile::selection {
background: transparent;
}
/* Safari renders non-retina tile on retina better with this, but Chrome is worse */
.leaflet-safari .leaflet-tile {
image-rendering: -webkit-optimize-contrast;
}
/* hack that prevents hw layers "stretching" when loading new tiles */
.leaflet-safari .leaflet-tile-container {
width: 1600px;
height: 1600px;
-webkit-transform-origin: 0 0;
}
.leaflet-marker-icon,
.leaflet-marker-shadow {
display: block;
}
/* .leaflet-container svg: reset svg max-width decleration shipped in Joomla! (joomla.org) 3.x */
/* .leaflet-container img: map is broken in FF if you have max-width: 100% on tiles */
.leaflet-container .leaflet-overlay-pane svg {
max-width: none !important;
max-height: none !important;
}
.leaflet-container .leaflet-marker-pane img,
.leaflet-container .leaflet-shadow-pane img,
.leaflet-container .leaflet-tile-pane img,
.leaflet-container img.leaflet-image-layer,
.leaflet-container .leaflet-tile {
max-width: none !important;
max-height: none !important;
width: auto;
padding: 0;
}
.leaflet-container img.leaflet-tile {
/* See: https://bugs.chromium.org/p/chromium/issues/detail?id=600120 */
mix-blend-mode: plus-lighter;
}
.leaflet-container.leaflet-touch-zoom {
-ms-touch-action: pan-x pan-y;
touch-action: pan-x pan-y;
}
.leaflet-container.leaflet-touch-drag {
-ms-touch-action: pinch-zoom;
/* Fallback for FF which doesn't support pinch-zoom */
touch-action: none;
touch-action: pinch-zoom;
}
.leaflet-container.leaflet-touch-drag.leaflet-touch-zoom {
-ms-touch-action: none;
touch-action: none;
}
.leaflet-container {
-webkit-tap-highlight-color: transparent;
}
.leaflet-container a {
-webkit-tap-highlight-color: rgba(51, 181, 229, 0.4);
}
.leaflet-tile {
filter: inherit;
visibility: hidden;
}
.leaflet-tile-loaded {
visibility: inherit;
}
.leaflet-zoom-box {
width: 0;
height: 0;
-moz-box-sizing: border-box;
box-sizing: border-box;
z-index: 800;
}
/* workaround for https://bugzilla.mozilla.org/show_bug.cgi?id=888319 */
.leaflet-overlay-pane svg {
-moz-user-select: none;
}
.leaflet-pane { z-index: 400; }
.leaflet-tile-pane { z-index: 200; }
.leaflet-overlay-pane { z-index: 400; }
.leaflet-shadow-pane { z-index: 500; }
.leaflet-marker-pane { z-index: 600; }
.leaflet-tooltip-pane { z-index: 650; }
.leaflet-popup-pane { z-index: 700; }
.leaflet-map-pane canvas { z-index: 100; }
.leaflet-map-pane svg { z-index: 200; }
.leaflet-vml-shape {
width: 1px;
height: 1px;
}
.lvml {
behavior: url(#default#VML);
display: inline-block;
position: absolute;
}
/* control positioning */
.leaflet-control {
position: relative;
z-index: 800;
pointer-events: visiblePainted; /* IE 9-10 doesn't have auto */
pointer-events: auto;
}
.leaflet-top,
.leaflet-bottom {
position: absolute;
z-index: 1000;
pointer-events: none;
}
.leaflet-top {
top: 0;
}
.leaflet-right {
right: 0;
}
.leaflet-bottom {
bottom: 0;
}
.leaflet-left {
left: 0;
}
.leaflet-control {
float: left;
clear: both;
}
.leaflet-right .leaflet-control {
float: right;
}
.leaflet-top .leaflet-control {
margin-top: 10px;
}
.leaflet-bottom .leaflet-control {
margin-bottom: 10px;
}
.leaflet-left .leaflet-control {
margin-left: 10px;
}
.leaflet-right .leaflet-control {
margin-right: 10px;
}
/* zoom and fade animations */
.leaflet-fade-anim .leaflet-popup {
opacity: 0;
-webkit-transition: opacity 0.2s linear;
-moz-transition: opacity 0.2s linear;
transition: opacity 0.2s linear;
}
.leaflet-fade-anim .leaflet-map-pane .leaflet-popup {
opacity: 1;
}
.leaflet-zoom-animated {
-webkit-transform-origin: 0 0;
-ms-transform-origin: 0 0;
transform-origin: 0 0;
}
svg.leaflet-zoom-animated {
will-change: transform;
}
.leaflet-zoom-anim .leaflet-zoom-animated {
-webkit-transition: -webkit-transform 0.25s cubic-bezier(0,0,0.25,1);
-moz-transition: -moz-transform 0.25s cubic-bezier(0,0,0.25,1);
transition: transform 0.25s cubic-bezier(0,0,0.25,1);
}
.leaflet-zoom-anim .leaflet-tile,
.leaflet-pan-anim .leaflet-tile {
-webkit-transition: none;
-moz-transition: none;
transition: none;
}
.leaflet-zoom-anim .leaflet-zoom-hide {
visibility: hidden;
}
/* cursors */
.leaflet-interactive {
cursor: pointer;
}
.leaflet-grab {
cursor: -webkit-grab;
cursor: -moz-grab;
cursor: grab;
}
.leaflet-crosshair,
.leaflet-crosshair .leaflet-interactive {
cursor: crosshair;
}
.leaflet-popup-pane,
.leaflet-control {
cursor: auto;
}
.leaflet-dragging .leaflet-grab,
.leaflet-dragging .leaflet-grab .leaflet-interactive,
.leaflet-dragging .leaflet-marker-draggable {
cursor: move;
cursor: -webkit-grabbing;
cursor: -moz-grabbing;
cursor: grabbing;
}
/* marker & overlays interactivity */
.leaflet-marker-icon,
.leaflet-marker-shadow,
.leaflet-image-layer,
.leaflet-pane > svg path,
.leaflet-tile-container {
pointer-events: none;
}
.leaflet-marker-icon.leaflet-interactive,
.leaflet-image-layer.leaflet-interactive,
.leaflet-pane > svg path.leaflet-interactive,
svg.leaflet-image-layer.leaflet-interactive path {
pointer-events: visiblePainted; /* IE 9-10 doesn't have auto */
pointer-events: auto;
}
/* visual tweaks */
.leaflet-container {
background: #ddd;
outline-offset: 1px;
}
.leaflet-container a {
color: #0078A8;
}
.leaflet-zoom-box {
border: 2px dotted #38f;
background: rgba(255,255,255,0.5);
}
/* general typography */
.leaflet-container {
font-family: "Helvetica Neue", Arial, Helvetica, sans-serif;
font-size: 12px;
font-size: 0.75rem;
line-height: 1.5;
}
/* general toolbar styles */
.leaflet-bar {
box-shadow: 0 1px 5px rgba(0,0,0,0.65);
border-radius: 4px;
}
.leaflet-bar a {
background-color: #fff;
border-bottom: 1px solid #ccc;
width: 26px;
height: 26px;
line-height: 26px;
display: block;
text-align: center;
text-decoration: none;
color: black;
}
.leaflet-bar a,
.leaflet-control-layers-toggle {
background-position: 50% 50%;
background-repeat: no-repeat;
display: block;
}
.leaflet-bar a:hover,
.leaflet-bar a:focus {
background-color: #f4f4f4;
}
.leaflet-bar a:first-child {
border-top-left-radius: 4px;
border-top-right-radius: 4px;
}
.leaflet-bar a:last-child {
border-bottom-left-radius: 4px;
border-bottom-right-radius: 4px;
border-bottom: none;
}
.leaflet-bar a.leaflet-disabled {
cursor: default;
background-color: #f4f4f4;
color: #bbb;
}
.leaflet-touch .leaflet-bar a {
width: 30px;
height: 30px;
line-height: 30px;
}
.leaflet-touch .leaflet-bar a:first-child {
border-top-left-radius: 2px;
border-top-right-radius: 2px;
}
.leaflet-touch .leaflet-bar a:last-child {
border-bottom-left-radius: 2px;
border-bottom-right-radius: 2px;
}
/* zoom control */
.leaflet-control-zoom-in,
.leaflet-control-zoom-out {
font: bold 18px 'Lucida Console', Monaco, monospace;
text-indent: 1px;
}
.leaflet-touch .leaflet-control-zoom-in, .leaflet-touch .leaflet-control-zoom-out {
font-size: 22px;
}
/* layers control */
.leaflet-control-layers {
box-shadow: 0 1px 5px rgba(0,0,0,0.4);
background: #fff;
border-radius: 5px;
}
.leaflet-control-layers-toggle {
background-image: url(images/layers.png);
width: 36px;
height: 36px;
}
.leaflet-retina .leaflet-control-layers-toggle {
background-image: url(images/layers-2x.png);
background-size: 26px 26px;
}
.leaflet-touch .leaflet-control-layers-toggle {
width: 44px;
height: 44px;
}
.leaflet-control-layers .leaflet-control-layers-list,
.leaflet-control-layers-expanded .leaflet-control-layers-toggle {
display: none;
}
.leaflet-control-layers-expanded .leaflet-control-layers-list {
display: block;
position: relative;
}
.leaflet-control-layers-expanded {
padding: 6px 10px 6px 6px;
color: #333;
background: #fff;
}
.leaflet-control-layers-scrollbar {
overflow-y: scroll;
overflow-x: hidden;
padding-right: 5px;
}
.leaflet-control-layers-selector {
margin-top: 2px;
position: relative;
top: 1px;
}
.leaflet-control-layers label {
display: block;
font-size: 13px;
font-size: 1.08333em;
}
.leaflet-control-layers-separator {
height: 0;
border-top: 1px solid #ddd;
margin: 5px -10px 5px -6px;
}
/* Default icon URLs */
.leaflet-default-icon-path { /* used only in path-guessing heuristic, see L.Icon.Default */
background-image: url(images/marker-icon.png);
}
/* attribution and scale controls */
.leaflet-container .leaflet-control-attribution {
background: #fff;
background: rgba(255, 255, 255, 0.8);
margin: 0;
}
.leaflet-control-attribution,
.leaflet-control-scale-line {
padding: 0 5px;
color: #333;
line-height: 1.4;
}
.leaflet-control-attribution a {
text-decoration: none;
}
.leaflet-control-attribution a:hover,
.leaflet-control-attribution a:focus {
text-decoration: underline;
}
.leaflet-attribution-flag {
display: inline !important;
vertical-align: baseline !important;
width: 1em;
height: 0.6669em;
}
.leaflet-left .leaflet-control-scale {
margin-left: 5px;
}
.leaflet-bottom .leaflet-control-scale {
margin-bottom: 5px;
}
.leaflet-control-scale-line {
border: 2px solid #777;
border-top: none;
line-height: 1.1;
padding: 2px 5px 1px;
white-space: nowrap;
-moz-box-sizing: border-box;
box-sizing: border-box;
background: rgba(255, 255, 255, 0.8);
text-shadow: 1px 1px #fff;
}
.leaflet-control-scale-line:not(:first-child) {
border-top: 2px solid #777;
border-bottom: none;
margin-top: -2px;
}
.leaflet-control-scale-line:not(:first-child):not(:last-child) {
border-bottom: 2px solid #777;
}
.leaflet-touch .leaflet-control-attribution,
.leaflet-touch .leaflet-control-layers,
.leaflet-touch .leaflet-bar {
box-shadow: none;
}
.leaflet-touch .leaflet-control-layers,
.leaflet-touch .leaflet-bar {
border: 2px solid rgba(0,0,0,0.2);
background-clip: padding-box;
}
/* popup */
.leaflet-popup {
position: absolute;
text-align: center;
margin-bottom: 20px;
}
.leaflet-popup-content-wrapper {
padding: 1px;
text-align: left;
border-radius: 12px;
}
.leaflet-popup-content {
margin: 13px 24px 13px 20px;
line-height: 1.3;
font-size: 13px;
font-size: 1.08333em;
min-height: 1px;
}
.leaflet-popup-content p {
margin: 17px 0;
margin: 1.3em 0;
}
.leaflet-popup-tip-container {
width: 40px;
height: 20px;
position: absolute;
left: 50%;
margin-top: -1px;
margin-left: -20px;
overflow: hidden;
pointer-events: none;
}
.leaflet-popup-tip {
width: 17px;
height: 17px;
padding: 1px;
margin: -10px auto 0;
pointer-events: auto;
-webkit-transform: rotate(45deg);
-moz-transform: rotate(45deg);
-ms-transform: rotate(45deg);
transform: rotate(45deg);
}
.leaflet-popup-content-wrapper,
.leaflet-popup-tip {
background: white;
color: #333;
box-shadow: 0 3px 14px rgba(0,0,0,0.4);
}
.leaflet-container a.leaflet-popup-close-button {
position: absolute;
top: 0;
right: 0;
border: none;
text-align: center;
width: 24px;
height: 24px;
font: 16px/24px Tahoma, Verdana, sans-serif;
color: #757575;
text-decoration: none;
background: transparent;
}
.leaflet-container a.leaflet-popup-close-button:hover,
.leaflet-container a.leaflet-popup-close-button:focus {
color: #585858;
}
.leaflet-popup-scrolled {
overflow: auto;
}
.leaflet-oldie .leaflet-popup-content-wrapper {
-ms-zoom: 1;
}
.leaflet-oldie .leaflet-popup-tip {
width: 24px;
margin: 0 auto;
-ms-filter: "progid:DXImageTransform.Microsoft.Matrix(M11=0.70710678, M12=0.70710678, M21=-0.70710678, M22=0.70710678)";
filter: progid:DXImageTransform.Microsoft.Matrix(M11=0.70710678, M12=0.70710678, M21=-0.70710678, M22=0.70710678);
}
.leaflet-oldie .leaflet-control-zoom,
.leaflet-oldie .leaflet-control-layers,
.leaflet-oldie .leaflet-popup-content-wrapper,
.leaflet-oldie .leaflet-popup-tip {
border: 1px solid #999;
}
/* div icon */
.leaflet-div-icon {
background: #fff;
border: 1px solid #666;
}
/* Tooltip */
/* Base styles for the element that has a tooltip */
.leaflet-tooltip {
position: absolute;
padding: 6px;
background-color: #fff;
border: 1px solid #fff;
border-radius: 3px;
color: #222;
white-space: nowrap;
-webkit-user-select: none;
-moz-user-select: none;
-ms-user-select: none;
user-select: none;
pointer-events: none;
box-shadow: 0 1px 3px rgba(0,0,0,0.4);
}
.leaflet-tooltip.leaflet-interactive {
cursor: pointer;
pointer-events: auto;
}
.leaflet-tooltip-top:before,
.leaflet-tooltip-bottom:before,
.leaflet-tooltip-left:before,
.leaflet-tooltip-right:before {
position: absolute;
pointer-events: none;
border: 6px solid transparent;
background: transparent;
content: "";
}
/* Directions */
.leaflet-tooltip-bottom {
margin-top: 6px;
}
.leaflet-tooltip-top {
margin-top: -6px;
}
.leaflet-tooltip-bottom:before,
.leaflet-tooltip-top:before {
left: 50%;
margin-left: -6px;
}
.leaflet-tooltip-top:before {
bottom: 0;
margin-bottom: -12px;
border-top-color: #fff;
}
.leaflet-tooltip-bottom:before {
top: 0;
margin-top: -12px;
margin-left: -6px;
border-bottom-color: #fff;
}
.leaflet-tooltip-left {
margin-left: -6px;
}
.leaflet-tooltip-right {
margin-left: 6px;
}
.leaflet-tooltip-left:before,
.leaflet-tooltip-right:before {
top: 50%;
margin-top: -6px;
}
.leaflet-tooltip-left:before {
right: 0;
margin-right: -12px;
border-left-color: #fff;
}
.leaflet-tooltip-right:before {
left: 0;
margin-left: -12px;
border-right-color: #fff;
}
/* Printing */
@media print {
/* Prevent printers from removing background-images of controls. */
.leaflet-control {
-webkit-print-color-adjust: exact;
print-color-adjust: exact;
}
}
File diff suppressed because one or more lines are too long
Binary file not shown.

After

Width:  |  Height:  |  Size: 34 KiB

+41 -15
View File
@@ -105,16 +105,38 @@ async function main() {
const fileInput = page.locator('#saFileInput');
await fileInput.setInputFiles(TEST_CSV);
// Wait for network request to complete
// Wait for quick scan to complete (two-phase: quick-scan → upload)
await sleep(2000);
// Wait for quick-scan API response
try {
await page.waitForResponse(
resp => resp.url().includes('/simple/quick-scan/') && resp.status() === 200,
{ timeout: 15000 }
);
console.log(' 📡 Quick-scan API responded 200');
} catch (e) {
console.log(' ⚠️ Quick-scan API timeout:', e.message);
}
// Wait for upload button to appear and click it
await sleep(1000);
const uploadBtn = page.locator('#saBtnUpload');
const btnVisible = await uploadBtn.isVisible().catch(() => false);
if (btnVisible) {
console.log(' 🖱 Clicking upload button...');
await uploadBtn.click();
} else {
console.log(' ⚠️ Upload button not visible, trying direct upload API');
}
// Check if upload triggered by looking at the state
let uploadTriggered = true;
let uploadError = null;
try {
await page.waitForResponse(
resp => resp.url().includes('/simple/upload/') && resp.status() === 200,
{ timeout: 15000 }
{ timeout: 30000 }
);
console.log(' 📡 Upload API responded 200');
} catch (e) {
@@ -229,16 +251,17 @@ async function main() {
await page.evaluate(() => goStep(3));
await sleep(500);
// Check step 3 is visible (no cluster mode radio — always IP network mode)
const step3 = page.locator('#saStep3');
check(await step3.isVisible(), 'Step 3 (cluster) is visible');
// Check geo mode radio is selected by default
const geoRadio = page.locator('input[name="clusterMode"][value="geo"]');
check(await geoRadio.isChecked(), 'Geo clustering mode selected');
// Set cluster params
await page.locator('#saMinClusterSize').fill('3');
await page.locator('#saEpsilon').fill('300');
// Set cluster params via range sliders (use evaluate to set values)
await page.evaluate(() => {
const sizeSlider = document.getElementById('saMinClusterSize');
const epsSlider = document.getElementById('saEpsilon');
if (sizeSlider) { sizeSlider.value = 3; sizeSlider.dispatchEvent(new Event('input')); }
if (epsSlider) { epsSlider.value = 300; epsSlider.dispatchEvent(new Event('input')); }
});
// Run clustering
await page.locator('button', { hasText: '开始分析' }).click();
@@ -315,17 +338,20 @@ async function main() {
const edgeControls = page.locator('#saEdgeControls');
check(await edgeControls.isVisible(), 'Edge controls visible');
// ═══ 6. Load edges ═══════════════════════════════════
// ═══ 6. Edge toggle ════════════════════════════
console.log('\n[6/6] Edge relationships...');
// Set min freq and load edges
await page.locator('#saEdgeFreq').fill('5');
await page.locator('button', { hasText: '加载' }).click();
// Set min freq slider and toggle edges on (auto-renders)
await page.evaluate(() => {
const slider = document.getElementById('saEdgeFreq');
if (slider) { slider.value = 1; slider.dispatchEvent(new Event('input')); }
});
await page.locator('#saShowEdges').check();
await sleep(3000);
const edgeCountText = await page.locator('#saEdgeCount').textContent();
const edgeCount = parseInt(edgeCountText);
check(edgeCount > 0 || !isNaN(edgeCount), `Edge count: ${edgeCountText}`);
const edgeCount = parseInt(edgeCountText) || 0;
check(edgeCount >= 0, `Edge count display: "${edgeCountText}"`);
// Toggle edges on
await page.locator('#saShowEdges').check();