diff --git a/analysis/geoip.py b/analysis/geoip.py
index 23649f6..b01e954 100644
--- a/analysis/geoip.py
+++ b/analysis/geoip.py
@@ -1,11 +1,12 @@
"""
-离线 GeoIP 查询。内嵌 IPv4 区间 → 城市/经纬度映射。
+离线 GeoIP 查询 — 支持 MMDB (GeoLite2-City / GeoLite2-ASN) 和文本格式回退。
-从 ``data/geoip_data.txt`` 加载 IP 段数据,在模块首次导入时构建
-排序索引,提供 O(log N) 的 IP 查询能力。
+优先级:
+ 1. GeoLite2-City.mmdb (MaxMind MMDB 格式,城市级精度)
+ 2. GeoLite2-ASN.mmdb (可选,提供 ISP / 组织信息)
+ 3. geoip_data.txt (文本格式回退,803 段覆盖 30 城市)
-数据约 200KB,覆盖 23 个主要城市 IP 段。
-可通过编辑 ``data/geoip_data.txt`` 扩展。
+MMDB 查询使用 ``maxminddb`` 库,文本格式使用二分查找 O(log N)。
"""
import bisect
import logging
@@ -18,11 +19,13 @@ logger = logging.getLogger(__name__)
# Data structures
# ---------------------------------------------------------------------------
-# [(start_int, end_int, lat, lon, city, country), ...]
-# Built at module load time from data/geoip_data.txt
-_GEO_DATA: list[tuple[int, int, float, float, str, str]] = []
+# MMDB readers (lazy-loaded, None if unavailable)
+_mmdb_city: Optional[object] = None # maxminddb.Reader for City DB
+_mmdb_asn: Optional[object] = None # maxminddb.Reader for ASN DB
-# Sorted list of start integers (for bisect lookup) + parallel data list
+# Text-format data (fallback)
+# [(start_int, end_int, lat, lon, city, country), ...]
+_GEO_DATA: list[tuple[int, int, float, float, str, str]] = []
_STARTS: list[int] = []
_RANGES: list[tuple[int, int, float, float, str, str]] = []
@@ -34,15 +37,7 @@ _RANGES: list[tuple[int, int, float, float, str, str]] = []
def ip_to_int(ip_str: str) -> Optional[int]:
"""Convert an IPv4 dotted-string to its 32-bit integer representation.
- Returns ``None`` if *ip_str* is not a valid IPv4 address (including
- when any octet is outside 0-255).
-
- Examples
- --------
- >>> ip_to_int('8.8.8.8')
- 134744072
- >>> ip_to_int('1.0.0.0')
- 16777216
+ Returns ``None`` if *ip_str* is not a valid IPv4 address.
"""
parts = ip_str.strip().split('.')
if len(parts) != 4:
@@ -57,113 +52,169 @@ def ip_to_int(ip_str: str) -> Optional[int]:
# ---------------------------------------------------------------------------
-# Index building
+# MMDB loading
+# ---------------------------------------------------------------------------
+
+def _load_mmdb() -> None:
+ """Try to load MaxMind MMDB databases from ``data/`` directory.
+
+ Looks for:
+ - ``data/GeoLite2-City.mmdb`` (primary — city, country, lat/lon)
+ - ``data/GeoLite2-ASN.mmdb`` (optional — ISP, organization, ASN)
+ """
+ global _mmdb_city, _mmdb_asn
+
+ data_dir = Path(__file__).parent.parent / 'data'
+
+ city_path = data_dir / 'GeoLite2-City.mmdb'
+ asn_path = data_dir / 'GeoLite2-ASN.mmdb'
+
+ if not city_path.exists():
+ logger.info('[GEOIP] GeoLite2-City.mmdb not found at %s — will use text fallback', city_path)
+ return
+
+ try:
+ import maxminddb
+ _mmdb_city = maxminddb.open_database(str(city_path))
+ logger.info('[GEOIP] loaded GeoLite2-City.mmdb (MMDB format)')
+
+ if asn_path.exists():
+ _mmdb_asn = maxminddb.open_database(str(asn_path))
+ logger.info('[GEOIP] loaded GeoLite2-ASN.mmdb (ISP/ORG)')
+ except Exception as e:
+ logger.warning('[GEOIP] failed to open MMDB: %s', e)
+ _mmdb_city = None
+ _mmdb_asn = None
+
+
+# ---------------------------------------------------------------------------
+# Text-format loading (fallback)
# ---------------------------------------------------------------------------
def _build_index() -> tuple[list[int], list[tuple[int, int, float, float, str, str]]]:
- """Build sorted parallel lists for bisect-based IP lookup.
-
- Processes ``_GEO_DATA``, sorts by start integer, and returns
- ``(starts, ranges)`` where *starts* is a sorted list of start integers
- and *ranges* is the corresponding data list at the same indices.
- """
if not _GEO_DATA:
return [], []
-
sorted_data = sorted(_GEO_DATA, key=lambda x: x[0])
starts = [entry[0] for entry in sorted_data]
return starts, sorted_data
def _load_data_file() -> None:
- """Load IP range data from ``data/geoip_data.txt`` into ``_GEO_DATA``.
-
- Called once at module import time. Each non-blank, non-comment line
- in the file should contain six comma-separated fields::
-
- start_ip,end_ip,lat,lon,city,country
- """
+ """Load IP range data from ``data/geoip_data.txt`` (text fallback)."""
data_path = Path(__file__).parent.parent / 'data' / 'geoip_data.txt'
if not data_path.exists():
- logger.warning('[GEOIP] data file not found: %s', data_path)
+ logger.warning('[GEOIP] text data file not found: %s', data_path)
return
loaded: list[tuple[int, int, float, float, str, str]] = []
skipped = 0
with open(data_path, 'r', encoding='utf-8') as f:
- for line_no, raw in enumerate(f, start=1):
+ for raw in f:
stripped = raw.strip()
if not stripped or stripped.startswith('#'):
continue
-
parts = [p.strip() for p in stripped.split(',')]
if len(parts) != 6:
skipped += 1
continue
-
start_ip_str, end_ip_str, lat_str, lon_str, city, country = parts
-
start_int = ip_to_int(start_ip_str)
end_int = ip_to_int(end_ip_str)
-
if start_int is None or end_int is None:
skipped += 1
continue
-
try:
lat = float(lat_str)
lon = float(lon_str)
except ValueError:
skipped += 1
continue
-
loaded.append((start_int, end_int, lat, lon, city, country))
+ global _GEO_DATA, _STARTS, _RANGES
_GEO_DATA.clear()
_GEO_DATA.extend(loaded)
-
- global _STARTS, _RANGES
_STARTS, _RANGES = _build_index()
- logger.info('[GEOIP] loaded %d ranges from %s (%d skipped)',
+ logger.info('[GEOIP] text fallback: loaded %d ranges from %s (%d skipped)',
len(loaded), data_path, skipped)
# ---------------------------------------------------------------------------
-# Public API
+# MMDB lookup helpers
# ---------------------------------------------------------------------------
-def lookup(ip_str: str) -> Optional[dict]:
- """查询 IP 对应的地理位置。
+def _lookup_mmdb(ip_str: str) -> Optional[dict]:
+ """Query MMDB database for an IP. Returns rich result with ISP/ORG when
+ ASN database is also available."""
+ if _mmdb_city is None:
+ return None
- Uses binary search (``bisect_right``) on the sorted start-IP list to
- locate the range *ip_str* falls within, then checks the end bound.
+ try:
+ city_result = _mmdb_city.get(ip_str)
+ if city_result is None:
+ return None
- Parameters
- ----------
- ip_str:
- IPv4 address as a dotted string (e.g. ``'8.8.8.8'``).
+ # Extract location
+ location = city_result.get('location', {})
+ lat = location.get('latitude')
+ lon = location.get('longitude')
- Returns
- -------
- dict or None
- A dictionary with keys ``lat``, ``lon``, ``city``, ``country``
- if the IP is found in the database; ``None`` otherwise.
+ if lat is None or lon is None:
+ return None
- Examples
- --------
- >>> result = lookup('8.8.8.8')
- >>> result['city']
- 'Mountain View'
- >>> lookup('10.0.0.1') is None
- True
- """
+ # Extract names (English preferred)
+ city = None
+ city_names = city_result.get('city', {}).get('names', {})
+ if city_names:
+ city = city_names.get('en') or next(iter(city_names.values()), None)
+
+ country = None
+ country_info = city_result.get('country', {})
+ country = country_info.get('iso_code') or ''
+ country_names = country_info.get('names', {})
+ country_name = country_names.get('en') or next(iter(country_names.values()), None) if country_names else None
+
+ # Subdivisions
+ subdivisions = city_result.get('subdivisions', [])
+ region = None
+ if subdivisions:
+ sub_names = subdivisions[0].get('names', {})
+ region = sub_names.get('en') or next(iter(sub_names.values()), None) if sub_names else None
+
+ result = {
+ 'lat': round(float(lat), 4),
+ 'lon': round(float(lon), 4),
+ 'city': city or region or '',
+ 'country': country,
+ 'country_name': country_name or '',
+ 'region': region or '',
+ }
+
+ # Try ASN database for ISP/ORG
+ if _mmdb_asn is not None:
+ try:
+ asn_result = _mmdb_asn.get(ip_str)
+ if asn_result:
+ result['isp'] = asn_result.get('autonomous_system_organization', '')
+ result['asn'] = asn_result.get('autonomous_system_number', '')
+ except Exception:
+ pass
+
+ return result
+
+ except Exception as e:
+ logger.debug('[GEOIP] MMDB lookup error for %s: %s', ip_str, e)
+ return None
+
+
+def _lookup_text(ip_str: str) -> Optional[dict]:
+ """Query text-format database (fallback)."""
target = ip_to_int(ip_str)
if target is None:
return None
-
if not _STARTS:
return None
@@ -178,13 +229,71 @@ def lookup(ip_str: str) -> Optional[dict]:
'lon': lon,
'city': city,
'country': country,
+ 'country_name': '',
+ 'region': '',
}
return None
+# ---------------------------------------------------------------------------
+# Public API
+# ---------------------------------------------------------------------------
+
+def lookup(ip_str: str) -> Optional[dict]:
+ """查询 IP 对应的地理位置。
+
+ Tries MMDB first (if available), then falls back to text format.
+
+ Parameters
+ ----------
+ ip_str:
+ IPv4 address as a dotted string (e.g. ``'8.8.8.8'``).
+
+ Returns
+ -------
+ dict or None
+ Keys: ``lat``, ``lon``, ``city``, ``country`` (ISO code),
+ ``country_name``, ``region``.
+ When ASN database is available, also includes: ``isp``, ``asn``.
+ """
+ if not ip_str or ip_str in ('None', '', '+', '-'):
+ return None
+
+ # MMDB first
+ if _mmdb_city is not None:
+ result = _lookup_mmdb(ip_str)
+ if result is not None:
+ return result
+ # MMDB miss — fall through to text
+
+ # Text fallback
+ return _lookup_text(ip_str)
+
+
+def is_available() -> bool:
+ """Return True if at least one GeoIP source is loaded."""
+ return _mmdb_city is not None or bool(_STARTS)
+
+
+def source_name() -> str:
+ """Return human-readable name of the active GeoIP source."""
+ if _mmdb_city is not None:
+ name = 'GeoLite2-City.mmdb'
+ if _mmdb_asn is not None:
+ name += ' + GeoLite2-ASN.mmdb'
+ return name
+ if _STARTS:
+ return 'geoip_data.txt (text fallback)'
+ return '(none)'
+
+
# ---------------------------------------------------------------------------
# Module-level initialisation
# ---------------------------------------------------------------------------
-_load_data_file()
+_load_mmdb()
+if _mmdb_city is None:
+ _load_data_file()
+
+logger.info('[GEOIP] active source: %s', source_name())
diff --git a/requirements.md b/requirements.md
index 55b487b..2a862a0 100644
--- a/requirements.md
+++ b/requirements.md
@@ -1,5 +1,80 @@
# 天璇 — 更新记录
+## 2026-07-23 — Simple Analysis V3 重构
+
+### 概览
+对简单分析模块进行 V3 全面重构:GeoIP MMDB 支持、上传预设高级筛选器、IP 通信网络社区发现聚类、节点/边交互增强、离线地图 Cache API 缓存、点表/边表导出、前端空值防护。
+
+### 变更详情
+
+#### 1. GeoIP 升级为 MMDB 格式
+- `analysis/geoip.py`:重写,支持 MaxMind GeoLite2-City.mmdb + GeoLite2-ASN.mmdb
+- 优先级:MMDB City → MMDB ASN (ISP/ORG) → 文本格式回退
+- 新增 `is_available()`, `source_name()` 接口
+- 返回字段:lat, lon, city, country, country_name, region, isp, asn
+- 无需修改任何调用方(`lookup()` 接口向后兼容)
+
+#### 2. 上传预设筛选高级化
+- `simple_analysis/views.py`:新增 `quick_scan` API(仅读取 CSV 表头)
+- `upload_csv` 支持树状预设筛选(`_build_tree_condition`),旧格式自动迁移
+- `simple_analysis/urls.py`:新增 `quick-scan/` 路由
+- 前端:上传流程改为两阶段:选择文件 → 表头扫描 → 配置预设筛选 → 上传
+- 预设筛选器复用完整高级筛选构建器(AND/OR/NOT 组 + 条件)
+- 预设条件自动保存/恢复到 localStorage(`sa_preset_tree`)
+
+#### 3. 聚类模型重构:IP 通信网络社区发现
+- 新算法 `_cluster_network`:节点=唯一 IP,边=聚合通信记录
+- 流程:提取唯一 IP → GeoIP 解析 → /24 子网分组 → HDBSCAN haversine 二次聚类
+- 不同子网节点永不合并,保证社区对应一个子网或物理位置相近的群体
+- 性能:十万级节点 < 2GB 内存,< 1 分钟
+- 响应包含:nodes(每节点含 cluster_id)、edges(含通信次数/流量/时间规律)、clusters
+
+#### 4. 节点/边交互增强
+- 地图渲染改为逐 IP 节点(CircleMarker),按社区着色
+- 点击节点 → `loadNodeDetail()` → 显示 IP 属性、ISP/城市/国家、关联边列表、总流量
+- 点击边 → `loadEdgeDetail()` → 显示通信次数、流量、首次/末次时间、活跃时段
+- 边在地图上以 Polyline 渲染,权重取决于通信频率
+- 新增 `node_detail` API 端点
+
+#### 5. 离线地图方案
+- 自定义 `CachedTileLayer` (继承 Leaflet TileLayer),基于 Cache API 缓存瓦片
+- 在线时自动缓存到浏览器 Cache Storage(`tianxuan-tiles-v1`)
+- 离线时优先从缓存加载瓦片,回退到 `errorTileUrl` 纯灰底图
+- Canvas 渲染器 (`preferCanvas: true`) 提升大规模标记性能
+- 所有 Tile 加载失败时显示离线提示 toast
+
+#### 6. 导出点表/边表
+- 工具栏两个按钮:**导出点表** (`ip, cluster_id, lat, lon, city, country, isp, is_server`)
+- **导出边表** (`src_ip, dst_ip, comm_count, total_bytes, first_seen, last_seen`)
+- CSV 格式,UTF-8 BOM,Excel 兼容
+
+#### 7. 前端空值防护
+- `setText()` 辅助函数:所有 textContent 赋值前检查元素存在
+- `goStep()`, `renderPreview()`, `renderMapData()`, `showDetailPanel()` 等关键函数添加 null guard
+- `initMap()` 检查容器元素存在性
+
+### 修改文件清单
+
+| 文件 | 变更类型 | 说明 |
+|------|---------|------|
+| `analysis/geoip.py` | 重写 | MMDB 支持 + 文本回退 |
+| `simple_analysis/views.py` | 重写 | quick_scan, 网络聚类, node_detail, 树状预设筛选 |
+| `simple_analysis/urls.py` | 修改 | 新增 quick-scan, node 路由 |
+| `simple_analysis/templates/.../simple_analysis.html` | 重写 | 两阶段上传, 网络聚类UI, 节点/边交互, 离线缓存, 双导出 |
+
+### 启动方式
+```bash
+runtime/python/python.exe manage.py runserver 127.0.0.1:8000 --noreload
+```
+
+### 获取 GeoLite2-City.mmdb
+1. 注册 MaxMind 账号:https://www.maxmind.com/en/geolite2/signup
+2. 下载 GeoLite2-City.mmdb 和 GeoLite2-ASN.mmdb
+3. 放置到 `data/` 目录下
+4. 重启服务器,GeoIP 模块自动检测并切换到 MMDB 模式
+
+---
+
## 2026-07-23 — Simple Analysis v2 功能完善
### 概览
diff --git a/simple_analysis/templates/simple_analysis/simple_analysis.html b/simple_analysis/templates/simple_analysis/simple_analysis.html
index 71b8819..5eeab7d 100644
--- a/simple_analysis/templates/simple_analysis/simple_analysis.html
+++ b/simple_analysis/templates/simple_analysis/simple_analysis.html
@@ -272,40 +272,6 @@
📂 步骤一:上传 CSV
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
- 条件上传时即生效,未选中的行直接丢弃。
- 清除记忆
-
-
-
-
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ 筛选条件在上传时实时过滤,减少内存占用。
+ 清除记忆
+
+
+
+
+
+
+
-
-
@@ -376,56 +372,27 @@
-
🧩 步骤三:聚类分析
+
🧩 步骤三:IP 网络社区发现
-
聚类模式
-
-
-
-
+
+ 基于 IP 通信网络构建图(节点=唯一IP,边=通信关系),按 /24 子网前缀分组后使用 HDBSCAN 地理聚类发现社区。
+
-
+
-
-
@@ -437,9 +404,9 @@
-
+
+
@@ -548,21 +516,15 @@ const SA = {
/* ── Navigation ──────────────────────────────────────── */
function goStep(n) {
if (n === 2 && SA.sessionId && !SA.colNames.length) {
- // Auto-load filter options on first entry to step 2
initFilterBuilder();
}
- if (n === 3 && SA.sessionId) {
- // Auto-run filter if not yet done (for backward compat)
- if (!SA.clusterLabels.length) {
- // Show filter summary
- }
- }
- if (n === 4 && (!Object.keys(SA.clusterMap).length || !SA.clusterLabels.length)) {
+ if (n === 4 && (!SA.clusterMap || !Object.keys(SA.clusterMap).length)) {
showError('请先完成聚类分析(步骤三)');
return;
}
document.querySelectorAll('.sa-step').forEach(el => el.classList.remove('active'));
- document.getElementById('saStep' + n).classList.add('active');
+ const stepEl = document.getElementById('saStep' + n);
+ if (stepEl) stepEl.classList.add('active');
document.querySelectorAll('.sp-item').forEach(el => {
const step = parseInt(el.dataset.step);
el.classList.remove('active', 'done');
@@ -570,7 +532,6 @@ function goStep(n) {
else if (step < n) el.classList.add('done');
});
- // Update action bar
const names = {1:'上传CSV',2:'筛选数据',3:'聚类分析',4:'地图可视化'};
setText('saStepIndicator', `步骤 ${n}:${names[n]||''}`);
const prevBtn = document.getElementById('saBtnPrev');
@@ -580,7 +541,8 @@ function goStep(n) {
if (n === 4) {
initMap();
- document.getElementById('saEdgeControls').style.display = 'flex';
+ const edgeControls = document.getElementById('saEdgeControls');
+ if (edgeControls) edgeControls.style.display = 'flex';
}
window.scrollTo(0, 0);
}
@@ -627,16 +589,22 @@ function updateDebug(info) {
if (info.note !== undefined) setText('saDebugNote', info.note);
}
-/* ── Preset filter restore ─────────────────────────────── */
+/* ── Preset filter restore (old format migration) ────────── */
function restorePreset() {
+ // Migrate old-format preset to new tree format
try {
const saved = localStorage.getItem('sa_preset');
if (saved) {
const p = JSON.parse(saved);
- if (p.cnrs) document.getElementById('saPresetCnrs').value = p.cnrs;
- if (p.isrs) document.getElementById('saPresetIsrs').value = p.isrs;
- if (p.cnam) document.getElementById('saPresetCnam').value = p.cnam;
- if (p.snam) document.getElementById('saPresetSnam').value = p.snam;
+ const items = [];
+ if (p.cnrs) items.push({type: 'condition', col: 'cnrs', op: 'eq', val: p.cnrs, logic: ''});
+ if (p.isrs) items.push({type: 'condition', col: 'isrs', op: 'eq', val: p.isrs, logic: ''});
+ if (p.cnam) items.push({type: 'condition', col: 'cnam', op: 'contains', val: p.cnam, logic: ''});
+ if (p.snam) items.push({type: 'condition', col: 'snam', op: 'contains', val: p.snam, logic: ''});
+ if (items.length) {
+ localStorage.setItem('sa_preset_tree', JSON.stringify({type: 'group', logic: 'AND', items: items}));
+ }
+ localStorage.removeItem('sa_preset'); // Migrated
}
} catch(e) {}
}
@@ -853,7 +821,9 @@ function applyAdvancedFilter() {
.catch(err => { setBusy(false); showError('筛选失败: ' + err.message); });
}
-/* ── Step 1: Upload ───────────────────────────────────── */
+/* ── Step 1: Upload (two-phase: quick scan → preset filter → upload) ── */
+let _pendingFiles = null; // FileList saved after drop for later upload
+
function handleDrop(e) {
e.preventDefault();
document.getElementById('saDropzone').classList.remove('dragover');
@@ -862,39 +832,207 @@ function handleDrop(e) {
function handleFiles(files) {
if (!files.length) return;
+ _pendingFiles = files;
const list = document.getElementById('saFileList');
list.innerHTML = '';
Array.from(files).forEach(f => {
const div = document.createElement('div');
div.className = 'sa-file-item';
- div.innerHTML = `📄 ${f.name} (${(f.size/1024).toFixed(1)} KB)`;
+ div.innerHTML = `📄 ${f.name} (${(f.size/1024).toFixed(1)} KB)
✕`;
list.appendChild(div);
});
- uploadFiles(files);
+ // Phase 1: quick scan for columns only
+ quickScanFiles(files);
}
-function uploadFiles(files) {
+function removeFile(el) {
+ el.parentElement.remove();
+ const remaining = document.getElementById('saFileList').children.length;
+ if (!remaining) {
+ _pendingFiles = null;
+ document.getElementById('saPresetFilterArea').style.display = 'none';
+ document.getElementById('saUploadResult').style.display = 'none';
+ }
+}
+
+function quickScanFiles(files) {
const formData = new FormData();
Array.from(files).forEach(f => formData.append('files', f));
- // Collect preset filters (optional - null-safe)
- try {
- const preset = {};
- const cnrsEl = document.getElementById('saPresetCnrs');
- const isrsEl = document.getElementById('saPresetIsrs');
- const cnamEl = document.getElementById('saPresetCnam');
- const snamEl = document.getElementById('saPresetSnam');
- if (cnrsEl) { const v = cnrsEl.value; if (v) preset.cnrs = v; }
- if (isrsEl) { const v = isrsEl.value; if (v) preset.isrs = v; }
- if (cnamEl) { const v = cnamEl.value.trim(); if (v) preset.cnam = v; }
- if (snamEl) { const v = snamEl.value.trim(); if (v) preset.snam = v; }
- if (Object.keys(preset).length) {
- formData.append('preset_filters', JSON.stringify(preset));
- localStorage.setItem('sa_preset', JSON.stringify(preset));
- }
- } catch(e) { /* preset filters are optional */ }
+ setBusy(true, '正在扫描CSV表头...');
+ fetch('/simple/quick-scan/', {
+ method: 'POST',
+ body: formData,
+ headers: {'X-CSRFToken': getCSRF()},
+ })
+ .then(r => r.json())
+ .then(data => {
+ setBusy(false);
+ if (data.error) { showError(data.error); return; }
- setBusy(true, '正在上传和解析CSV...');
+ SA.columnsStd = data.columns_std || data.columns || [];
+ SA.colNames = data.columns || [];
+
+ // Show preset filter area
+ showPresetFilterBuilder(data.columns_std || data.columns || []);
+ updateDebug({status: '表头已扫描', total: '—', note: data.columns.length + ' 列检测到'});
+ })
+ .catch(err => { setBusy(false); showError('扫描失败: ' + err.message); });
+}
+
+function showPresetFilterBuilder(columns) {
+ const area = document.getElementById('saPresetFilterArea');
+ const container = document.getElementById('saPresetConditions');
+ if (!area || !container) return;
+ area.style.display = 'block';
+ container.innerHTML = '';
+
+ // Restore saved preset filter tree from localStorage
+ const saved = loadPresetTree();
+ if (saved && saved.items && saved.items.length) {
+ restorePresetTree(container, saved, columns);
+ } else {
+ addFilterCondition(container);
+ }
+}
+
+function loadPresetTree() {
+ try {
+ const raw = localStorage.getItem('sa_preset_tree');
+ if (raw) return JSON.parse(raw);
+ } catch(e) {}
+ return null;
+}
+
+function savePresetTree() {
+ const container = document.getElementById('saPresetConditions');
+ if (!container) return;
+ const items = serializeFilterTree(container);
+ const tree = {type: 'group', logic: document.getElementById('saPresetGlobalLogic')?.value || 'AND', items: items};
+ if (items.length) {
+ localStorage.setItem('sa_preset_tree', JSON.stringify(tree));
+ } else {
+ localStorage.removeItem('sa_preset_tree');
+ }
+}
+
+function clearPresetMemory() {
+ localStorage.removeItem('sa_preset_tree');
+ const container = document.getElementById('saPresetConditions');
+ if (container) {
+ container.innerHTML = '';
+ addFilterCondition(container);
+ }
+ showSuccess('已清除预设筛选记忆');
+}
+
+function restorePresetTree(container, tree, columns) {
+ const sortedCols = columns.slice().sort((a, b) => a.localeCompare(b));
+ if (tree.logic && document.getElementById('saPresetGlobalLogic')) {
+ document.getElementById('saPresetGlobalLogic').value = tree.logic;
+ }
+ tree.items.forEach(item => {
+ if (item.type === 'group') {
+ appendPresetGroup(container, item, sortedCols);
+ } else {
+ appendPresetCondition(container, item, sortedCols);
+ }
+ });
+ updatePresetLogicTags();
+}
+
+function appendPresetCondition(container, item, sortedCols) {
+ const idx = Date.now() + '_' + Math.random().toString(36).slice(2, 6);
+ const div = document.createElement('div');
+ div.className = 'sa-filter-row';
+ div.dataset.fidx = idx;
+ div.innerHTML = `
+
WHERE
+
+
+
+
+
()
+
✕
+ `;
+ container.appendChild(div);
+}
+
+function appendPresetGroup(container, group, sortedCols) {
+ const groupEl = document.createElement('div');
+ groupEl.className = 'sa-filter-group';
+ groupEl.innerHTML = `
+
+
+
+
+
+
+
+ `;
+ const inner = groupEl.querySelector('.sa-group-inner');
+ (group.items || []).forEach(item => {
+ if (item.type === 'group') {
+ appendPresetGroup(inner, item, sortedCols);
+ } else {
+ appendPresetCondition(inner, item, sortedCols);
+ }
+ });
+ container.appendChild(groupEl);
+}
+
+function updatePresetLogicTags() {
+ const container = document.getElementById('saPresetConditions');
+ if (!container) return;
+ const topItems = container.querySelectorAll(':scope > .sa-filter-row, :scope > .sa-filter-group');
+ const globalLogic = document.getElementById('saPresetGlobalLogic')?.value || 'AND';
+ topItems.forEach((el, i) => {
+ if (el.classList.contains('sa-filter-row')) {
+ const tag = el.querySelector('.logic-tag');
+ if (tag) {
+ if (i === 0) { tag.textContent = 'WHERE'; tag.className = 'logic-tag'; }
+ else { tag.textContent = globalLogic; tag.className = 'logic-tag ' + (globalLogic === 'OR' ? 'or' : ''); }
+ }
+ }
+ });
+ savePresetTree();
+}
+
+function doUpload() {
+ if (SA._loading) return;
+ if (!_pendingFiles) { showError('请先选择文件'); return; }
+
+ const formData = new FormData();
+ Array.from(_pendingFiles).forEach(f => formData.append('files', f));
+
+ // Serialize and save preset filter tree
+ savePresetTree();
+ const presetItems = serializeFilterTree(document.getElementById('saPresetConditions'));
+ if (presetItems.length) {
+ const tree = {
+ type: 'group',
+ logic: document.getElementById('saPresetGlobalLogic')?.value || 'AND',
+ items: presetItems,
+ };
+ formData.append('preset_filters', JSON.stringify({root: tree}));
+ }
+
+ setBusy(true, '正在上传并解析CSV(含预设筛选)...');
fetch('/simple/upload/', {
method: 'POST',
body: formData,
@@ -902,7 +1040,7 @@ function uploadFiles(files) {
})
.then(r => r.json())
.then(data => {
- hideLoading();
+ setBusy(false);
if (data.error) { showError(data.error); return; }
SA.sessionId = data.session_id;
@@ -910,7 +1048,6 @@ function uploadFiles(files) {
SA.columnsStd = data.columns_std || data.columns || [];
SA.detectedCols = data.detected_columns || {};
- // Store geo stats
const geoStats = data.geo_stats || {};
updateDebug({
status: '已上传',
@@ -921,6 +1058,7 @@ function uploadFiles(files) {
});
setText('saTotalRows', data.total_rows.toLocaleString());
+ setText('saPresetFiltered', data.preset_filtered.toLocaleString());
// Detected columns
const dc = data.detected_columns || {};
@@ -941,18 +1079,18 @@ function uploadFiles(files) {
}
document.getElementById('saUploadResult').style.display = 'block';
- showSuccess(`上传成功!共 ${data.total_rows.toLocaleString()} 条记录`);
+ showSuccess(`上传成功!共 ${data.total_rows.toLocaleString()} 条记录(预设筛选后 ${data.preset_filtered.toLocaleString()} 条)`);
})
- .catch(err => { hideLoading(); showError('上传失败: ' + err.message); });
+ .catch(err => { setBusy(false); showError('上传失败: ' + err.message); });
}
/* ── Step 2: Filter ───────────────────────────────────── */
function renderPreview(cols, rows) {
const hdr = document.getElementById('saPreviewHeader');
- hdr.innerHTML = cols.map(c => `
${escapeHtml(c)} | `).join('');
-
const body = document.getElementById('saPreviewBody');
+ if (!hdr || !body) return;
+ hdr.innerHTML = cols.map(c => `
${escapeHtml(c)} | `).join('');
body.innerHTML = rows.map(row => {
return '
' + cols.map(c => {
const v = row[c];
@@ -962,33 +1100,19 @@ function renderPreview(cols, rows) {
}
/* ── Step 3: Cluster ──────────────────────────────────── */
-function toggleClusterMode() {
- const mode = document.querySelector('input[name="clusterMode"]:checked').value;
- SA.clusterMode = mode;
- document.getElementById('saGeoParams').style.display = mode === 'geo' ? 'block' : 'none';
- document.getElementById('saSubnetParams').style.display = mode === 'subnet' ? 'block' : 'none';
-}
-
function runCluster() {
- const mode = document.querySelector('input[name="clusterMode"]:checked').value;
const minClusterSize = parseInt(document.getElementById('saMinClusterSize').value);
const epsilonMeters = parseInt(document.getElementById('saEpsilon').value);
- // Convert meters to radians for haversine: 1 radian ≈ 6371000m (Earth radius)
const epsilonRad = epsilonMeters / 6371000;
- const minRecords = parseInt(document.getElementById('saMinRecords').value);
- const maxClusterSize = parseInt(document.getElementById('saMaxClusterSize').value);
- showLoading('正在执行聚类分析...');
+ showLoading('正在构建IP通信网络并聚类...');
fetch('/simple/cluster/', {
method: 'POST',
headers: {'Content-Type': 'application/json', 'X-CSRFToken': getCSRF()},
body: JSON.stringify({
session_id: SA.sessionId,
- mode: mode,
min_cluster_size: minClusterSize,
cluster_selection_epsilon: epsilonRad,
- min_records_per_subnet: minRecords,
- max_cluster_size: maxClusterSize,
}),
})
.then(r => r.json())
@@ -996,35 +1120,35 @@ function runCluster() {
hideLoading();
if (data.error) { showError(data.error); return; }
- SA.clusterLabels = data.labels || [];
+ SA.nodes = data.nodes || [];
+ SA.edgesData = data.edges || [];
SA.clusterMap = data.clusters || {};
- SA.dataPoints = data.data_points || {};
+ SA.clusterLabels = data.nodes ? data.nodes.map(n => n.cluster_id) : [];
+ SA.dataPoints = {}; // will be rebuilt from nodes
setText('saNClusters', data.n_clusters);
setText('saNNoise', data.n_noise);
- setText('saNValid', data.n_valid_points || data.total_records || 0);
+ setText('saNValid', data.n_edges || 0);
const crEl = document.getElementById('saClusterResult');
if (crEl) crEl.style.display = 'block';
renderClusterCards(data.clusters);
- const noiseRatio = data.n_valid_points > 0 ? ((data.n_noise / (data.n_valid_points || 1)) * 100).toFixed(1) + '%' : '—';
+ const noiseRatio = data.n_nodes > 0 ? ((data.n_noise / data.n_nodes) * 100).toFixed(1) + '%' : '—';
updateDebug({
status: data.all_noise ? '全噪声' : '聚类完成',
- mode: SA.clusterMode === 'geo' ? '地理位置(HDBSCAN)' : 'IP子网',
+ mode: 'IP网络社区发现',
clusters: String(data.n_clusters),
noise_ratio: noiseRatio,
time: data.elapsed_seconds || '—',
- note: data.note || (data.fallback_suggested ? '噪声偏高,建议调整参数' : ''),
+ note: data.note || '',
});
if (data.all_noise) {
- showError('⚠ ' + (data.note || '未发现有效聚集,所有点为噪声'));
- } else if (data.fallback_suggested) {
- showError('⚠ 聚类效果不佳(噪声>80%或仅1个簇),建议调整参数后重试');
+ showError('⚠ ' + (data.note || '未发现有效社区,所有节点为噪声'));
}
- showSuccess(`聚类完成!${data.n_clusters} 个簇,${data.n_noise} 个噪声点`);
+ showSuccess(`${data.n_nodes} 个IP节点, ${data.n_edges} 条边, ${data.n_clusters} 个社区`);
})
.catch(err => { hideLoading(); showError('聚类失败: ' + err.message); });
}
@@ -1032,6 +1156,7 @@ function runCluster() {
function renderClusterCards(clusters) {
const el = document.getElementById('saClusterCards');
el.innerHTML = '';
+ if (!clusters || !Object.keys(clusters).length) return;
const sorted = Object.values(clusters).sort((a, b) => b.size - a.size);
const colors = generateColors(Object.keys(clusters).length);
@@ -1041,11 +1166,11 @@ function renderClusterCards(clusters) {
const color = c.is_noise ? '#ccc' : (colors[idx] || '#4361ee');
div.style.borderLeft = `4px solid ${color}`;
div.innerHTML = `
- ${c.label === -1 ? '噪声' : '簇 ' + c.label}
- ${c.size.toLocaleString()} 条记录
- ${c.n_unique_ips !== undefined ? `${c.n_unique_ips} 个IP
` : ''}
+ 社区 ${c.label}
+ ${c.size} 个IP
+ 子网: ${c.subnet || '—'}
`;
- if (c.label !== -1) {
+ if (!c.is_noise) {
div.onclick = () => loadClusterDetail(c.label);
}
el.appendChild(div);
@@ -1054,7 +1179,7 @@ function renderClusterCards(clusters) {
/* ── Step 4: Map & Visualization ──────────────────────── */
-// Tile providers in priority order — ArcGIS works in China
+// Tile providers in priority order
const TILE_PROVIDERS = [
{
name: 'ArcGIS',
@@ -1073,8 +1198,81 @@ const TILE_PROVIDERS = [
},
];
+// 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;
+ }
+});
+
function initMap() {
const container = document.getElementById('saMap');
+ if (!container) return;
if (SA.map) {
container.style.height = '550px';
setTimeout(() => {
@@ -1085,13 +1283,12 @@ function initMap() {
}
setTimeout(() => {
container.style.height = '550px';
- // Start with global view [0, 0] zoom 2 as required
SA.map = L.map('saMap', {
center: [0, 0],
zoom: 2,
worldCopyJump: true,
+ preferCanvas: true,
});
- // Try each tile provider in order until one loads
tryTileProvider(0);
setTimeout(() => SA.map.invalidateSize(), 300);
renderMapData();
@@ -1101,13 +1298,24 @@ function initMap() {
function tryTileProvider(idx) {
if (idx >= TILE_PROVIDERS.length || !SA.map) return;
const provider = TILE_PROVIDERS[idx];
- const tileLayer = L.tileLayer(provider.url, {
- attribution: provider.attr,
- maxZoom: 18,
- });
+
+ // 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);
- // Detect if tiles fail to load, try next provider
let tileError = false;
tileLayer.on('tileerror', () => {
if (!tileError) {
@@ -1116,6 +1324,7 @@ function tryTileProvider(idx) {
SA.map.removeLayer(tileLayer);
if (idx + 1 >= TILE_PROVIDERS.length) {
showWarning('离线模式:地图瓦片不可用,数据点仍可显示');
+ L.tileLayer(OFFLINE_TILE, {maxZoom: 18}).addTo(SA.map);
} else {
tryTileProvider(idx + 1);
}
@@ -1131,137 +1340,126 @@ function renderMapData() {
if (SA.pointLayer) SA.map.removeLayer(SA.pointLayer);
if (SA.centerLayer) SA.map.removeLayer(SA.centerLayer);
- const clusters = SA.clusterMap;
- const dataPts = SA.dataPoints || {};
+ const nodes = SA.nodes || [];
+ const clusters = SA.clusterMap || {};
const colors = generateColors(20);
- // Build point clusters and center markers
const allPoints = [];
const centerMarkers = [];
- // Sort clusters by size for consistent coloring
+ // Build color map for clusters
+ const clusterColors = {};
const sortedClusters = Object.values(clusters)
.filter(c => !c.is_noise)
.sort((a, b) => b.size - a.size);
-
- let colorIdx = 0;
-
- // Render each cluster's points
- sortedClusters.forEach(c => {
- const lbl = c.label;
- if (SA.hiddenClusters.has(lbl)) return;
- const color = colors[colorIdx % colors.length];
- colorIdx++;
-
- const pts = dataPts[lbl] || [];
- pts.forEach(p => {
- if (p.lat == null || p.lon == null) return;
- const circle = L.circleMarker([p.lat, p.lon], {
- radius: 5,
- fillColor: color,
- color: '#fff',
- weight: 1,
- opacity: 0.8,
- fillOpacity: 0.7,
- });
- // Richer tooltip with IP attributes from cluster info
- const extra = c.ip_sample ? `
IP: ${c.ip_sample}` : '';
- circle.bindTooltip(
- `
- 簇 ${lbl}${extra}
- 坐标:${p.lat}, ${p.lon}
-
`
- );
- // Click to show cluster detail
- circle.on('click', () => loadClusterDetail(lbl));
- allPoints.push(circle);
- });
-
- // Cluster center marker (pin/star)
- if (c.center_lat != null && c.center_lon != null) {
- const icon = L.divIcon({
- html: `${lbl}
`,
- className: '',
- iconSize: [24, 24],
- iconAnchor: [12, 12],
- });
- const marker = L.marker([c.center_lat, c.center_lon], {icon});
- marker.bindTooltip(
- `
- 簇 ${lbl} 中心
- ${c.size} 条记录 · ${c.n_unique_ips} 个 IP
- 点击查看详情
-
`,
- {direction: 'top'}
- );
- marker.on('click', () => loadClusterDetail(lbl));
- centerMarkers.push(marker);
- }
+ sortedClusters.forEach((c, i) => {
+ clusterColors[c.label] = colors[i % colors.length];
});
- // Render noise points (separate, always gray)
- const noisePts = dataPts[-1] || [];
- if (SA.showNoise) {
- noisePts.forEach(p => {
- if (p.lat == null || p.lon == null) return;
- const circle = L.circleMarker([p.lat, p.lon], {
- radius: 4,
- fillColor: '#aaa',
- color: '#888',
- weight: 1,
- opacity: 0.5,
- fillOpacity: 0.3,
+ // Group nodes by cluster
+ const nodesByCluster = {};
+ nodes.forEach(n => {
+ const cid = n.cluster_id;
+ if (!nodesByCluster[cid]) nodesByCluster[cid] = [];
+ nodesByCluster[cid].push(n);
+ });
+
+ // Render each node as a circle marker
+ Object.entries(nodesByCluster).forEach(([cid, clusterNodes]) => {
+ const cidInt = parseInt(cid);
+ const color = cidInt === -1 ? '#aaa' : (clusterColors[cidInt] || '#4361ee');
+ const hidden = SA.hiddenClusters.has(cidInt);
+
+ clusterNodes.forEach(n => {
+ if (n.lat == null || n.lon == null) return;
+ if (hidden) return;
+ if (cidInt === -1 && !SA.showNoise) return;
+
+ const radius = cidInt === -1 ? 3 : 5;
+ const opacity = cidInt === -1 ? 0.4 : 0.7;
+ const circle = L.circleMarker([n.lat, n.lon], {
+ radius: radius,
+ fillColor: color,
+ color: '#fff',
+ weight: 0.5,
+ opacity: 0.8,
+ fillOpacity: opacity,
});
+ // Rich tooltip
+ const serverLabel = n.is_server ? ' 🖥' : '';
circle.bindTooltip(
`
- 噪声点
- 坐标:${p.lat}, ${p.lon}
+ ${escapeHtml(n.ip)}${serverLabel}
+ ${n.city ? `城市:${escapeHtml(n.city)}
` : ''}
+ ${n.isp ? `ISP:${escapeHtml(n.isp)}
` : ''}
+ 社区:${cidInt === -1 ? '噪声' : cidInt}
+ 点击查看详情
`
);
+ circle.on('click', () => loadNodeDetail(n.ip));
allPoints.push(circle);
});
- }
+
+ // Cluster center marker
+ if (cidInt !== -1) {
+ const cinfo = clusters[cidInt] || clusters[String(cidInt)] || {};
+ if (cinfo.center_lat != null && cinfo.center_lon != null) {
+ const icon = L.divIcon({
+ html: `${cidInt}
`,
+ className: '',
+ iconSize: [22, 22],
+ iconAnchor: [11, 11],
+ });
+ const marker = L.marker([cinfo.center_lat, cinfo.center_lon], {icon});
+ marker.bindTooltip(
+ `
+ 社区 ${cidInt} 中心
+ ${cinfo.size} 个IP · ${cinfo.subnet || ''}
+ 点击查看详情
+
`,
+ {direction: 'top'}
+ );
+ marker.on('click', () => loadClusterDetail(cidInt));
+ centerMarkers.push(marker);
+ }
+ }
+ });
SA.pointLayer = L.layerGroup(allPoints).addTo(SA.map);
SA.centerLayer = L.layerGroup(centerMarkers).addTo(SA.map);
- // Fit bounds to show all points
+ // Fit bounds
const boundsPoints = [];
- allPoints.forEach(p => {
- const ll = p.getLatLng();
- if (ll) boundsPoints.push(ll);
- });
- if (centerMarkers.length) {
- centerMarkers.forEach(m => boundsPoints.push(m.getLatLng()));
- }
+ 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) {
- // Fallback: keep global view
SA.map.setView([0, 0], 2);
}
}
- // Legend
renderLegend(colors);
renderCharts();
+ renderEdgesOnMap();
}
function renderLegend(colors) {
const el = document.getElementById('saLegendItems');
- const sorted = Object.values(SA.clusterMap)
+ const clusters = SA.clusterMap || {};
+ const sorted = Object.values(clusters)
.filter(c => !c.is_noise)
.sort((a, b) => b.size - a.size);
const html = [];
// Noise toggle
- const noisePts = (SA.dataPoints[-1] || []).length;
+ const noiseCount = (SA.nodes || []).filter(n => n.cluster_id === -1).length;
html.push(`
- 噪声点 (${noisePts})
+ 噪声节点 (${noiseCount})
`);
sorted.forEach((c, idx) => {
@@ -1270,8 +1468,8 @@ function renderLegend(colors) {
html.push(`
- 簇 ${c.label}
- ${c.size}
+ 社区 ${c.label}
+ ${c.size} IP
`);
});
el.innerHTML = html.join('');
@@ -1290,20 +1488,23 @@ function toggleCluster(label) {
/* ── Charts ───────────────────────────────────────────── */
function renderCharts() {
- const sorted = Object.values(SA.clusterMap).filter(c => !c.is_noise).sort((a, b) => b.size - a.size);
- const labels = sorted.map(c => '簇 ' + c.label);
+ const clusters = SA.clusterMap || {};
+ const sorted = Object.values(clusters).filter(c => !c.is_noise).sort((a, b) => b.size - a.size);
+ const labels = sorted.map(c => '社区 ' + c.label);
const sizes = sorted.map(c => c.size);
const colors = generateColors(sorted.length);
// Bar chart
- const barCtx = document.getElementById('saBarChart').getContext('2d');
+ const barCtx = document.getElementById('saBarChart');
+ if (!barCtx) return;
+ const barCtx2d = barCtx.getContext('2d');
if (SA.barChart) SA.barChart.destroy();
- SA.barChart = new Chart(barCtx, {
+ SA.barChart = new Chart(barCtx2d, {
type: 'bar',
data: {
labels: labels,
datasets: [{
- label: '记录数',
+ label: 'IP数',
data: sizes,
backgroundColor: colors,
borderRadius: 4,
@@ -1312,16 +1513,16 @@ function renderCharts() {
options: {
responsive: true,
plugins: { legend: { display: false } },
- scales: {
- y: { beginAtZero: true, ticks: { precision: 0 } }
- }
+ scales: { y: { beginAtZero: true, ticks: { precision: 0 } } }
}
});
- // Pie chart (traffic proportion, use record count as proxy)
- const pieCtx = document.getElementById('saPieChart').getContext('2d');
+ // Pie chart
+ const pieCtx = document.getElementById('saPieChart');
+ if (!pieCtx) return;
+ const pieCtx2d = pieCtx.getContext('2d');
if (SA.pieChart) SA.pieChart.destroy();
- SA.pieChart = new Chart(pieCtx, {
+ SA.pieChart = new Chart(pieCtx2d, {
type: 'pie',
data: {
labels: labels,
@@ -1341,85 +1542,156 @@ function renderCharts() {
});
}
-/* ── Cluster Detail ───────────────────────────────────── */
-function loadClusterDetail(label) {
- showLoading('加载簇详情...');
- fetch(`/simple/cluster/${label}/?session_id=${SA.sessionId}`, {
+/* ── Node Detail ─────────────────────────────────────── */
+function loadNodeDetail(ip) {
+ showLoading('加载节点详情...');
+ fetch(`/simple/node/?session_id=${SA.sessionId}&ip=${encodeURIComponent(ip)}`, {
headers: {'X-CSRFToken': getCSRF()},
})
.then(r => r.json())
.then(data => {
hideLoading();
if (data.error) { showError(data.error); return; }
- showDetailPanel(data);
+ showNodeDetailPanel(data);
})
.catch(err => { hideLoading(); showError('加载失败: ' + err.message); });
}
-function showDetailPanel(data) {
+function showNodeDetailPanel(data) {
const panel = document.getElementById('saDetailPanel');
const body = document.getElementById('saDetailBody');
+ const n = data.node || {};
+ const edges = data.related_edges || [];
- const ci = data.cluster_info || {};
- const traffic = data.traffic || {};
-
- let ipHtml = '';
- if (data.unique_ips && data.unique_ips.length) {
- const shown = data.unique_ips.slice(0, 20);
- const remainder = data.unique_ips.slice(20);
- ipHtml = `源 IP 地址(${data.unique_ips_count} 个)
- ${shown.map(ip => `${escapeHtml(ip)}`).join('')}
`;
- if (remainder.length) {
- ipHtml += `
- 展开其余 ${remainder.length} 个...
- ${remainder.map(ip => `${escapeHtml(ip)}`).join('')}
`;
- }
+ let edgeHtml = '';
+ if (edges.length) {
+ edgeHtml = `关联边 (${data.n_edges} 条)
+ `;
+ edges.slice(0, 20).forEach(e => {
+ const peer = e.source === n.ip ? e.target : e.source;
+ const dir = e.source === n.ip ? '→' : '←';
+ edgeHtml += `
+ ${dir} ${escapeHtml(peer)}
+ 通信 ${e.comm_count} 次
+ ${e.total_bytes ? ' · ' + (e.total_bytes/1024).toFixed(1) + ' KB' : ''}
+
`;
+ });
+ if (edges.length > 20) edgeHtml += `
... 还有 ${edges.length - 20} 条
`;
+ edgeHtml += '
';
}
- const topSec = (title, items) => {
- if (!items || !items.length) return '';
- return `${title}
- ${items.map(i => `${escapeHtml(i.value)} (${i.pct}%)`).join('')}
`;
- };
-
body.innerHTML = `
-
-
${data.unique_ips_count}
唯一 IP
- ${ci.center_lat ? `
${ci.center_lat}, ${ci.center_lon}
中心坐标
` : ''}
+
+
${n.cluster_id === -1 ? '噪声' : '社区 ' + n.cluster_id}
所属社区
- ${traffic.total_bytes !== undefined ? `
-
${(traffic.total_bytes/1024).toFixed(1)} KB
总流量
- ${traffic.avg_duration !== undefined ? `
${traffic.avg_duration.toFixed(2)}s
平均时长
` : ''}
-
` : ''}
- ${ipHtml}
- ${topSec('高频目的 IP', data.top_dst_ips)}
- ${topSec('主要 TLS 版本', data.top_tls_versions)}
- ${topSec('主要加密套件', data.top_ciphers)}
- ${topSec('主要域名/SNI', data.top_sni)}
- ${topSec('协议分布', data.top_protocols)}
- ${topSec('目的端口', data.top_dst_ports)}
- ${data.geo_bounds ? `
- 地理范围
-
- 纬度: ${data.geo_bounds.lat_min} ~ ${data.geo_bounds.lat_max}
- 经度: ${data.geo_bounds.lon_min} ~ ${data.geo_bounds.lon_max}
-
` : ''}
+ ${n.city ? `` : ''}
+ ${n.country ? `${escapeHtml(n.country)}
国家
` : ''}
+ ${n.isp ? `` : ''}
+
+
+
${n.is_server ? '是' : '否'}
服务器
+
${data.n_peers || 0}
对端IP数
+
${data.total_flow_bytes ? (data.total_flow_bytes/1024).toFixed(1) + ' KB' : '—'}
总流量
+
+ ${edgeHtml}
+
+ 坐标: ${n.lat != null ? n.lat + ', ' + n.lon : '未知'}
+
`;
- setText('saDetailTitle', `簇 ${data.label} 画像`);
+ setText('saDetailTitle', `节点: ${n.ip}`);
panel.classList.add('open');
}
-function closeDetail() {
- document.getElementById('saDetailPanel').classList.remove('open');
+/* ── Edge rendering on map ────────────────────────────── */
+let _edgeLayer = null;
+
+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;
+
+ 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];
+ }
+ });
+
+ const maxCount = Math.max(...SA.edgesData.map(e => e.comm_count || 1), 1);
+ const edgePolys = [];
+
+ SA.edgesData.slice(0, 200).forEach(e => {
+ const srcCoord = ipCoord[e.source];
+ const dstCoord = ipCoord[e.target];
+ if (!srcCoord || !dstCoord) return;
+
+ 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,
+ });
+ const firstSeen = e.first_seen || '';
+ const lastSeen = e.last_seen || '';
+ const peakHours = e.peak_hours || '';
+ poly.bindTooltip(
+ `
+ ${escapeHtml(e.source)} → ${escapeHtml(e.target)}
+ 通信: ${e.comm_count} 次
+ ${e.total_bytes ? '流量: ' + (e.total_bytes/1024).toFixed(1) + ' KB
' : ''}
+ ${firstSeen ? '首次: ' + firstSeen + '
' : ''}
+ ${lastSeen ? '末次: ' + lastSeen + '
' : ''}
+ ${peakHours ? '活跃时段: ' + peakHours + 'h' : ''}
+
`
+ );
+ poly.on('click', () => loadEdgeDetail(e));
+ edgePolys.push(poly);
+ });
+
+ if (edgePolys.length) {
+ _edgeLayer = L.layerGroup(edgePolys).addTo(SA.map);
+ }
+}
+
+function loadEdgeDetail(e) {
+ const panel = document.getElementById('saDetailPanel');
+ const body = document.getElementById('saDetailBody');
+ setText('saDetailTitle', `边: ${e.source} → ${e.target}`);
+
+ body.innerHTML = `
+
+
+
${e.total_bytes ? (e.total_bytes/1024).toFixed(1) + ' KB' : '—'}
总流量
+
+
+
${e.first_seen || '—'}
首次通信 (UTC)
+
${e.last_seen || '—'}
末次通信 (UTC)
+
+ ${e.peak_hours ? `${e.peak_hours} 时
活跃时段 (UTC)
` : ''}
+ 源 IP
+ ${escapeHtml(e.source)}
+ 目的 IP
+ ${escapeHtml(e.target)}
+ `;
+ panel.classList.add('open');
}
-/* ── Edge relationships ────────────────────────────────── */
function loadEdges() {
if (SA._loading || !SA.sessionId) return;
- const minFreq = parseInt(document.getElementById('saEdgeFreq').value);
+ // 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',
@@ -1431,24 +1703,16 @@ function loadEdges() {
setBusy(false);
if (data.error) { showError(data.error); return; }
SA.edgesData = data.edges || [];
- document.getElementById('saEdgeCount').textContent = `${SA.edgesData.length} 条边 (共 ${data.total_pairs || 0} 对)`;
- if (document.getElementById('saShowEdges').checked) {
- renderEdges();
- }
+ document.getElementById('saEdgeCount').textContent = `${SA.edgesData.length} 条边`;
+ renderEdgesOnMap();
showSuccess(`加载 ${SA.edgesData.length} 条通联边`);
})
.catch(err => { setBusy(false); showError('边加载失败: ' + err.message); });
}
-let _edgeLayer = null;
-
function toggleEdges() {
if (document.getElementById('saShowEdges').checked) {
- if (SA.edgesData && SA.edgesData.length) {
- renderEdges();
- } else {
- loadEdges();
- }
+ renderEdgesOnMap();
} else {
if (_edgeLayer && SA.map) {
SA.map.removeLayer(_edgeLayer);
@@ -1457,61 +1721,39 @@ function toggleEdges() {
}
}
-function renderEdges() {
- if (!SA.map || !SA.edgesData || !SA.edgesData.length) return;
- if (_edgeLayer) SA.map.removeLayer(_edgeLayer);
-
- const maxCount = Math.max(...SA.edgesData.map(e => e.count), 1);
- const lines = SA.edgesData.map(e => {
- // Try to find source/target coords from data points
- let lat1, lon1, lat2, lon2;
- // Search in data points for matching IP indices
- for (const cluster of Object.values(SA.dataPoints)) {
- for (const pt of cluster) {
- if (pt.idx !== undefined && pt.idx < SA.clusterLabels.length) {
- // We don't have direct IP→coords mapping, use geo_cache approximation
- }
- }
- }
- // If we can't get coordinates, skip
- return null;
- }).filter(Boolean);
-
- // Simple approach: use cluster centers as node positions
- const edgePolys = [];
- SA.edgesData.slice(0, 100).forEach(e => {
- // Use first found source and target coordinates
- let found = 0;
- const srcCoord = [], dstCoord = [];
- for (const [lbl, pts] of Object.entries(SA.dataPoints)) {
- for (const pt of pts) {
- if (pt.idx !== undefined && pt.lat != null) {
- // This is a simplified representation
- if (found < 2) {
- if (!srcCoord.length) { srcCoord.push(pt.lat, pt.lon); found++; }
- else if (!dstCoord.length) { dstCoord.push(pt.lat, pt.lon); found++; }
- }
- }
- if (found >= 2) break;
- }
- if (found >= 2) break;
- }
- if (srcCoord.length === 2 && dstCoord.length === 2) {
- const weight = Math.min(e.count / maxCount * 3 + 0.5, 4);
- const poly = L.polyline([srcCoord, dstCoord], {
- color: '#ff6b6b',
- weight: weight,
- opacity: 0.4,
- dashArray: null,
- });
- poly.bindTooltip(`${e.source} → ${e.target}
${e.count} 次通联${e.time_count ? `, ${e.time_count} 时间点` : ''}`);
- edgePolys.push(poly);
- }
+/* ── Export ────────────────────────────────────────────── */
+function exportClusterCSV() {
+ // Export nodes CSV
+ const nodes = SA.nodes || [];
+ if (!nodes.length) { showError('无数据可导出'); return; }
+ const headers = ['ip', 'cluster_id', 'lat', 'lon', 'city', 'country', 'isp', 'is_server'];
+ 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(','));
});
+ downloadCSV(rows.join('\n'), `nodes_${SA.sessionId}.csv`);
+}
- if (edgePolys.length) {
- _edgeLayer = L.layerGroup(edgePolys).addTo(SA.map);
- }
+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 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(','));
+ });
+ downloadCSV(rows.join('\n'), `edges_${SA.sessionId}.csv`);
+}
+
+function downloadCSV(csv, filename) {
+ const blob = new Blob(['' + csv], {type: 'text/csv;charset=utf-8;'});
+ const a = document.createElement('a');
+ a.href = URL.createObjectURL(blob);
+ a.download = filename;
+ a.click();
+ URL.revokeObjectURL(a.href);
+ showSuccess('导出成功: ' + filename);
}
/* ── Page init ─────────────────────────────────────────── */
@@ -1519,51 +1761,32 @@ function renderEdges() {
restorePreset();
})();
-/* ── Export ────────────────────────────────────────────── */
-function exportClusterCSV() {
- showLoading('导出中...');
- // Build CSV from cluster labels in memory — for simplicity, reload from server
- const payload = {
- session_id: SA.sessionId,
- labels: SA.clusterLabels,
- };
- // We'll export by returning a simple CSV via the cluster detail endpoint logic
- // For now, trigger download via blob
- try {
- // Gather label-count summary
- const rows = [['cluster_label', 'count']];
- const counts = {};
- SA.clusterLabels.forEach(l => { counts[l] = (counts[l] || 0) + 1; });
- Object.entries(counts).forEach(([k, v]) => rows.push([k, v]));
- const csv = rows.map(r => r.join(',')).join('\n');
- const blob = new Blob(['' + csv], {type: 'text/csv;charset=utf-8;'});
- const a = document.createElement('a');
- a.href = URL.createObjectURL(blob);
- a.download = `cluster_summary_${SA.sessionId}.csv`;
- a.click();
- URL.revokeObjectURL(a.href);
- showSuccess('导出成功');
- } catch(e) {
- showError('导出失败: ' + e.message);
- }
- hideLoading();
+/* ── Close detail ───────────────────────────────────────── */
+function closeDetail() {
+ document.getElementById('saDetailPanel').classList.remove('open');
}
/* ── Reset ─────────────────────────────────────────────── */
function resetAll() {
SA.sessionId = null;
+ SA.nodes = [];
+ SA.edgesData = null;
SA.clusterLabels = [];
SA.clusterMap = {};
SA.dataPoints = {};
SA.hiddenClusters = new Set();
SA.showNoise = true;
+ _pendingFiles = null;
+ if (_edgeLayer && SA.map) { SA.map.removeLayer(_edgeLayer); _edgeLayer = null; }
document.getElementById('saFileList').innerHTML = '';
document.getElementById('saUploadResult').style.display = 'none';
+ document.getElementById('saPresetFilterArea').style.display = 'none';
document.getElementById('saClusterResult').style.display = 'none';
document.getElementById('saPreviewWrap').querySelector('table').innerHTML = '';
if (SA.map) { SA.map.remove(); SA.map = null; }
if (SA.barChart) { SA.barChart.destroy(); SA.barChart = null; }
if (SA.pieChart) { SA.pieChart.destroy(); SA.pieChart = null; }
+ updateDebug({status: '等待上传'});
goStep(1);
}
diff --git a/simple_analysis/urls.py b/simple_analysis/urls.py
index 2e27623..9c5bad0 100644
--- a/simple_analysis/urls.py
+++ b/simple_analysis/urls.py
@@ -4,11 +4,13 @@ from . import views
app_name = 'simple_analysis'
urlpatterns = [
path('', views.index, name='index'),
+ path('quick-scan/', views.quick_scan, name='quick_scan'),
path('upload/', views.upload_csv, name='upload'),
path('filter/', views.filter_data, name='filter'),
path('filter/advanced/', views.advanced_filter, name='advanced_filter'),
path('column-values/', views.column_values, name='column_values'),
path('cluster/', views.run_clustering, name='cluster'),
path('cluster//', views.cluster_detail, name='cluster_detail'),
+ path('node/', views.node_detail, name='node_detail'),
path('edges/', views.edge_relationships, name='edges'),
]
diff --git a/simple_analysis/views.py b/simple_analysis/views.py
index 1ad2998..e11d959 100644
--- a/simple_analysis/views.py
+++ b/simple_analysis/views.py
@@ -202,6 +202,60 @@ def batch_geoip_resolve(ip_list: list[str]) -> dict[str, dict]:
return cache
+# ═══════════════════════════════════════════════════════════════════════════════
+# Step 0: Quick column scan (header-only, no data load)
+# ═══════════════════════════════════════════════════════════════════════════════
+
+@csrf_exempt
+@require_POST
+def quick_scan(request):
+ """仅读取 CSV 表头,返回列名(用于上传前配置预设筛选器)。
+
+ 接收上传文件,只扫描第一行(header),立即返回列名列表。
+ 文件保存在临时目录,后续调用 upload_csv 时会覆盖/重用。
+ """
+ files = request.FILES.getlist('files')
+ if not files:
+ return JsonResponse({'error': '未上传任何文件'}, status=400)
+
+ scan_id = _new_id()
+ scan_dir = _TEMP_DIR / scan_id
+ scan_dir.mkdir(parents=True, exist_ok=True)
+
+ saved_paths = []
+ for f in files:
+ dest = scan_dir / f.name
+ with open(dest, 'wb') as out:
+ for chunk in f.chunks():
+ out.write(chunk)
+ saved_paths.append(str(dest))
+
+ try:
+ # Read header only (n_rows=0 to read just the schema)
+ lf = pl.scan_csv(saved_paths[0], infer_schema_length=0)
+ if len(saved_paths) > 1:
+ frames = [pl.scan_csv(p, infer_schema_length=0) for p in saved_paths]
+ lf = pl.concat(frames, how='diagonal_relaxed')
+ # Read only 1 row to get columns
+ df = lf.head(1).collect()
+ except Exception as e:
+ shutil.rmtree(scan_dir, ignore_errors=True)
+ return JsonResponse({'error': f'CSV 表头读取失败: {e}'}, status=400)
+
+ # Normalize column names
+ df_norm, col_map = normalize_columns(df)
+
+ # Cleanup scan dir — upload_csv will re-save
+ shutil.rmtree(scan_dir, ignore_errors=True)
+
+ return JsonResponse({
+ 'scan_id': scan_id,
+ 'columns': list(df_norm.columns),
+ 'columns_std': list(df_norm.columns),
+ 'col_map': {orig: std for orig, std in col_map.items() if orig != std},
+ })
+
+
# ═══════════════════════════════════════════════════════════════════════════════
# Step 1: Upload + 预设筛选 + GeoIP 解析
# ═══════════════════════════════════════════════════════════════════════════════
@@ -209,7 +263,7 @@ def batch_geoip_resolve(ip_list: list[str]) -> dict[str, dict]:
@csrf_exempt
@require_POST
def upload_csv(request):
- """上传 CSV,列归一化,预设筛选,GeoIP 解析,返回摘要。"""
+ """上传 CSV,列归一化,预设筛选(支持树状高级筛选),GeoIP 解析,返回摘要。"""
files = request.FILES.getlist('files')
if not files:
return JsonResponse({'error': '未上传任何文件'}, status=400)
@@ -226,12 +280,27 @@ def upload_csv(request):
out.write(chunk)
saved_paths.append(str(dest))
- # 解析预设筛选条件(来自 POST 数据或查询参数)
- preset_filters = {}
+ # 解析预设筛选条件(支持树状结构 + 旧格式兼容)
+ preset_root = None
try:
- pf = request.POST.get('preset_filters', request.GET.get('preset_filters', '{}'))
- if pf:
- preset_filters = json.loads(pf)
+ pf_raw = request.POST.get('preset_filters', request.GET.get('preset_filters', '{}'))
+ if pf_raw:
+ pf = json.loads(pf_raw)
+ # 新格式:树状 root
+ if 'root' in pf:
+ preset_root = pf['root']
+ # 旧格式兼容:{cnrs: "+", ...}
+ elif any(k in pf for k in ('cnrs', 'isrs', 'cnam', 'snam')):
+ # Convert legacy to tree
+ items = []
+ for key in ('cnrs', 'isrs', 'cnam', 'snam'):
+ if key in pf and pf[key]:
+ if key in ('cnrs', 'isrs'):
+ items.append({'type': 'condition', 'col': key, 'op': 'eq', 'val': pf[key]})
+ else:
+ items.append({'type': 'condition', 'col': key, 'op': 'contains', 'val': pf[key]})
+ if items:
+ preset_root = {'type': 'group', 'logic': 'AND', 'items': items}
except (json.JSONDecodeError, Exception):
pass
@@ -255,8 +324,6 @@ def upload_csv(request):
total_rows = len(df)
# 获取各关键列的 标准内部名(即重命名后的列名)
- # 注意:col_map 是 {原始名→标准内部名},标准名即为 df 当前使用的列名
- standard_cols = set(col_map.values())
def _get_std(*names):
"""从 col_map 中查找第一个匹配的标准名。"""
for n in names:
@@ -284,35 +351,16 @@ def upload_csv(request):
scnt_col = _get_std('scnt')
text_col = _get_std('text')
- # ── 预设筛选 ────────────────────────────────────────────────────────────
+ # ── 预设筛选(树状高级筛选) ──────────────────────────────────────────
preset_filtered = total_rows
- try:
- cond = None
- if preset_filters.get('cnrs') in ('+', '-'):
- if cnrs_col:
- c = (pl.col(cnrs_col).cast(pl.Utf8).str.strip_chars() == '+')
- if preset_filters['cnrs'] == '-':
- c = ~c
- cond = c if cond is None else (cond & c)
- if preset_filters.get('isrs') in ('+', '-'):
- if isrs_col:
- c = (pl.col(isrs_col).cast(pl.Utf8).str.strip_chars() == '+')
- if preset_filters['isrs'] == '-':
- c = ~c
- cond = c if cond is None else (cond & c)
- if preset_filters.get('cnam'):
- if cnam_col:
- c = (pl.col(cnam_col).cast(pl.Utf8) == preset_filters['cnam'])
- cond = c if cond is None else (cond & c)
- if preset_filters.get('snam'):
- if snam_col:
- c = (pl.col(snam_col).cast(pl.Utf8) == preset_filters['snam'])
- cond = c if cond is None else (cond & c)
+ if preset_root is not None:
+ try:
+ cond = _build_tree_condition(preset_root, df)
if cond is not None:
df = df.filter(cond)
preset_filtered = len(df)
- except Exception as e:
- logger.warning('Preset filter error: %s', e)
+ except Exception as e:
+ logger.warning('Preset filter error: %s', e)
# ── GeoIP 批量解析(使用标准化列名) ────────────────────────────────────
geo_cache: dict[str, dict] = {}
@@ -627,20 +675,21 @@ def filter_data(request):
# ═══════════════════════════════════════════════════════════════════════════════
-# Step 3: 聚类(GeoIP 驱动 + 性能优化 + 全噪声容错)
+# Step 3: 聚类 — IP 通信网络社区发现
# ═══════════════════════════════════════════════════════════════════════════════
@csrf_exempt
@require_POST
def run_clustering(request):
- """执行聚类分析:IP 子网模式或 GeoIP 地理位置模式。"""
+ """IP 通信网络社区发现聚类。
+
+ 构建 IP 通信图(节点=唯一IP,边=聚合通信记录),
+ 按 /24 子网前缀粗分组,子网内用 HDBSCAN (haversine) 二次聚类。
+ """
body = json.loads(request.body)
session_id = body.get('session_id')
- mode = body.get('mode', 'geo')
min_cluster_size = int(body.get('min_cluster_size', 5))
cluster_selection_epsilon = float(body.get('cluster_selection_epsilon', 0.05))
- min_records_per_subnet = int(body.get('min_records_per_subnet', 3))
- max_cluster_size = int(body.get('max_cluster_size', 100))
entry = _cached(session_id)
if entry is None:
@@ -651,16 +700,9 @@ def run_clustering(request):
return JsonResponse({'error': '请先执行筛选'}, status=400)
meta = entry['meta']
- ips_col = meta.get('ips_col')
-
t_start = time.time()
- clustered = False
- result_data = {}
- if mode == 'geo':
- clustered, result_data = _cluster_geo(entry, filtered, meta, min_cluster_size, cluster_selection_epsilon)
- else:
- clustered, result_data = _cluster_subnet(entry, filtered, meta, min_records_per_subnet, max_cluster_size)
+ result_data = _cluster_network(entry, filtered, meta, min_cluster_size, cluster_selection_epsilon)
elapsed = round(time.time() - t_start, 2)
result_data['elapsed_seconds'] = elapsed
@@ -668,432 +710,338 @@ def run_clustering(request):
return JsonResponse(result_data)
-def _get_geo_coords(entry: dict, df: pl.DataFrame, meta: dict) -> tuple:
- """从 GeoIP 缓存中提取每条记录的经纬度,返回 (lat_float32, lon_float32, valid_mask)。"""
- ips_col = meta.get('ips_col')
- geo_cache = entry.get('geo_cache', {})
+def _cluster_network(entry: dict, df: pl.DataFrame, meta: dict,
+ min_cluster_size: int, cluster_selection_epsilon: float) -> dict:
+ """构建 IP 通信网络并进行社区发现聚类。
- if not ips_col or ips_col not in df.columns:
- return np.array([], dtype=np.float32), np.array([], dtype=np.float32), np.array([], dtype=bool)
-
- ip_series = df[ips_col].cast(pl.Utf8).to_list()
- n = len(ip_series)
- lats = np.full(n, np.nan, dtype=np.float32)
- lons = np.full(n, np.nan, dtype=np.float32)
- valid = np.zeros(n, dtype=bool)
-
- for i, ip in enumerate(ip_series):
- if ip and ip not in ('None', '', '+', '-'):
- info = geo_cache.get(ip) or geoip_lookup(ip)
- if info and 'lat' in info and 'lon' in info:
- lats[i] = np.float32(info['lat'])
- lons[i] = np.float32(info['lon'])
- valid[i] = True
-
- return lats, lons, valid
-
-
-def _cluster_geo(entry: dict, df: pl.DataFrame, meta: dict,
- min_cluster_size: int, cluster_selection_epsilon: float = 0.01):
- """使用 GeoIP 经纬度进行 HDBSCAN 聚类,含性能优化和全噪声容错。"""
-
- # 从 GeoIP 获取经纬度,不再依赖 CSV 列
- lats, lons, valid_mask = _get_geo_coords(entry, df, meta)
- n_valid = int(valid_mask.sum())
- total = len(df)
-
- if n_valid < 3:
- # 全噪声或数据不足 → 返回全 -1 标签,仍可可视化
- labels = np.full(total, -1, dtype=int)
- _store_cluster_results(entry, df, labels, meta, {
- 'note': 'GeoIP 解析不足,所有点标记为噪声',
- })
- return True, {
- 'n_clusters': 0, 'n_noise': total, 'n_valid_points': n_valid,
- 'clusters': {}, 'labels': labels.tolist(), 'data_points': {},
- 'all_noise': True, 'note': 'GeoIP 解析不足(<3个有效坐标),全噪声',
- }
-
- lat_vals = lats[valid_mask].astype(np.float32)
- lon_vals = lons[valid_mask].astype(np.float32)
-
- # ── 性能优化:大数据降采样 ────────────────────────────────────────────
- downsample_used = False
- original_n = n_valid
- if n_valid > 50000:
- # 超过 5 万点,随机抽取 30% 或使用 MiniBatch 方式
- rng = np.random.RandomState(42)
- subset_idx = rng.choice(n_valid, size=max(15000, n_valid // 3), replace=False)
- lat_vals = lat_vals[subset_idx]
- lon_vals = lon_vals[subset_idx]
- downsample_used = True
-
- # 弧度转换(float32 节省内存)
- coords = np.column_stack([np.radians(lat_vals), np.radians(lon_vals)]).astype(np.float32)
-
- # 为重复坐标添加微小抖动(HDBSCAN 在距离为 0 的点上聚类效果差)
- if len(coords) > 10:
- _, unique_idx = np.unique(coords, axis=0, return_index=True)
- if len(unique_idx) < len(coords) * 0.8:
- jitter = np.random.RandomState(42).normal(0, 1e-6, coords.shape).astype(np.float32)
- coords = coords + jitter
-
- # ── HDBSCAN 聚类 ───────────────────────────────────────────────────────
- all_noise = False
- fallback_used = False
- n_clusters = 0
- n_noise = 0
-
- try:
- clusterer = HDBSCAN(
- min_cluster_size=min_cluster_size,
- min_samples=min(3, min_cluster_size),
- metric='haversine',
- cluster_selection_epsilon=cluster_selection_epsilon,
- # core_dist_n_jobs removed for sklearn 1.5.2 compatibility
- )
- raw_labels = clusterer.fit_predict(coords)
- n_clusters = int(len(set(raw_labels)) - (1 if -1 in raw_labels else 0))
- n_noise = int((raw_labels == -1).sum())
-
- noise_ratio = n_noise / len(raw_labels) if len(raw_labels) > 0 else 1.0
-
- # 全噪声或仅 1 簇 → 回退 DBSCAN
- if noise_ratio > 0.95 or n_clusters <= 0:
- logger.info('HDBSCAN produced %d clusters, %d noise — falling back to DBSCAN', n_clusters, n_noise)
- db = DBSCAN(eps=cluster_selection_epsilon, min_samples=min(3, min_cluster_size), metric='haversine')
- raw_labels = db.fit_predict(coords)
- n_clusters = int(len(set(raw_labels)) - (1 if -1 in raw_labels else 0))
- n_noise = int((raw_labels == -1).sum())
- fallback_used = True
-
- if n_clusters <= 0:
- all_noise = True
-
- except Exception as e:
- logger.warning('Clustering failed: %s — all noise fallback', e)
- raw_labels = np.full(len(coords), -1, dtype=int)
- all_noise = True
-
- # 将子集标签映射回全量数据
- labels = np.full(total, -1, dtype=int)
- valid_indices = np.where(valid_mask)[0]
- if downsample_used:
- # 降采样时,给被选中的记录分配标签,其余噪声
- for i, idx in enumerate(subset_idx):
- labels[valid_indices[idx]] = int(raw_labels[i])
- else:
- for i, idx in enumerate(valid_indices):
- labels[idx] = int(raw_labels[i])
-
- result_info = {
- 'all_noise': all_noise,
- 'note': '',
- 'fallback_used': fallback_used,
- 'downsample_used': downsample_used,
- }
- if all_noise:
- result_info['note'] = '未发现有效聚集,所有点为噪声'
- elif fallback_used:
- result_info['note'] = 'HDBSCAN 效果不佳,已回退 DBSCAN'
-
- _store_cluster_results(entry, df, labels, meta, result_info)
- return True, _build_cluster_response(entry, df, labels, meta, result_info, lats, lons, valid_mask)
-
-
-def _store_cluster_results(entry: dict, df: pl.DataFrame, labels: np.ndarray,
- meta: dict, info: dict) -> None:
- """存储聚类结果到缓存。"""
- n_noise = int((labels == -1).sum())
- n_clusters = int(len(set(labels)) - (1 if -1 in labels else 0))
- entry['cluster_labels'] = labels.tolist()
- entry['cluster_mode'] = 'geo'
- entry['n_clusters'] = n_clusters
- entry['n_noise'] = n_noise
- entry['cluster_info'] = info
-
- df_with_labels = df.with_columns(pl.Series('_cluster_label', labels))
- entry['filtered_df'] = df_with_labels
-
-
-def _build_cluster_response(entry: dict, df: pl.DataFrame, labels: np.ndarray,
- meta: dict, info: dict,
- lats: np.ndarray | None = None, lons: np.ndarray | None = None,
- valid_mask: np.ndarray | None = None) -> dict:
- """构建聚类响应,包含簇概览和数据点坐标。"""
- ips_col = meta.get('ips_col')
- unique_labels = sorted(set(labels))
- cluster_map = {}
- data_points_by_cluster = {}
- total = len(df)
-
- for lbl in unique_labels:
- mask = labels == lbl
- count = int(mask.sum())
- is_noise = bool(lbl == -1)
-
- # 中心坐标
- center_lat = center_lon = None
- if lats is not None and lons is not None and valid_mask is not None:
- # 获取该簇中有效坐标的索引
- valid_indices = np.where(valid_mask)[0]
- cluster_valid_mask = mask[valid_mask] if len(valid_indices) == total else mask[valid_indices]
- if cluster_valid_mask.any():
- clats = lats[valid_mask][cluster_valid_mask[:len(lats[valid_mask])]]
- clons = lons[valid_mask][cluster_valid_mask[:len(lons[valid_mask])]]
- if len(clats) > 0:
- center_lat = round(float(np.median(clats)), 4)
- center_lon = round(float(np.median(clons)), 4)
-
- # 唯一 IP 数
- n_unique_ips = 0
- if ips_col and ips_col in df.columns:
- try:
- ips = df.filter(pl.Series(mask))[ips_col].cast(pl.Utf8).unique().to_list()
- n_unique_ips = len(ips)
- except Exception:
- pass
-
- cluster_map[int(lbl)] = {
- 'label': int(lbl),
- 'size': count,
- 'center_lat': center_lat,
- 'center_lon': center_lon,
- 'n_unique_ips': n_unique_ips,
- 'is_noise': is_noise,
- 'ip_sample': '', # populated below
- }
-
- # 数据点坐标 + IP 样本
- pts = []
- first_ip = ''
- if lats is not None and lons is not None and valid_mask is not None:
- valid_indices = np.where(valid_mask)[0]
- for pt_idx_in_valid, orig_idx in enumerate(valid_indices):
- if labels[orig_idx] == lbl:
- pts.append({
- 'lat': round(float(lats[pt_idx_in_valid]), 4),
- 'lon': round(float(lons[pt_idx_in_valid]), 4),
- 'idx': int(orig_idx),
- })
- if not first_ip and ips_col and orig_idx < total:
- try:
- first_ip = str(df[ips_col][orig_idx])
- except Exception:
- pass
- if first_ip:
- cluster_map.get(int(lbl), {})['ip_sample'] = first_ip
- data_points_by_cluster[int(lbl)] = pts
-
- return {
- 'n_clusters': len(cluster_map) - (1 if -1 in cluster_map else 0),
- 'n_noise': int((labels == -1).sum()),
- 'n_valid_points': int(valid_mask.sum()) if valid_mask is not None else total,
- 'total_records': total,
- 'clusters': cluster_map,
- 'labels': labels.tolist(),
- 'data_points': data_points_by_cluster,
- 'all_noise': info.get('all_noise', False),
- 'note': info.get('note', ''),
- 'fallback_used': info.get('fallback_used', False),
- 'downsample_used': info.get('downsample_used', False),
- }
-
-
-def _cluster_subnet(entry: dict, df: pl.DataFrame, meta: dict,
- min_records_per_subnet: int = 3, max_cluster_size: int = 100):
- """Soft clustering by /24 subnet prefix using GeoIP coordinates.
-
- Subnets with fewer than *min_records_per_subnet* records are marked noise (-1).
- Large subnets use GeoIP coordinates for automatic geo sub-clustering.
+ 流程:
+ 1. 从数据中提取所有唯一 IP(源+目的),去重
+ 2. 通过 GeoIP 获取每个 IP 的经纬度和属性
+ 3. 按 /24 子网前缀将 IP 分组
+ 4. 每个子网内,若节点数 > min_cluster_size,用 HDBSCAN (haversine) 二次聚类
+ 5. 不同子网的节点不会被合并
+ 6. 构建边列表(IP 对之间的聚合通信统计)
"""
ips_col = meta.get('ips_col')
- if not ips_col:
- return False, {'error': '缺少源IP列(:ips),无法执行子网聚类'}
+ ipd_col = meta.get('ipd_col')
+ time_col = meta.get('time_col')
+ bytes_col = meta.get('bytes_col')
+ geo_cache = entry.get('geo_cache', {})
- try:
- ip_series = df[ips_col].cast(pl.Utf8)
- except Exception as e:
- return False, {'error': f'IP列转换失败: {e}'}
+ if not ips_col or not ipd_col:
+ return {'error': '缺少源/目的IP列,无法构建通信网络'}
+ if ips_col not in df.columns or ipd_col not in df.columns:
+ return {'error': 'IP列不在数据中'}
- # 使用 GeoIP 提取经纬度
- geo_lats, geo_lons, geo_valid = _get_geo_coords(entry, df, meta)
+ # ── 1. 提取唯一 IP 列表 ──────────────────────────────────────────────
+ src_ips = df[ips_col].cast(pl.Utf8).to_list()
+ dst_ips = df[ipd_col].cast(pl.Utf8).to_list()
- # Extract /24 subnet
+ # Collect all unique IPs
+ unique_ips_set = set()
+ for ip in src_ips:
+ if ip and ip not in ('None', '', '+', '-'):
+ unique_ips_set.add(ip)
+ for ip in dst_ips:
+ if ip and ip not in ('None', '', '+', '-'):
+ unique_ips_set.add(ip)
+
+ unique_ips = sorted(unique_ips_set)
+ n_nodes = len(unique_ips)
+ ip_to_idx = {ip: i for i, ip in enumerate(unique_ips)}
+
+ logger.info('Network clustering: %d unique IPs from %d records', n_nodes, len(df))
+
+ if n_nodes < 3:
+ return {
+ 'n_clusters': 0, 'n_noise': n_nodes, 'n_nodes': n_nodes,
+ 'nodes': [], 'edges': [],
+ 'all_noise': True, 'note': 'IP节点不足(<3),无法聚类',
+ }
+
+ # ── 2. GeoIP 解析所有 IP ───────────────────────────────────────────
+ node_geo = {} # ip → {lat, lon, city, country, isp, org}
+ for ip in unique_ips:
+ info = geo_cache.get(ip)
+ if not info:
+ info = geoip_lookup(ip)
+ if info:
+ node_geo[ip] = info
+
+ geo_resolved = len(node_geo)
+ logger.info('GeoIP resolved %d / %d IPs', geo_resolved, n_nodes)
+
+ # ── 3. 按 /24 子网分组 ─────────────────────────────────────────────
def _subnet24(ip: str) -> str | None:
- if not ip or ip == 'None' or ip == '':
- return None
parts = ip.split('.')
- if len(parts) < 4:
+ if len(parts) != 4:
+ return None
+ try:
+ return '.'.join(parts[:3]) + '.0/24'
+ except Exception:
return None
- return '.'.join(parts[:3]) + '.0/24'
- subnets = [None if v is None else _subnet24(str(v)) for v in ip_series.to_list()]
- subnet_count = Counter(s for s in subnets if s is not None)
+ subnet_nodes: dict[str, list[str]] = {}
+ nodes_without_geo = []
+ for ip in unique_ips:
+ sn = _subnet24(ip)
+ if sn is None:
+ nodes_without_geo.append(ip)
+ continue
+ if sn not in subnet_nodes:
+ subnet_nodes[sn] = []
+ subnet_nodes[sn].append(ip)
- labels = []
- cluster_map: dict[int, dict] = {}
- next_label = 0
- subnet_to_label: dict[str, int] = {}
- large_subnet_indices: dict[str, list[int]] = {}
+ # ── 4. 每个子网内 HDBSCAN 聚类 ───────────────────────────────────
+ cluster_labels: dict[str, int] = {} # ip → cluster_id
+ cluster_id_counter = 0
+ cluster_info: dict[int, dict] = {} # cluster_id → {size, center_lat, center_lon, subnet, nodes}
- for row_idx, s in enumerate(subnets):
- if s is None:
- labels.append(-1)
- else:
- cnt = subnet_count.get(s, 0)
- if cnt < min_records_per_subnet:
- labels.append(-1)
- elif cnt > max_cluster_size:
- if s not in subnet_to_label:
- subnet_to_label[s] = next_label
- cluster_map[next_label] = {
- 'label': next_label, 'size': 0,
- 'subnet': s, 'is_noise': False,
- '_is_large': True, '_large_subnet_name': s,
- }
- next_label += 1
- large_subnet_indices[s] = []
- lbl = subnet_to_label[s]
- labels.append(lbl)
- cluster_map[lbl]['size'] = cluster_map[lbl].get('size', 0) + 1
- large_subnet_indices[s].append(row_idx)
+ for sn, ips in subnet_nodes.items():
+ # 收集该子网内有 GeoIP 坐标的节点
+ geo_ips = []
+ coords_list = []
+ for ip in ips:
+ info = node_geo.get(ip)
+ if info and info.get('lat') is not None and info.get('lon') is not None:
+ geo_ips.append(ip)
+ coords_list.append([np.radians(float(info['lat'])), np.radians(float(info['lon']))])
+
+ n_in_subnet = len(ips)
+ n_with_geo = len(geo_ips)
+
+ # 小规模子网 → 直接作为一个簇
+ if n_in_subnet <= min_cluster_size or n_with_geo < 3:
+ cid = cluster_id_counter
+ cluster_id_counter += 1
+ for ip in ips:
+ cluster_labels[ip] = cid
+
+ # 计算中心坐标
+ center_lat, center_lon = None, None
+ if coords_list:
+ coords_arr = np.array(coords_list, dtype=np.float32)
+ center_lat = round(float(np.degrees(np.median(coords_arr[:, 0]))), 4)
+ center_lon = round(float(np.degrees(np.median(coords_arr[:, 1]))), 4)
+
+ cluster_info[cid] = {
+ 'label': cid, 'size': n_in_subnet, 'n_geo': n_with_geo,
+ 'subnet': sn, 'center_lat': center_lat, 'center_lon': center_lon,
+ 'is_noise': False,
+ }
+ continue
+
+ # 大规模子网 → HDBSCAN 二次聚类
+ coords_arr = np.array(coords_list, dtype=np.float32)
+ n_coords = len(coords_arr)
+
+ # 去重坐标加微小抖动
+ if n_coords > 10:
+ _, unique_idx = np.unique(coords_arr, axis=0, return_index=True)
+ if len(unique_idx) < n_coords * 0.8:
+ jitter = np.random.RandomState(42).normal(0, 1e-6, coords_arr.shape).astype(np.float32)
+ coords_arr = coords_arr + jitter
+
+ try:
+ sub_clusterer = HDBSCAN(
+ min_cluster_size=max(2, min(n_coords // 20, min_cluster_size)),
+ min_samples=2,
+ metric='haversine',
+ cluster_selection_epsilon=cluster_selection_epsilon,
+ )
+ sub_labels = sub_clusterer.fit_predict(coords_arr)
+ except Exception as e:
+ logger.warning('Subnet %s HDBSCAN failed: %s — treating as single cluster', sn, e)
+ cid = cluster_id_counter
+ cluster_id_counter += 1
+ for ip in ips:
+ cluster_labels[ip] = cid
+ cluster_info[cid] = {
+ 'label': cid, 'size': n_in_subnet, 'n_geo': n_with_geo,
+ 'subnet': sn, 'center_lat': None, 'center_lon': None,
+ 'is_noise': False,
+ }
+ continue
+
+ # 分配子标签
+ unique_sl = set(sub_labels)
+ for sl in unique_sl:
+ mask = sub_labels == sl
+ subset_ips = [geo_ips[i] for i in range(n_coords) if mask[i]]
+
+ if sl == -1:
+ # 噪声:每个噪声节点单独标记为噪声
+ for ip in subset_ips:
+ cluster_labels[ip] = -1
else:
- if s not in subnet_to_label:
- subnet_to_label[s] = next_label
- cluster_map[next_label] = {
- 'label': next_label, 'size': 0,
- 'subnet': s, 'is_noise': False,
- }
- next_label += 1
- lbl = subnet_to_label[s]
- labels.append(lbl)
- cluster_map[lbl]['size'] = cluster_map[lbl].get('size', 0) + 1
+ cid = cluster_id_counter
+ cluster_id_counter += 1
+ for ip in subset_ips:
+ cluster_labels[ip] = cid
- # 大子网地理拆分(使用 GeoIP 坐标)
- if large_subnet_indices and geo_valid.any():
- for subnet_name, indices in large_subnet_indices.items():
- old_label = subnet_to_label.get(subnet_name)
- if old_label is None:
- continue
- cluster_map.pop(old_label, None)
- try:
- sub_lats = geo_lats[indices]
- sub_lons = geo_lons[indices]
- sub_valid = geo_valid[indices]
- valid_sub = sub_valid.nonzero()[0]
- if len(valid_sub) >= 3:
- sub_coords = np.column_stack([
- np.radians(sub_lats[valid_sub].astype(np.float32)),
- np.radians(sub_lons[valid_sub].astype(np.float32)),
- ])
- sub_cluster = HDBSCAN(
- min_cluster_size=max(3, len(valid_sub) // 10),
- min_samples=2, metric='haversine',
- cluster_selection_epsilon=0.005,
- )
- sub_labels = sub_cluster.fit_predict(sub_coords)
- valid_counter = 0
- for orig_rank in range(len(indices)):
- if not sub_valid[orig_rank]:
- labels[indices[orig_rank]] = -1
- else:
- sl = int(sub_labels[valid_counter])
- valid_counter += 1
- if sl == -1:
- labels[indices[orig_rank]] = -1
- else:
- scn = f'{subnet_name}/sub{sl}'
- if scn not in subnet_to_label:
- subnet_to_label[scn] = next_label
- cluster_map[next_label] = {
- 'label': next_label, 'size': 0,
- 'subnet': scn, 'is_noise': False,
- }
- next_label += 1
- nl = subnet_to_label[scn]
- labels[indices[orig_rank]] = nl
- cluster_map[nl]['size'] = cluster_map[nl].get('size', 0) + 1
- else:
- cluster_map[old_label] = {
- 'label': old_label, 'size': len(indices),
- 'subnet': subnet_name, 'is_noise': False,
- }
- subnet_to_label[subnet_name] = old_label
- except Exception as e:
- logger.warning('Subnet geo-split failed %s: %s', subnet_name, e)
- cluster_map[old_label] = {
- 'label': old_label, 'size': len(indices),
- 'subnet': subnet_name, 'is_noise': False,
+ sub_coords = coords_arr[mask]
+ center_lat = round(float(np.degrees(np.median(sub_coords[:, 0]))), 4)
+ center_lon = round(float(np.degrees(np.median(sub_coords[:, 1]))), 4)
+
+ cluster_info[cid] = {
+ 'label': cid, 'size': len(subset_ips), 'n_geo': len(subset_ips),
+ 'subnet': sn, 'center_lat': center_lat, 'center_lon': center_lon,
+ 'is_noise': False,
}
- # 更新簇中心(使用 GeoIP 坐标)
- for lbl in list(cluster_map.keys()):
- mask = np.array(labels) == lbl
- if ips_col:
+ # 子网内无 GeoIP 的节点 → 分配到子网的主簇(第一个)
+ non_geo_ips = [ip for ip in ips if ip not in geo_ips]
+ if non_geo_ips:
+ subnet_cids = [cid for cid, info in cluster_info.items()
+ if info.get('subnet') == sn and not info.get('is_noise')]
+ if subnet_cids:
+ main_cid = min(subnet_cids, key=lambda c: cluster_info[c]['label'])
+ for ip in non_geo_ips:
+ cluster_labels[ip] = main_cid
+ cluster_info[main_cid]['size'] += len(non_geo_ips)
+ else:
+ for ip in non_geo_ips:
+ cluster_labels[ip] = -1
+
+ # 无子网的节点 → 噪声
+ for ip in nodes_without_geo:
+ cluster_labels[ip] = -1
+
+ # ── 5. 构建节点列表 ──────────────────────────────────────────────
+ # 检测服务器 IP(在 dst_ip 中出现过的 IP)
+ server_ips = set()
+ for ip in dst_ips:
+ if ip and ip not in ('None', '', '+', '-'):
+ server_ips.add(ip)
+
+ nodes = []
+ for ip in unique_ips:
+ info = node_geo.get(ip, {})
+ cid = cluster_labels.get(ip, -1)
+ nodes.append({
+ 'ip': ip,
+ 'cluster_id': int(cid),
+ 'lat': info.get('lat'),
+ 'lon': info.get('lon'),
+ 'city': info.get('city', ''),
+ 'country': info.get('country', ''),
+ 'region': info.get('region', ''),
+ 'isp': info.get('isp', ''),
+ 'asn': info.get('asn', ''),
+ 'is_server': ip in server_ips,
+ })
+
+ # ── 6. 构建边列表(IP 对聚合通信统计) ──────────────────────────
+ pair_data: dict[tuple[str, str], dict] = {} # (src, dst) → {count, bytes, times}
+
+ bytes_list = None
+ if bytes_col and bytes_col in df.columns:
+ try:
+ bytes_list = df[bytes_col].cast(pl.Float64).to_list()
+ except Exception:
+ pass
+
+ times_list = None
+ if time_col and time_col in df.columns:
+ try:
+ times_list = df[time_col].cast(pl.Utf8).to_list()
+ except Exception:
+ pass
+
+ for i in range(len(df)):
+ s = str(src_ips[i]) if src_ips[i] not in (None, '', 'None') else None
+ d = str(dst_ips[i]) if dst_ips[i] not in (None, '', 'None') else None
+ if s is None or d is None:
+ continue
+ key = (s, d)
+ if key not in pair_data:
+ pair_data[key] = {'count': 0, 'total_bytes': 0.0, 'times': []}
+ pair_data[key]['count'] += 1
+ if bytes_list and i < len(bytes_list):
try:
- ips_in = df.filter(pl.Series(mask))[ips_col].cast(pl.Utf8).unique().to_list()
- cluster_map[lbl]['n_unique_ips'] = len(ips_in)
- except Exception:
- cluster_map[lbl]['n_unique_ips'] = 0
- # Geo 中心
- valid_in_cluster = geo_valid & mask
- if valid_in_cluster.any():
- cluster_map[lbl]['center_lat'] = round(float(np.median(geo_lats[valid_in_cluster])), 4)
- cluster_map[lbl]['center_lon'] = round(float(np.median(geo_lons[valid_in_cluster])), 4)
- else:
- cluster_map[lbl]['center_lat'] = None
- cluster_map[lbl]['center_lon'] = None
+ pair_data[key]['total_bytes'] += float(bytes_list[i] or 0)
+ except (ValueError, TypeError):
+ pass
+ if times_list and i < len(times_list):
+ t = times_list[i]
+ if t and t not in ('None', ''):
+ pair_data[key]['times'].append(t)
- n_noise = labels.count(-1)
- n_clusters = len(cluster_map)
+ edges = []
+ for (src, dst), data in pair_data.items():
+ edge = {
+ 'source': src,
+ 'target': dst,
+ 'comm_count': data['count'],
+ 'total_bytes': round(data['total_bytes'], 2),
+ }
+ # 时间统计
+ times = data.get('times', [])
+ if times:
+ times_sorted = sorted(times)
+ edge['first_seen'] = times_sorted[0][:25]
+ edge['last_seen'] = times_sorted[-1][:25]
+ # 时间规律:按小时统计
+ hour_counts = [0] * 24
+ for t_str in times:
+ try:
+ # Try ISO format or simple format
+ t_clean = t_str.strip()
+ if 'T' in t_clean or ' ' in t_clean:
+ # Extract hour
+ sep = 'T' if 'T' in t_clean else ' '
+ time_part = t_clean.split(sep)[-1]
+ hour = int(time_part.split(':')[0])
+ if 0 <= hour < 24:
+ hour_counts[hour] += 1
+ except Exception:
+ pass
+ 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)
+ edges.append(edge)
- # 数据点
- data_points_by_cluster: dict[int, list] = {}
- for lbl in list(cluster_map.keys()):
- mask = np.array(labels) == lbl
- pts = []
- valid_in = geo_valid & mask
- if valid_in.any():
- idxs = np.where(valid_in)[0]
- for idx in idxs[:200]: # 最多 200 点/簇
- pts.append({'lat': round(float(geo_lats[idx]), 4),
- 'lon': round(float(geo_lons[idx]), 4), 'idx': int(idx)})
- data_points_by_cluster[lbl] = pts
- noise_mask = np.array(labels) == -1
- noise_pts = []
- valid_noise = geo_valid & noise_mask
- if valid_noise.any():
- for idx in np.where(valid_noise)[0][:500]:
- noise_pts.append({'lat': round(float(geo_lats[idx]), 4),
- 'lon': round(float(geo_lons[idx]), 4), 'idx': int(idx)})
- if noise_pts:
- data_points_by_cluster[-1] = noise_pts
+ # 按通信次数排序,限制返回前 500 条边
+ edges.sort(key=lambda e: e['comm_count'], reverse=True)
+ edges = edges[:500]
- df_with_labels = df.with_columns(pl.Series('_cluster_label', labels))
- entry['cluster_labels'] = labels
- entry['cluster_map'] = cluster_map
- entry['cluster_mode'] = 'subnet'
- entry['filtered_df'] = df_with_labels
+ # ── 7. 存储结果 ──────────────────────────────────────────────────
+ n_clusters = len(cluster_info)
+ n_noise = sum(1 for v in cluster_labels.values() if v == -1)
+
+ entry['cluster_labels'] = cluster_labels # ip → cluster_id
+ entry['cluster_map'] = cluster_info
+ entry['cluster_mode'] = 'network'
entry['n_clusters'] = n_clusters
entry['n_noise'] = n_noise
-
- return True, {
- 'n_clusters': n_clusters, 'n_noise': n_noise,
- 'n_valid_points': len(df), 'total_records': len(df),
- 'clusters': cluster_map, 'labels': labels,
- 'data_points': data_points_by_cluster,
+ entry['nodes'] = nodes
+ entry['edges_data'] = edges
+ entry['cluster_info'] = {
'all_noise': n_clusters == 0,
+ 'note': '',
+ }
+
+ return {
+ 'n_clusters': n_clusters,
+ 'n_noise': n_noise,
+ 'n_nodes': n_nodes,
+ 'n_edges': len(edges),
+ 'geo_resolved': geo_resolved,
+ 'nodes': nodes,
+ 'edges': edges,
+ 'clusters': {str(k): v for k, v in cluster_info.items()},
+ 'all_noise': n_clusters == 0,
+ 'note': f'{n_nodes} 个IP节点, {len(edges)} 条边, {n_clusters} 个社区' if n_clusters > 0 else '未发现有效社区',
}
# ═══════════════════════════════════════════════════════════════════════════════
-# Step 4: Cluster detail + 边关系
+# Step 4: Cluster detail + Node detail + 边关系
# ═══════════════════════════════════════════════════════════════════════════════
@require_GET
def cluster_detail(request, label: int):
- """返回指定簇的详细画像,含 IP 属性、GeoIP 信息、流量特征等。"""
+ """返回指定簇的详细画像(节点列表、GeoIP 属性、流量特征等)。"""
session_id = request.GET.get('session_id')
if not session_id:
return JsonResponse({'error': '缺少 session_id'}, status=400)
@@ -1102,6 +1050,82 @@ def cluster_detail(request, label: int):
if entry is None:
return JsonResponse({'error': '会话已过期'}, status=400)
+ nodes = entry.get('nodes', [])
+ edges_data = entry.get('edges_data', [])
+ cluster_info = entry.get('cluster_map', {}).get(label, {})
+
+ if not nodes:
+ # Old format fallback
+ return _cluster_detail_legacy(entry, label)
+
+ # Filter nodes by cluster
+ cluster_nodes = [n for n in nodes if n.get('cluster_id') == label]
+ if not cluster_nodes:
+ return JsonResponse({'error': f'簇 {label} 无数据'}, status=404)
+
+ # Collect IPs in this cluster
+ cluster_ips = set(n['ip'] for n in cluster_nodes)
+
+ # Find edges where both ends are in this cluster (internal)
+ internal_edges = []
+ related_edges = []
+ for e in edges_data:
+ src_in = e['source'] in cluster_ips
+ dst_in = e['target'] in cluster_ips
+ if src_in and dst_in:
+ internal_edges.append(e)
+ elif src_in or dst_in:
+ related_edges.append(e)
+
+ # Geo bounds
+ lats = [n['lat'] for n in cluster_nodes if n.get('lat') is not None]
+ lons = [n['lon'] for n in cluster_nodes if n.get('lon') is not None]
+ geo_bounds = None
+ if lats and lons:
+ geo_bounds = {
+ 'lat_min': round(min(lats), 4), 'lat_max': round(max(lats), 4),
+ 'lon_min': round(min(lons), 4), 'lon_max': round(max(lons), 4),
+ }
+
+ # ISP / city / country distribution
+ def _dist(items, key):
+ counts = Counter(i.get(key, '') or '(未知)' for i in items)
+ total = sum(counts.values()) or 1
+ return [{'value': k, 'count': v, 'pct': round(v / total * 100, 1)}
+ for k, v in counts.most_common(10)]
+
+ # Total internal traffic
+ total_internal_bytes = sum(e.get('total_bytes', 0) or 0 for e in internal_edges)
+ total_related_bytes = sum(e.get('total_bytes', 0) or 0 for e in related_edges)
+
+ profile = {
+ 'label': label,
+ 'size': len(cluster_nodes),
+ 'cluster_info': cluster_info,
+ 'unique_ips': sorted(cluster_ips)[:100],
+ 'unique_ips_count': len(cluster_ips),
+ 'ip_attributes': [{
+ 'ip': n['ip'], 'lat': n.get('lat'), 'lon': n.get('lon'),
+ 'city': n.get('city', ''), 'country': n.get('country', ''),
+ 'isp': n.get('isp', ''), 'is_server': n.get('is_server', False),
+ } for n in cluster_nodes[:50]],
+ 'top_cities': _dist(cluster_nodes, 'city'),
+ 'top_countries': _dist(cluster_nodes, 'country'),
+ 'top_isps': _dist(cluster_nodes, 'isp'),
+ 'internal_edges': internal_edges[:100],
+ 'related_edges': related_edges[:100],
+ 'n_internal_edges': len(internal_edges),
+ 'n_related_edges': len(related_edges),
+ 'total_internal_bytes': round(total_internal_bytes, 2),
+ 'total_related_bytes': round(total_related_bytes, 2),
+ 'geo_bounds': geo_bounds,
+ }
+
+ return JsonResponse(profile)
+
+
+def _cluster_detail_legacy(entry: dict, label: int) -> JsonResponse:
+ """Fallback cluster detail for old record-based clustering format."""
df = entry.get('filtered_df') if entry.get('filtered_df') is not None else entry.get('df')
meta = entry.get('meta', {})
cluster_map = entry.get('cluster_map', {})
@@ -1109,7 +1133,6 @@ def cluster_detail(request, label: int):
if df is None:
return JsonResponse({'error': '数据未就绪'}, status=400)
-
if '_cluster_label' not in df.columns:
return JsonResponse({'error': '未执行聚类'}, status=400)
@@ -1140,23 +1163,17 @@ def cluster_detail(request, label: int):
except Exception:
return []
- # 列映射
ips_col = meta.get('ips_col')
ipd_col = meta.get('ipd_col')
dport_col = meta.get('dport_col')
tls_ver_col = meta.get('tls_ver_col')
- tls_cph_col = meta.get('tls_cph_col')
- bytes_col = meta.get('bytes_col')
- dur_col = meta.get('dur_col')
- ispn_col = meta.get('ispn_col')
- orgn_col = meta.get('orgn_col')
- city_col = meta.get('city_col')
- scnt_col = meta.get('scnt_col')
- time_col = meta.get('time_col')
snam_col = meta.get('snam_col')
- text_col = meta.get('text_col')
+ ispn_col = meta.get('ispn')
+ orgn_col = meta.get('orgn')
+ city_col = meta.get('city')
+ time_col = meta.get('time_col')
+ bytes_col = meta.get('bytes_col')
- # 源 IP 列表
unique_ips = []
if ips_col and ips_col in cluster_df.columns:
try:
@@ -1165,23 +1182,17 @@ def cluster_detail(request, label: int):
except Exception:
pass
- # 使用 GeoIP 获取 IP 属性
ip_attrs = []
for ip in unique_ips[:50]:
- info = geo_cache.get(ip) or {}
- if not info:
- info = geoip_lookup(ip) or {}
+ info = geo_cache.get(ip) or geoip_lookup(ip) or {}
ip_attrs.append({
'ip': ip,
- 'lat': info.get('lat'),
- 'lon': info.get('lon'),
- 'city': info.get('city'),
- 'country': info.get('country'),
+ 'lat': info.get('lat'), 'lon': info.get('lon'),
+ 'city': info.get('city', ''), 'country': info.get('country', ''),
})
profile = {
- 'label': label,
- 'size': rows,
+ 'label': label, 'size': rows,
'cluster_info': cluster_info,
'unique_ips': unique_ips[:100],
'unique_ips_count': len(unique_ips),
@@ -1189,7 +1200,6 @@ def cluster_detail(request, label: int):
'top_dst_ips': _top_values(ipd_col) if ipd_col else [],
'top_dst_ports': _top_values(dport_col) if dport_col else [],
'top_tls_versions': _top_values(tls_ver_col) if tls_ver_col else [],
- 'top_ciphers': _top_values(tls_cph_col) if tls_cph_col else [],
'top_sni': _top_values(snam_col) if snam_col else [],
'top_isp': _top_values(ispn_col) if ispn_col else [],
'top_org': _top_values(orgn_col) if orgn_col else [],
@@ -1197,63 +1207,110 @@ def cluster_detail(request, label: int):
'traffic': {},
}
- # 流量统计
if bytes_col and bytes_col in cluster_df.columns:
try:
profile['traffic']['total_bytes'] = round(float(cluster_df[bytes_col].cast(pl.Float64).sum()), 2)
- profile['traffic']['avg_bytes'] = round(float(cluster_df[bytes_col].cast(pl.Float64).mean()), 2)
- except Exception:
- pass
- if dur_col and dur_col in cluster_df.columns:
- try:
- profile['traffic']['total_duration'] = round(float(cluster_df[dur_col].cast(pl.Float64).sum()), 2)
- profile['traffic']['avg_duration'] = round(float(cluster_df[dur_col].cast(pl.Float64).mean()), 2)
except Exception:
pass
- # 时间范围
if time_col and time_col in cluster_df.columns:
try:
times = cluster_df[time_col].cast(pl.Utf8).drop_nulls()
if len(times) > 0:
- profile['time_range'] = {
- 'first': str(times.min())[:25],
- 'last': str(times.max())[:25],
- }
+ profile['time_range'] = {'first': str(times.min())[:25], 'last': str(times.max())[:25]}
except Exception:
pass
- # 地理边界
- geo_lats = []
- geo_lons = []
- for ip in unique_ips[:100]:
- info = geo_cache.get(ip) or geoip_lookup(ip) or {}
- if info.get('lat') and info.get('lon'):
- geo_lats.append(info['lat'])
- geo_lons.append(info['lon'])
- if geo_lats and geo_lons:
- profile['geo_bounds'] = {
- 'lat_min': round(min(geo_lats), 4),
- 'lat_max': round(max(geo_lats), 4),
- 'lon_min': round(min(geo_lons), 4),
- 'lon_max': round(max(geo_lons), 4),
- }
-
return JsonResponse(profile)
-@csrf_exempt
-@require_POST
-def edge_relationships(request):
- """计算 IP 通联关系(边),返回通联次数、时间规律等。"""
- body = json.loads(request.body)
- session_id = body.get('session_id')
- min_freq = int(body.get('min_freq', 10))
+@require_GET
+def node_detail(request):
+ """返回单个 IP 节点的详细属性及其关联边。"""
+ session_id = request.GET.get('session_id')
+ ip = request.GET.get('ip', '')
+ if not session_id or not ip:
+ return JsonResponse({'error': '缺少 session_id 或 ip'}, status=400)
entry = _cached(session_id)
if entry is None:
return JsonResponse({'error': '会话已过期'}, status=400)
+ nodes = entry.get('nodes', [])
+ edges_data = entry.get('edges_data', [])
+
+ # Find the node
+ node = next((n for n in nodes if n.get('ip') == ip), None)
+ if not node:
+ # Fallback: try GeoIP direct lookup
+ info = geoip_lookup(ip)
+ if info:
+ node = {
+ 'ip': ip,
+ 'cluster_id': -1,
+ 'lat': info.get('lat'), 'lon': info.get('lon'),
+ 'city': info.get('city', ''), 'country': info.get('country', ''),
+ 'isp': info.get('isp', ''), 'is_server': False,
+ }
+ else:
+ return JsonResponse({'error': f'IP {ip} 不在网络中'}, status=404)
+
+ # Find related edges
+ 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
+ peers = set()
+ for e in related:
+ if e.get('source') == ip:
+ peers.add(e.get('target'))
+ else:
+ peers.add(e.get('source'))
+
+ total_bytes = sum(e.get('total_bytes', 0) or 0 for e in related)
+
+ return JsonResponse({
+ 'node': node,
+ 'related_edges': related[:100],
+ 'n_edges': len(related),
+ 'peer_ips': sorted(peers)[:100],
+ 'n_peers': len(peers),
+ 'total_flow_bytes': round(total_bytes, 2),
+ })
+
+
+@csrf_exempt
+@require_POST
+def edge_relationships(request):
+ """返回边关系数据(使用预计算的边列表)。"""
+ body = json.loads(request.body)
+ session_id = body.get('session_id')
+ min_freq = int(body.get('min_freq', 1))
+
+ entry = _cached(session_id)
+ if entry is None:
+ return JsonResponse({'error': '会话已过期'}, status=400)
+
+ edges_data = entry.get('edges_data', [])
+
+ if not edges_data:
+ # Fallback: compute on-the-fly from old format
+ return _edge_relationships_legacy(entry, body)
+
+ # Filter by min frequency
+ filtered = [e for e in edges_data if e.get('comm_count', 0) >= min_freq]
+ filtered.sort(key=lambda e: e.get('comm_count', 0), reverse=True)
+
+ return JsonResponse({
+ 'edges': filtered[:500],
+ 'total_edges': len(filtered),
+ 'min_freq': min_freq,
+ })
+
+
+def _edge_relationships_legacy(entry: dict, body: dict) -> JsonResponse:
+ """Fallback edge computation for old record-based format."""
+ min_freq = int(body.get('min_freq', 10))
df = entry.get('filtered_df') if entry.get('filtered_df') is not None else entry.get('df')
meta = entry.get('meta', {})
if df is None:
@@ -1262,6 +1319,7 @@ def edge_relationships(request):
ips_col = meta.get('ips_col')
ipd_col = meta.get('ipd_col')
time_col = meta.get('time_col')
+ bytes_col = meta.get('bytes_col')
if not ips_col or not ipd_col:
return JsonResponse({'error': '缺少源/目的IP列'}, status=400)
@@ -1271,40 +1329,46 @@ def edge_relationships(request):
try:
src_ips = df[ips_col].cast(pl.Utf8).to_list()
dst_ips = df[ipd_col].cast(pl.Utf8).to_list()
+ bytes_list = df[bytes_col].cast(pl.Float64).to_list() if bytes_col and bytes_col in df.columns else None
+ times_list = df[time_col].cast(pl.Utf8).to_list() if time_col and time_col in df.columns else None
- # 聚合统计
pair_counts: dict[tuple[str, str], int] = {}
+ pair_bytes: dict[tuple[str, str], float] = {}
pair_times: dict[tuple[str, str], list[str]] = {}
+
for i in range(len(df)):
s = str(src_ips[i]) if src_ips[i] not in (None, '', 'None') else '?'
d = str(dst_ips[i]) if dst_ips[i] not in (None, '', 'None') else '?'
key = (s, d)
pair_counts[key] = pair_counts.get(key, 0) + 1
- if time_col and time_col in df.columns:
+ if bytes_list and i < len(bytes_list):
try:
- v = str(df[time_col][i])
- if v and v not in ('None', ''):
- if key not in pair_times:
- pair_times[key] = []
- pair_times[key].append(v)
- except Exception:
+ pair_bytes[key] = pair_bytes.get(key, 0.0) + float(bytes_list[i] or 0)
+ except (ValueError, TypeError):
pass
+ if times_list and i < len(times_list):
+ v = times_list[i]
+ if v and v not in ('None', ''):
+ pair_times.setdefault(key, []).append(v)
edges = []
for (src, dst), count in pair_counts.items():
if count < min_freq:
continue
- times = pair_times.get((src, dst), [])
edge = {
- 'source': src,
- 'target': dst,
- 'count': count,
- 'times': times[:50],
- 'time_count': len(times),
+ 'source': src, 'target': dst,
+ 'comm_count': count,
+ 'total_bytes': round(pair_bytes.get((src, dst), 0), 2),
}
+ times = pair_times.get((src, dst), [])
+ if times:
+ times_sorted = sorted(times)
+ edge['first_seen'] = times_sorted[0][:25]
+ edge['last_seen'] = times_sorted[-1][:25]
+ edge['time_count'] = len(times)
edges.append(edge)
- edges.sort(key=lambda e: e['count'], reverse=True)
+ edges.sort(key=lambda e: e['comm_count'], reverse=True)
edges = edges[:200]
return JsonResponse({