c49475d207
后端:
- traffic_timeline_view: 返回edges[{first_ts,last_ts}]替代windows
- datetime.strptime + .timestamp()保留微秒精度
前端:
- 完全移除窗口概念, edge._fs/_ls定义生命周期
- 时间桶索引(O(1)查找可见边)
- 生命周期<5s自动扩展±30s
- 5%软边缘淡入淡出
- 每帧仅检查当前时间桶±1的边(性能优化)
- 最大渲染500条(按count排序)
验证: 500条边, ~127K像素, 206活跃节点, 边持久可见
Co-Authored-By: Claude <noreply@anthropic.com>
2471 lines
97 KiB
Python
2471 lines
97 KiB
Python
"""简单分析模块 v2 — 离线 GeoIP + 高级筛选 + 高性能聚类 + 丰富可视化。
|
||
|
||
改进:
|
||
- 使用离线 GeoIP 数据库(analysis.geoip)解析 IP 经纬度,抛弃 CSV 自带列
|
||
- 列名统一规范化,内置 50+ 列映射字典
|
||
- 上传预设筛选 + 高级 AND/OR/NOT 筛选
|
||
- HDBSCAN 性能优化(float32、降采样、DBSCAN 回退)
|
||
- 全噪声时可视化、点属性、边关系
|
||
"""
|
||
|
||
import json
|
||
import logging
|
||
import os
|
||
import re
|
||
import shutil
|
||
import time
|
||
import uuid
|
||
from pathlib import Path
|
||
from collections import Counter
|
||
|
||
import numpy as np
|
||
import polars as pl
|
||
from django.conf import settings
|
||
from django.http import JsonResponse
|
||
from django.shortcuts import render
|
||
from django.views.decorators.csrf import csrf_exempt
|
||
from django.views.decorators.http import require_POST, require_GET
|
||
from sklearn.cluster import HDBSCAN, DBSCAN
|
||
from sklearn.preprocessing import StandardScaler
|
||
|
||
# 复用天璇原有离线 GeoIP 模块(data/geoip_data.txt 覆盖 23 城市 ISP 段)
|
||
from analysis.geoip import lookup as geoip_lookup
|
||
|
||
logger = logging.getLogger(__name__)
|
||
|
||
# ── In-memory cache ──────────────────────────────────────────────────────────
|
||
_cache: dict[str, dict] = {}
|
||
_TEMP_DIR = Path(settings.FILE_UPLOAD_TEMP_DIR) / 'simple_analysis'
|
||
_TEMP_DIR.mkdir(parents=True, exist_ok=True)
|
||
|
||
|
||
def _new_id() -> str:
|
||
return uuid.uuid4().hex[:12]
|
||
|
||
|
||
def _cached(key: str) -> dict | None:
|
||
return _cache.get(key)
|
||
|
||
|
||
def _store(key: str, data: dict) -> None:
|
||
_cache[key] = data
|
||
|
||
|
||
# ═══════════════════════════════════════════════════════════════════════════════
|
||
# 列名规范化 — 完整映射字典
|
||
# ═══════════════════════════════════════════════════════════════════════════════
|
||
|
||
# 标准内部名称 → 可能的 CSV 原始列名(按优先级)
|
||
_COLUMN_ALIASES: dict[str, list[str]] = {
|
||
# IP 地址
|
||
'ips': [':ips', 'src_ip', 'source_ip', 'ip_src', 'client-ip', 'client_ip'],
|
||
'ipd': [':ipd', 'dst_ip', 'dest_ip', 'destination_ip', 'ip_dst', 'server-ip', 'server_ip'],
|
||
# 布尔/状态
|
||
'cnrs': ['cnrs', 'conn_success', 'result', '4srs'],
|
||
'isrs': ['isrs', 'is_resolved', 'resolved', '8ses'],
|
||
# 类别/名称
|
||
'cnam': ['cnam', 'snam', 'class_name', 'category', 'target', 'name', 'tabl'],
|
||
'snam': ['snam', 'server_name', 'tls_sni', 'sni'],
|
||
# 经纬度(仍保留检测,但聚类不再依赖)
|
||
'src_lat': [':ips.latd', 'src_latitude', 'src_lat', 'source_lat'],
|
||
'src_lon': [':ips.lond', 'src_longitude', 'src_lon', 'source_lon'],
|
||
'dst_lat': [':ipd.latd', 'dst_latitude', 'dst_lat', 'dest_lat'],
|
||
'dst_lon': [':ipd.lond', 'dst_longitude', 'dst_lon', 'dest_lon'],
|
||
# 端口/协议
|
||
'dport': ['1ipp', 'dst_port', 'dest_port', 'port', 'dport'],
|
||
'proto': ['proto', 'protocol', 'l4_proto'],
|
||
# TLS
|
||
'tls_ver': ['0ver', 'tls_version', 'version', 'tlsver'],
|
||
'tls_cph': ['0cph', 'cipher_suite', 'cipher', 'tls_cipher'],
|
||
'tls_rnd': ['0rnd', 'tls_random', 'random'],
|
||
'tls_rnt': ['0rnt', 'tls_rnt'],
|
||
# 流量
|
||
'bytes': ['8byt', 'bytes_sent', 'bytes', 'src_bytes', '8ppk'],
|
||
'duration': ['4dur', 'duration', 'dur', 'flow_duration'],
|
||
'packets': ['8pak', 'packets', 'pkt_count'],
|
||
# 时序
|
||
'time': ['time', 'timestamp', '4dbn'],
|
||
'seq': ['8seq', 'seq', 'sequence'],
|
||
'ack': ['8ack', 'ack', 'acknowledgement'],
|
||
# 地理信息
|
||
'ispn': [':ips.ispn', 'isp_name', 'isp'],
|
||
'orgn': [':ips.orgn', 'organization', 'org'],
|
||
'city': [':ips.city', 'city', 'src_city'],
|
||
'ipd_ispn': [':ipd.ispn', 'dst_ispn', 'dst_isp'],
|
||
'ipd_orgn': [':ipd.orgn', 'dst_organization', 'dst_org'],
|
||
'ipd_city': [':ipd.city', 'dst_city'],
|
||
# 其他
|
||
'scnt': ['scnt', 'country', 'country_code'],
|
||
'dcnt': ['dcnt', 'dst_country'],
|
||
'source_node': ['source-node', 'source_node'],
|
||
'prd': [':prd', 'prd', 'product'],
|
||
'prs': [':prs', 'prs', 'process'],
|
||
'ipd_anon': [':ipd.anon', 'dst_anon', 'anon'],
|
||
'8did': ['8did', 'did', 'device_id'],
|
||
'8dbd': ['8dbd', 'dbd'],
|
||
'2tmo': ['2tmo', 'tmo', 'timeout'],
|
||
'text': ['text', 'payload', 'content'],
|
||
'host': ['host', 'hostname', 'host_name', 'remote_host', 'peer_name'],
|
||
}
|
||
|
||
|
||
def _norm(name: str) -> str:
|
||
"""归一化列名:去首尾空格,特殊字符→下划线,转小写。"""
|
||
n = name.strip().lower()
|
||
n = re.sub(r'[-:.\s]+', '_', n)
|
||
return n
|
||
|
||
|
||
def normalize_columns(df: pl.DataFrame) -> tuple[pl.DataFrame, dict[str, str]]:
|
||
"""对 DataFrame 列名进行统一规范化,返回 (新df, 规范化映射 {原始名→标准内部名})。
|
||
|
||
规范化规则:
|
||
1. 去首尾空格
|
||
2. 连续特殊字符(- : . 空格)→ 下划线
|
||
3. 全部小写
|
||
然后根据 _COLUMN_ALIASES 模糊匹配到标准内部名称。
|
||
"""
|
||
col_map = {} # 原始名 → 标准内部名
|
||
reverse_map = {} # norm(原始名) → 原始名
|
||
|
||
for c in df.columns:
|
||
nc = _norm(c)
|
||
reverse_map[nc] = c
|
||
|
||
# 遍历别名表,尝试匹配
|
||
used_originals = set()
|
||
for std_name, aliases in _COLUMN_ALIASES.items():
|
||
for alias in aliases:
|
||
n_alias = _norm(alias)
|
||
# 精确匹配
|
||
if n_alias in reverse_map and reverse_map[n_alias] not in used_originals:
|
||
col_map[reverse_map[n_alias]] = std_name
|
||
used_originals.add(reverse_map[n_alias])
|
||
break
|
||
# 后缀匹配(如列名 _ips 匹配 :ips)
|
||
for orig, n_orig in reverse_map.items():
|
||
if orig in used_originals:
|
||
continue
|
||
if n_orig == n_alias or n_orig.endswith('_' + n_alias) or n_orig.startswith(n_alias + '_'):
|
||
col_map[orig] = std_name
|
||
used_originals.add(orig)
|
||
break
|
||
if std_name in col_map.values():
|
||
break
|
||
|
||
# 未匹配的保留原名
|
||
for c in df.columns:
|
||
if c not in col_map:
|
||
col_map[c] = c
|
||
|
||
# 重命名
|
||
rename_dict = {orig: std for orig, std in col_map.items() if orig != std}
|
||
if rename_dict:
|
||
df = df.rename(rename_dict)
|
||
|
||
return df, col_map
|
||
|
||
|
||
def find_col(norm_col_map: dict[str, str], *std_names: str) -> str | None:
|
||
"""在规范化映射中查找第一个匹配的标准列名,返回原始列名。"""
|
||
for std in std_names:
|
||
for orig, mapped in norm_col_map.items():
|
||
if mapped == std:
|
||
return orig
|
||
return None
|
||
|
||
|
||
# ═══════════════════════════════════════════════════════════════════════════════
|
||
# GeoIP 辅助(复用天璇离线库)
|
||
# ═══════════════════════════════════════════════════════════════════════════════
|
||
|
||
def resolve_ip_geo(ip_str: str | None) -> dict:
|
||
"""用离线 GeoIP 库解析单个 IP,返回 {lat, lon, city, country} 或空字典。"""
|
||
if not ip_str or ip_str in ('None', '', '+', '-'):
|
||
return {}
|
||
try:
|
||
result = geoip_lookup(ip_str)
|
||
if result:
|
||
return result
|
||
except Exception:
|
||
pass
|
||
return {}
|
||
|
||
|
||
def batch_geoip_resolve(ip_list: list[str]) -> dict[str, dict]:
|
||
"""批量解析 IP 地理信息,返回 {ip: {lat, lon, city, country}}。"""
|
||
cache: dict[str, dict] = {}
|
||
for ip in set(ip_list):
|
||
if ip and ip not in ('None', '', '+', '-'):
|
||
result = geoip_lookup(ip)
|
||
if result:
|
||
cache[ip] = result
|
||
return cache
|
||
|
||
|
||
# ═══════════════════════════════════════════════════════════════════════════════
|
||
# Step 0: Quick column scan (header-only, files kept for lazy value scan)
|
||
# ═══════════════════════════════════════════════════════════════════════════════
|
||
|
||
@csrf_exempt
|
||
@require_POST
|
||
def quick_scan(request):
|
||
"""仅读取 CSV 表头 + 保留文件供后续单列值扫描。
|
||
|
||
文件保存在 scan_dir,scan_id 存入 _cache。
|
||
后续 column_values 可用 scan_id 做单列 streaming Top-N 查询。
|
||
调用 upload_csv 时若提供 scan_id,可复用已保存的文件。
|
||
"""
|
||
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 (1 row is enough for column names)
|
||
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')
|
||
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)
|
||
|
||
# Keep files for lazy value scanning — store paths in cache
|
||
_store(scan_id, {
|
||
'scan_paths': saved_paths,
|
||
'col_map': col_map,
|
||
'columns': list(df_norm.columns),
|
||
})
|
||
|
||
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 解析
|
||
# ═══════════════════════════════════════════════════════════════════════════════
|
||
|
||
@csrf_exempt
|
||
@require_POST
|
||
def upload_csv(request):
|
||
"""上传 CSV,列归一化,预设筛选(支持树状高级筛选),GeoIP 解析,返回摘要。
|
||
|
||
若提供 scan_id,复用 quick_scan 阶段已保存的文件,避免重复写入磁盘。
|
||
"""
|
||
scan_id = request.POST.get('scan_id', request.GET.get('scan_id', ''))
|
||
|
||
session_id = _new_id()
|
||
upload_dir = _TEMP_DIR / session_id
|
||
|
||
saved_paths = []
|
||
|
||
# 复用 scan 阶段的文件(避免重复存储)
|
||
if scan_id:
|
||
scan_entry = _cached(scan_id)
|
||
if scan_entry and scan_entry.get('scan_paths'):
|
||
scan_dir = Path(scan_entry['scan_paths'][0]).parent
|
||
# Move the scan directory to the session directory
|
||
shutil.move(str(scan_dir), str(upload_dir))
|
||
# Rebuild saved_paths from new location
|
||
for p in upload_dir.iterdir():
|
||
if p.is_file():
|
||
saved_paths.append(str(p))
|
||
# Clean scan cache
|
||
_cache.pop(scan_id, None)
|
||
else:
|
||
upload_dir.mkdir(parents=True, exist_ok=True)
|
||
|
||
# Fallback: normal file upload
|
||
if not saved_paths:
|
||
files = request.FILES.getlist('files')
|
||
if not files:
|
||
return JsonResponse({'error': '未上传任何文件'}, status=400)
|
||
upload_dir.mkdir(parents=True, exist_ok=True)
|
||
for f in files:
|
||
dest = upload_dir / f.name
|
||
with open(dest, 'wb') as out:
|
||
for chunk in f.chunks():
|
||
out.write(chunk)
|
||
saved_paths.append(str(dest))
|
||
|
||
# 解析预设筛选条件(支持树状结构 + 旧格式兼容)
|
||
preset_root = None
|
||
try:
|
||
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
|
||
|
||
# Load & merge CSV
|
||
try:
|
||
lf = pl.scan_csv(
|
||
saved_paths[0] if len(saved_paths) == 1 else str(upload_dir / '*.csv'),
|
||
infer_schema_length=10000,
|
||
try_parse_dates=True,
|
||
)
|
||
if len(saved_paths) > 1:
|
||
frames = [pl.scan_csv(p, infer_schema_length=10000) for p in saved_paths]
|
||
lf = pl.concat(frames, how='diagonal_relaxed')
|
||
df = lf.collect(streaming=True)
|
||
except Exception as e:
|
||
shutil.rmtree(upload_dir, ignore_errors=True)
|
||
return JsonResponse({'error': f'CSV 解析失败: {e}'}, status=400)
|
||
|
||
# 列名规范化
|
||
df, col_map = normalize_columns(df)
|
||
total_rows = len(df)
|
||
|
||
# 获取各关键列的 标准内部名(即重命名后的列名)
|
||
def _get_std(*names):
|
||
"""从 col_map 中查找第一个匹配的标准名。"""
|
||
for n in names:
|
||
for orig, std in col_map.items():
|
||
if std == n:
|
||
return std
|
||
return None
|
||
|
||
ips_col = _get_std('ips')
|
||
ipd_col = _get_std('ipd')
|
||
cnam_col = _get_std('cnam')
|
||
cnrs_col = _get_std('cnrs')
|
||
isrs_col = _get_std('isrs')
|
||
snam_col = _get_std('snam')
|
||
dport_col = _get_std('dport')
|
||
proto_col = _get_std('proto')
|
||
tls_ver_col = _get_std('tls_ver')
|
||
tls_cph_col = _get_std('tls_cph')
|
||
bytes_col = _get_std('bytes')
|
||
packets_col = _get_std('packets')
|
||
dur_col = _get_std('duration')
|
||
time_col = _get_std('time')
|
||
ispn_col = _get_std('ispn')
|
||
orgn_col = _get_std('orgn')
|
||
city_col = _get_std('city')
|
||
scnt_col = _get_std('scnt')
|
||
text_col = _get_std('text')
|
||
host_col = _get_std('host')
|
||
|
||
# ── 预设筛选(树状高级筛选) ──────────────────────────────────────────
|
||
preset_filtered = total_rows
|
||
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)
|
||
|
||
# ── GeoIP 批量解析(使用标准化列名) ────────────────────────────────────
|
||
geo_cache: dict[str, dict] = {}
|
||
geo_skipped = 0
|
||
if ips_col and ips_col in df.columns:
|
||
all_ips = df[ips_col].cast(pl.Utf8).unique().to_list()
|
||
geo_cache = batch_geoip_resolve([ip for ip in all_ips if ip not in (None, '', 'None', '+', '-')])
|
||
logger.info('GeoIP resolved %d IPs', len(geo_cache))
|
||
|
||
# ── CNAM 频率 ────────────────────────────────────────────────────────────
|
||
cnam_freq = []
|
||
top_cnam = None
|
||
if cnam_col:
|
||
try:
|
||
cnam_series = df[cnam_col].cast(pl.Utf8).fill_null('')
|
||
counts = Counter(cnam_series.to_list())
|
||
total_cnam = sum(counts.values())
|
||
for val, cnt in counts.most_common():
|
||
cnam_freq.append({
|
||
'value': val if val else '(空)',
|
||
'count': cnt,
|
||
'pct': round(cnt / total_cnam * 100, 1),
|
||
})
|
||
if cnam_freq:
|
||
top_cnam = cnam_freq[0]
|
||
except Exception as e:
|
||
logger.warning('cnam frequency failed: %s', e)
|
||
|
||
# 存储元数据
|
||
meta = {
|
||
'session_id': session_id,
|
||
'total_rows': total_rows,
|
||
'preset_filtered': preset_filtered,
|
||
'col_names': list(df.columns),
|
||
'col_map': col_map, # {原始名→标准内部名}
|
||
'geo_cache': geo_cache, # {ip: {lat, lon, city, country}}
|
||
'geo_skipped': geo_skipped,
|
||
'ips_col': ips_col,
|
||
'ipd_col': ipd_col,
|
||
'cnam_col': cnam_col,
|
||
'cnrs_col': cnrs_col,
|
||
'isrs_col': isrs_col,
|
||
'snam_col': snam_col,
|
||
'dport_col': dport_col,
|
||
'proto_col': proto_col,
|
||
'tls_ver_col': tls_ver_col,
|
||
'tls_cph_col': tls_cph_col,
|
||
'bytes_col': bytes_col,
|
||
'packets_col': packets_col,
|
||
'dur_col': dur_col,
|
||
'time_col': time_col,
|
||
'ispn_col': ispn_col,
|
||
'orgn_col': orgn_col,
|
||
'city_col': city_col,
|
||
'scnt_col': scnt_col,
|
||
'text_col': text_col,
|
||
'host_col': host_col,
|
||
'snam_col': snam_col,
|
||
}
|
||
_store(session_id, {'meta': meta, 'df': df, 'geo_cache': geo_cache})
|
||
|
||
return JsonResponse({
|
||
'session_id': session_id,
|
||
'total_rows': total_rows,
|
||
'preset_filtered': preset_filtered,
|
||
'columns': list(df.columns)[:50],
|
||
'columns_std': list(df.columns), # 标准化后列名,供前端筛选器使用
|
||
'col_map': {orig: std for orig, std in col_map.items() if orig != std},
|
||
'detected_columns': {
|
||
'cnam': cnam_col,
|
||
'snam': snam_col,
|
||
'cnrs': cnrs_col,
|
||
'isrs': isrs_col,
|
||
'ips': ips_col,
|
||
'ipd': ipd_col,
|
||
'ispn': ispn_col,
|
||
'orgn': orgn_col,
|
||
'city': city_col,
|
||
'host': host_col,
|
||
'text': text_col,
|
||
},
|
||
'cnam_frequency': cnam_freq[:30],
|
||
'top_cnam': top_cnam,
|
||
'geo_stats': {
|
||
'ips_resolved': len(geo_cache),
|
||
},
|
||
})
|
||
|
||
|
||
# ═══════════════════════════════════════════════════════════════════════════════
|
||
# Step 2: 高级筛选构建器(AND/OR/NOT 组合条件)
|
||
# ═══════════════════════════════════════════════════════════════════════════════
|
||
|
||
def _build_condition(col_name: str, operator: str, value: str, logic: str = 'AND'):
|
||
"""构建单个筛选条件表达式。"""
|
||
col = pl.col(col_name)
|
||
v = value.strip()
|
||
try:
|
||
if operator == 'eq':
|
||
cond = col.cast(pl.Utf8) == v
|
||
elif operator == 'neq':
|
||
cond = col.cast(pl.Utf8) != v
|
||
elif operator == 'contains':
|
||
cond = col.cast(pl.Utf8).str.contains(v)
|
||
elif operator == 'not_contains':
|
||
cond = ~col.cast(pl.Utf8).str.contains(v)
|
||
elif operator == 'gt':
|
||
cond = col.cast(pl.Float64) > float(v)
|
||
elif operator == 'lt':
|
||
cond = col.cast(pl.Float64) < float(v)
|
||
elif operator == 'gte':
|
||
cond = col.cast(pl.Float64) >= float(v)
|
||
elif operator == 'lte':
|
||
cond = col.cast(pl.Float64) <= float(v)
|
||
elif operator == 'is_empty':
|
||
cond = col.is_null() | (col.cast(pl.Utf8) == '')
|
||
elif operator == 'not_empty':
|
||
cond = col.is_not_null() & (col.cast(pl.Utf8) != '')
|
||
else:
|
||
cond = col.cast(pl.Utf8) == v
|
||
except Exception:
|
||
cond = col.cast(pl.Utf8) == v
|
||
return cond
|
||
|
||
|
||
def _build_tree_condition(node, df) -> pl.Expr | None:
|
||
"""递归构建条件树,支持嵌套 AND/OR/NOT 组。
|
||
|
||
JSON 节点格式:
|
||
{"type": "condition", "col": "...", "op": "...", "val": "..."}
|
||
{"type": "group", "logic": "AND|OR", "items": [...]}
|
||
{"type": "group", "logic": "NOT", "items": [...]} # 否定整个组
|
||
"""
|
||
if node is None:
|
||
return None
|
||
t = node.get('type', 'condition')
|
||
|
||
if t == 'condition':
|
||
col = node.get('col', '')
|
||
if not col or col not in df.columns:
|
||
return None
|
||
c = _build_condition(col, node.get('op', 'eq'), node.get('val', ''))
|
||
if node.get('logic') == 'NOT':
|
||
c = ~c
|
||
return c
|
||
|
||
if t == 'group':
|
||
items = node.get('items', [])
|
||
if not items:
|
||
return None
|
||
logic = node.get('logic', 'AND')
|
||
conds = []
|
||
for item in items:
|
||
c = _build_tree_condition(item, df)
|
||
if c is not None:
|
||
conds.append(c)
|
||
if not conds:
|
||
return None
|
||
if logic == 'NOT':
|
||
combined = conds[0]
|
||
for c in conds[1:]:
|
||
combined = combined & c # NOT 组内默认 AND 组合
|
||
return ~combined
|
||
combined = conds[0]
|
||
for c in conds[1:]:
|
||
if logic == 'OR':
|
||
combined = combined | c
|
||
else:
|
||
combined = combined & c
|
||
return combined
|
||
|
||
return None
|
||
|
||
|
||
@csrf_exempt
|
||
@require_POST
|
||
def advanced_filter(request):
|
||
"""高级筛选:接受树状 AND/OR/NOT 分组条件,支持嵌套括号语义。"""
|
||
body = json.loads(request.body)
|
||
session_id = body.get('session_id')
|
||
root = body.get('root') # 新格式:树状结构
|
||
|
||
entry = _cached(session_id)
|
||
if entry is None:
|
||
return JsonResponse({'error': '会话已过期,请重新上传'}, status=400)
|
||
|
||
df = entry['df']
|
||
|
||
try:
|
||
if root:
|
||
# 新格式:递归构建条件树
|
||
combined = _build_tree_condition(root, df)
|
||
if combined is None:
|
||
entry['filtered_df'] = df
|
||
entry['meta']['filtered_rows'] = len(df)
|
||
return JsonResponse({'filtered_rows': len(df), 'total_before_filter': len(df), 'preview_columns': df.columns[:30], 'preview_rows': []})
|
||
else:
|
||
# 旧格式(向后兼容)
|
||
conditions = body.get('conditions', [])
|
||
global_logic = body.get('global_logic', 'AND')
|
||
if not conditions:
|
||
entry['filtered_df'] = df
|
||
entry['meta']['filtered_rows'] = len(df)
|
||
return JsonResponse({'filtered_rows': len(df), 'total_before_filter': len(df), 'preview_columns': df.columns[:30], 'preview_rows': []})
|
||
|
||
group_conds = []
|
||
for cond_item in conditions:
|
||
col = cond_item.get('col', '')
|
||
op = cond_item.get('op', 'eq')
|
||
val = cond_item.get('val', '')
|
||
if not col or col not in df.columns:
|
||
continue
|
||
c = _build_condition(col, op, val)
|
||
if cond_item.get('logic') == 'NOT':
|
||
c = ~c
|
||
group_conds.append(c)
|
||
|
||
if not group_conds:
|
||
return JsonResponse({'error': '无可用的筛选条件'}, status=400)
|
||
|
||
if global_logic == 'OR':
|
||
combined = group_conds[0]
|
||
for c in group_conds[1:]:
|
||
combined = combined | c
|
||
else:
|
||
combined = group_conds[0]
|
||
for c in group_conds[1:]:
|
||
combined = combined & c
|
||
|
||
filtered = df.filter(combined)
|
||
except Exception as e:
|
||
return JsonResponse({'error': f'筛选构建失败: {e}'}, status=400)
|
||
|
||
filtered_rows = len(filtered)
|
||
entry['meta']['filtered_rows'] = filtered_rows
|
||
entry['filtered_df'] = filtered
|
||
|
||
preview_cols = [c for c in filtered.columns if not c.startswith('_')][:30]
|
||
preview_rows = []
|
||
for row in filtered.head(20).select(preview_cols).iter_rows(named=True):
|
||
clean = {}
|
||
for k, v in row.items():
|
||
if v is None or (isinstance(v, float) and np.isnan(v)):
|
||
clean[k] = None
|
||
else:
|
||
clean[k] = str(v)[:120]
|
||
preview_rows.append(clean)
|
||
|
||
return JsonResponse({
|
||
'filtered_rows': filtered_rows,
|
||
'preview_columns': preview_cols,
|
||
'preview_rows': preview_rows,
|
||
'total_before_filter': len(df),
|
||
})
|
||
|
||
|
||
# ── 兼容原 filter 接口 ──────────────────────────────────────────────────────
|
||
|
||
@csrf_exempt
|
||
@require_POST
|
||
def filter_data(request):
|
||
"""兼容旧版简单筛选接口,内部调用高级筛选。"""
|
||
body = json.loads(request.body)
|
||
session_id = body.get('session_id')
|
||
selected_cnam = body.get('selected_cnam', '')
|
||
|
||
entry = _cached(session_id)
|
||
if entry is None:
|
||
return JsonResponse({'error': '会话已过期,请重新上传'}, status=400)
|
||
|
||
df = entry['df']
|
||
meta = entry['meta']
|
||
cnam_col = meta.get('cnam_col')
|
||
cnrs_col = meta.get('cnrs_col')
|
||
isrs_col = meta.get('isrs_col')
|
||
|
||
if not cnam_col:
|
||
return JsonResponse({'error': '缺少 cnam/snam 列', 'missing_columns': ['cnam']}, status=400)
|
||
|
||
try:
|
||
conditions = [{'col': cnam_col, 'op': 'eq', 'val': selected_cnam}]
|
||
if cnrs_col:
|
||
conditions.append({'col': cnrs_col, 'op': 'contains', 'val': '+'})
|
||
if isrs_col:
|
||
conditions.append({'col': isrs_col, 'op': 'contains', 'val': '+'})
|
||
|
||
combined = None
|
||
for c in conditions:
|
||
cond = _build_condition(c['col'], c['op'], c['val'])
|
||
combined = cond if combined is None else (combined & cond)
|
||
|
||
filtered = df.filter(combined) if combined else df
|
||
except Exception as e:
|
||
return JsonResponse({'error': f'筛选失败: {e}'}, status=400)
|
||
|
||
filtered_rows = len(filtered)
|
||
meta['filtered_rows'] = filtered_rows
|
||
entry['filtered_df'] = filtered
|
||
|
||
preview_cols = [c for c in filtered.columns if not c.startswith('_')][:30]
|
||
preview_rows = []
|
||
for row in filtered.head(20).select(preview_cols).iter_rows(named=True):
|
||
clean = {}
|
||
for k, v in row.items():
|
||
if v is None or (isinstance(v, float) and np.isnan(v)):
|
||
clean[k] = None
|
||
else:
|
||
clean[k] = str(v)[:120]
|
||
preview_rows.append(clean)
|
||
|
||
return JsonResponse({
|
||
'filtered_rows': filtered_rows,
|
||
'preview_columns': preview_cols,
|
||
'preview_rows': preview_rows,
|
||
'total_before_filter': len(df),
|
||
})
|
||
|
||
|
||
# ═══════════════════════════════════════════════════════════════════════════════
|
||
# Step 3: 聚类 — IP 通信网络社区发现
|
||
# ═══════════════════════════════════════════════════════════════════════════════
|
||
|
||
@csrf_exempt
|
||
@require_POST
|
||
def run_clustering(request):
|
||
"""IP 通信网络社区发现聚类。
|
||
|
||
构建 IP 通信图(节点=唯一IP,边=聚合通信记录),
|
||
按 /24 子网前缀粗分组,子网内用 HDBSCAN (haversine) 二次聚类。
|
||
"""
|
||
body = json.loads(request.body)
|
||
session_id = body.get('session_id')
|
||
min_cluster_size = int(body.get('min_cluster_size', 5))
|
||
cluster_selection_epsilon = float(body.get('cluster_selection_epsilon', 0.05))
|
||
|
||
entry = _cached(session_id)
|
||
if entry is None:
|
||
return JsonResponse({'error': '会话已过期,请重新上传'}, status=400)
|
||
|
||
filtered = entry.get('filtered_df') if entry.get('filtered_df') is not None else entry.get('df')
|
||
if filtered is None:
|
||
return JsonResponse({'error': '请先执行筛选'}, status=400)
|
||
|
||
meta = entry['meta']
|
||
t_start = time.time()
|
||
|
||
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
|
||
_store(session_id, entry)
|
||
return JsonResponse(result_data)
|
||
|
||
|
||
def _cluster_network(entry: dict, df: pl.DataFrame, meta: dict,
|
||
min_cluster_size: int, cluster_selection_epsilon: float) -> dict:
|
||
"""构建 IP 通信网络并进行社区发现聚类。
|
||
|
||
流程:
|
||
1. 从数据中提取所有唯一 IP(源+目的),去重
|
||
2. 通过 GeoIP 获取每个 IP 的经纬度和属性
|
||
3. 按 /24 子网前缀将 IP 分组
|
||
4. 每个子网内,若节点数 > min_cluster_size,用 HDBSCAN (haversine) 二次聚类
|
||
5. 不同子网的节点不会被合并
|
||
6. 构建边列表(IP 对之间的聚合通信统计)
|
||
"""
|
||
ips_col = meta.get('ips_col')
|
||
ipd_col = meta.get('ipd_col')
|
||
time_col = meta.get('time_col')
|
||
bytes_col = meta.get('bytes_col')
|
||
host_col = meta.get('host_col')
|
||
snam_col = meta.get('snam_col')
|
||
text_col = meta.get('text_col')
|
||
orgn_col = meta.get('orgn_col')
|
||
ispn_col = meta.get('ispn_col')
|
||
city_col = meta.get('city_col')
|
||
dport_col = meta.get('dport_col')
|
||
dur_col = meta.get('dur_col')
|
||
cnrs_col = meta.get('cnrs_col')
|
||
tls_ver_col = meta.get('tls_ver_col')
|
||
tls_cph_col = meta.get('tls_cph_col')
|
||
cnam_col = meta.get('cnam_col')
|
||
packets_col = meta.get('packets_col')
|
||
geo_cache = entry.get('geo_cache', {})
|
||
|
||
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列不在数据中'}
|
||
|
||
# ── 1. 提取唯一 IP 列表 ──────────────────────────────────────────────
|
||
src_ips = df[ips_col].cast(pl.Utf8).to_list()
|
||
dst_ips = df[ipd_col].cast(pl.Utf8).to_list()
|
||
|
||
# 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))
|
||
|
||
# ── 2. GeoIP 解析所有 IP ───────────────────────────────────────────
|
||
# MMDB优先(高精度),upload时的geo_cache兜底(低精度文本回退)
|
||
node_geo = {} # ip → {lat, lon, city, country, isp, org}
|
||
for ip in unique_ips:
|
||
info = geoip_lookup(ip) # 优先用当前加载的MMDB数据库
|
||
if not info:
|
||
info = geo_cache.get(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:
|
||
parts = ip.split('.')
|
||
if len(parts) != 4:
|
||
return None
|
||
try:
|
||
return '.'.join(parts[:3]) + '.0/24'
|
||
except Exception:
|
||
return 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)
|
||
|
||
# ── 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 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,
|
||
}
|
||
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,
|
||
}
|
||
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:
|
||
cid = cluster_id_counter
|
||
cluster_id_counter += 1
|
||
cluster_labels[ip] = cid
|
||
cluster_info[cid] = {
|
||
'label': cid, 'size': 1, 'n_geo': 1,
|
||
'subnet': sn,
|
||
'center_lat': round(float(node_geo[ip].get('lat', 0)), 4) if ip in node_geo else None,
|
||
'center_lon': round(float(node_geo[ip].get('lon', 0)), 4) if ip in node_geo else None,
|
||
}
|
||
else:
|
||
cid = cluster_id_counter
|
||
cluster_id_counter += 1
|
||
for ip in subset_ips:
|
||
cluster_labels[ip] = cid
|
||
|
||
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,
|
||
}
|
||
|
||
# 子网内无 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]
|
||
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:
|
||
cid = cluster_id_counter
|
||
cluster_id_counter += 1
|
||
cluster_labels[ip] = cid
|
||
cluster_info[cid] = {'label': cid, 'size': 1, 'n_geo': 0, 'subnet': sn,
|
||
'center_lat': None, 'center_lon': None}
|
||
|
||
# 无子网的节点 → 各自成簇
|
||
for ip in nodes_without_geo:
|
||
cid = cluster_id_counter
|
||
cluster_id_counter += 1
|
||
cluster_labels[ip] = cid
|
||
cluster_info[cid] = {'label': cid, 'size': 1, 'n_geo': 0, 'subnet': None,
|
||
'center_lat': None, 'center_lon': None}
|
||
|
||
# ── 5. 构建节点列表 ──────────────────────────────────────────────
|
||
# 检测服务器 IP(在 dst_ip 中出现过的 IP)
|
||
server_ips = set()
|
||
for ip in dst_ips:
|
||
if ip and ip not in ('None', '', '+', '-'):
|
||
server_ips.add(ip)
|
||
|
||
# ── 收集 per-IP 元数据 (host, text, org, peers, TLS, traffic) ────
|
||
ip_hosts: dict[str, str] = {} # ip → 最常见的 host
|
||
ip_texts: dict[str, list] = {} # ip → 前几条 payload 样本
|
||
ip_orgs: dict[str, str] = {} # ip → 最常见的 org
|
||
ip_peers: dict[str, list] = {} # ip → 对端 IP 列表
|
||
ip_snis: dict[str, list] = {} # ip → SNI/域名列表
|
||
ip_tls_vers: dict[str, list] = {} # ip → TLS 版本列表
|
||
ip_ciphers: dict[str, list] = {} # ip → 加密套件列表
|
||
ip_dports: dict[str, list] = {} # ip → 目的端口列表
|
||
ip_categories: dict[str, list] = {} # ip → 分类标签列表
|
||
ip_total_bytes: dict[str, float] = {} # ip → 总字节数
|
||
ip_total_pkts: dict[str, int] = {} # ip → 总包数
|
||
ip_durations: dict[str, list] = {} # ip → 持续时间列表
|
||
ip_conn_ok: dict[str, int] = {} # ip → 成功连接次数
|
||
ip_conn_total: dict[str, int] = {} # ip → 总连接次数
|
||
ip_first_ts: dict[str, str] = {} # ip → 首次出现时间
|
||
ip_last_ts: dict[str, str] = {} # ip → 最后出现时间
|
||
|
||
scan_limit = min(len(df), 50000)
|
||
# Pre-extract column lists for faster access
|
||
_host_col = host_col or snam_col
|
||
_has_host = _host_col and _host_col in df.columns
|
||
_has_text = text_col and text_col in df.columns
|
||
_has_orgn = orgn_col and orgn_col in df.columns
|
||
_has_snam = snam_col and snam_col in df.columns
|
||
_has_tls_ver = tls_ver_col and tls_ver_col in df.columns
|
||
_has_tls_cph = tls_cph_col and tls_cph_col in df.columns
|
||
_has_dport = dport_col and dport_col in df.columns
|
||
_has_cnam = cnam_col and cnam_col in df.columns
|
||
_has_bytes = bytes_col and bytes_col in df.columns
|
||
_has_pkts = packets_col and packets_col in df.columns if packets_col else False
|
||
_has_dur = dur_col and dur_col in df.columns
|
||
_has_cnrs = cnrs_col and cnrs_col in df.columns
|
||
_has_time = time_col and time_col in df.columns
|
||
|
||
for i in range(scan_limit):
|
||
sip = str(src_ips[i]) if i < len(src_ips) and src_ips[i] not in (None, '', 'None') else None
|
||
if not sip or sip not in unique_ips_set:
|
||
continue
|
||
try:
|
||
if _has_host:
|
||
hval = str(df[_host_col][i]) if df[_host_col][i] is not None else None
|
||
if hval and hval not in ('None', '', 'nan'):
|
||
ip_hosts.setdefault(sip, []).append(hval)
|
||
if _has_text:
|
||
tval = str(df[text_col][i]) if df[text_col][i] is not None else None
|
||
if tval and tval not in ('None', '', 'nan'):
|
||
lst = ip_texts.setdefault(sip, [])
|
||
if len(lst) < 3:
|
||
lst.append(tval[:200])
|
||
if _has_orgn:
|
||
oval = str(df[orgn_col][i]) if df[orgn_col][i] is not None else None
|
||
if oval and oval not in ('None', '', 'nan'):
|
||
ip_orgs.setdefault(sip, []).append(oval)
|
||
if _has_snam:
|
||
sval = str(df[snam_col][i]) if df[snam_col][i] is not None else None
|
||
if sval and sval not in ('None', '', 'nan', '+', '-'):
|
||
ip_snis.setdefault(sip, []).append(sval)
|
||
if _has_tls_ver:
|
||
tv = str(df[tls_ver_col][i]) if df[tls_ver_col][i] is not None else None
|
||
if tv and tv not in ('None', '', 'nan'):
|
||
ip_tls_vers.setdefault(sip, []).append(tv)
|
||
if _has_tls_cph:
|
||
tc = str(df[tls_cph_col][i]) if df[tls_cph_col][i] is not None else None
|
||
if tc and tc not in ('None', '', 'nan'):
|
||
ip_ciphers.setdefault(sip, []).append(tc[:50])
|
||
if _has_dport:
|
||
dp = str(df[dport_col][i]) if df[dport_col][i] is not None else None
|
||
if dp and dp not in ('None', '', 'nan'):
|
||
ip_dports.setdefault(sip, []).append(dp)
|
||
if _has_cnam:
|
||
cv = str(df[cnam_col][i]) if df[cnam_col][i] is not None else None
|
||
if cv and cv not in ('None', '', 'nan', '+', '-'):
|
||
ip_categories.setdefault(sip, []).append(cv)
|
||
if _has_bytes:
|
||
try:
|
||
bv = float(df[bytes_col][i] or 0)
|
||
ip_total_bytes[sip] = ip_total_bytes.get(sip, 0.0) + bv
|
||
except Exception:
|
||
pass
|
||
if _has_pkts:
|
||
try:
|
||
pv = int(df[packets_col][i] or 0)
|
||
ip_total_pkts[sip] = ip_total_pkts.get(sip, 0) + pv
|
||
except Exception:
|
||
pass
|
||
if _has_dur:
|
||
try:
|
||
dv = float(df[dur_col][i] or 0)
|
||
if dv > 0:
|
||
ip_durations.setdefault(sip, []).append(dv)
|
||
except Exception:
|
||
pass
|
||
if _has_cnrs:
|
||
cv = str(df[cnrs_col][i]) if df[cnrs_col][i] is not None else ''
|
||
ip_conn_total[sip] = ip_conn_total.get(sip, 0) + 1
|
||
if cv in ('+', '1', 'true', 'True', 'success'):
|
||
ip_conn_ok[sip] = ip_conn_ok.get(sip, 0) + 1
|
||
if _has_time:
|
||
tv = str(df[time_col][i]) if df[time_col][i] is not None else ''
|
||
if tv and tv not in ('None', '', 'nan'):
|
||
if sip not in ip_first_ts or tv < ip_first_ts[sip]:
|
||
ip_first_ts[sip] = tv
|
||
if sip not in ip_last_ts or tv > ip_last_ts[sip]:
|
||
ip_last_ts[sip] = tv
|
||
except Exception:
|
||
pass
|
||
|
||
# 取每 IP 最常见的值
|
||
def _most_common(lst: list, top_n: int = 8) -> list:
|
||
if not lst: return []
|
||
cnt = Counter(lst)
|
||
return [{'value': k, 'count': v} for k, v in cnt.most_common(top_n)]
|
||
|
||
def _top1(lst: list) -> str:
|
||
if not lst: return ''
|
||
return Counter(lst).most_common(1)[0][0]
|
||
|
||
for ip, hosts in ip_hosts.items():
|
||
ip_hosts[ip] = _top1(hosts) if isinstance(hosts, list) else hosts
|
||
for ip, orgs in ip_orgs.items():
|
||
ip_orgs[ip] = _top1(orgs) if isinstance(orgs, list) else orgs
|
||
|
||
# ── 5. 构建节点列表 ──────────────────────────────────────────────
|
||
# Build peer IP lists from raw src/dst pairs
|
||
for i in range(min(len(df), 50000)):
|
||
sip = str(src_ips[i]) if i < len(src_ips) and src_ips[i] not in (None, '', 'None') else None
|
||
dip = str(dst_ips[i]) if i < len(dst_ips) and dst_ips[i] not in (None, '', 'None') else None
|
||
if sip and dip:
|
||
ip_peers.setdefault(sip, set()).add(dip)
|
||
ip_peers.setdefault(dip, set()).add(sip)
|
||
|
||
nodes = []
|
||
for ip in unique_ips:
|
||
info = node_geo.get(ip, {})
|
||
cid = cluster_labels.get(ip, -1)
|
||
peer_set = ip_peers.get(ip, set())
|
||
dur_list = ip_durations.get(ip, [])
|
||
conn_total = ip_conn_total.get(ip, 0)
|
||
conn_ok = ip_conn_ok.get(ip, 0)
|
||
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,
|
||
'host': ip_hosts.get(ip, info.get('host', '')),
|
||
'org': ip_orgs.get(ip, info.get('org', '')),
|
||
'texts': ip_texts.get(ip, [])[:3],
|
||
'peer_ips': sorted(peer_set)[:20],
|
||
'peer_count': len(peer_set),
|
||
# Enriched attributes
|
||
'top_snis': _most_common(ip_snis.get(ip, []), 5),
|
||
'top_tls_vers': _most_common(ip_tls_vers.get(ip, []), 5),
|
||
'top_ciphers': _most_common(ip_ciphers.get(ip, []), 5),
|
||
'top_ports': _most_common(ip_dports.get(ip, []), 5),
|
||
'top_categories': _most_common(ip_categories.get(ip, []), 5),
|
||
'total_bytes': round(ip_total_bytes.get(ip, 0.0), 2),
|
||
'total_packets': ip_total_pkts.get(ip, 0),
|
||
'avg_duration': round(sum(dur_list) / len(dur_list), 2) if dur_list else None,
|
||
'conn_success_rate': round(conn_ok / conn_total * 100, 1) if conn_total > 0 else None,
|
||
'conn_count': conn_total,
|
||
'first_seen': ip_first_ts.get(ip, ''),
|
||
'last_seen': ip_last_ts.get(ip, ''),
|
||
})
|
||
|
||
# ── 6. 构建边列表(IP 对聚合通信统计) ──────────────────────────
|
||
pair_data: dict[tuple[str, str], dict] = {}
|
||
|
||
# Pre-extract column lists
|
||
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
|
||
|
||
pkts_list = None
|
||
if packets_col and packets_col in df.columns:
|
||
try:
|
||
pkts_list = df[packets_col].cast(pl.Float64).to_list()
|
||
except Exception:
|
||
pass
|
||
|
||
dur_list = None
|
||
if dur_col and dur_col in df.columns:
|
||
try:
|
||
dur_list = df[dur_col].cast(pl.Float64).to_list()
|
||
except Exception:
|
||
pass
|
||
|
||
dport_list = None
|
||
if dport_col and dport_col in df.columns:
|
||
try:
|
||
dport_list = df[dport_col].cast(pl.Utf8).to_list()
|
||
except Exception:
|
||
pass
|
||
|
||
tls_ver_list = None
|
||
if tls_ver_col and tls_ver_col in df.columns:
|
||
try:
|
||
tls_ver_list = df[tls_ver_col].cast(pl.Utf8).to_list()
|
||
except Exception:
|
||
pass
|
||
|
||
cph_list = None
|
||
if tls_cph_col and tls_cph_col in df.columns:
|
||
try:
|
||
cph_list = df[tls_cph_col].cast(pl.Utf8).to_list()
|
||
except Exception:
|
||
pass
|
||
|
||
snam_list = None
|
||
if snam_col and snam_col in df.columns:
|
||
try:
|
||
snam_list = df[snam_col].cast(pl.Utf8).to_list()
|
||
except Exception:
|
||
pass
|
||
|
||
cnam_list = None
|
||
if cnam_col and cnam_col in df.columns:
|
||
try:
|
||
cnam_list = df[cnam_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, 'total_packets': 0.0,
|
||
'times': [], 'durations': [], 'ports': [],
|
||
'tls_vers': [], 'ciphers': [], 'snis': [], 'categories': [],
|
||
}
|
||
pair_data[key]['count'] += 1
|
||
if bytes_list and i < len(bytes_list):
|
||
try:
|
||
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)
|
||
if pkts_list and i < len(pkts_list):
|
||
try:
|
||
pair_data[key]['total_packets'] += float(pkts_list[i] or 0)
|
||
except (ValueError, TypeError):
|
||
pass
|
||
if dur_list and i < len(dur_list):
|
||
try:
|
||
dv = float(dur_list[i] or 0)
|
||
if dv > 0:
|
||
pair_data[key]['durations'].append(dv)
|
||
except (ValueError, TypeError):
|
||
pass
|
||
if dport_list and i < len(dport_list):
|
||
pv = dport_list[i]
|
||
if pv and pv not in ('None', '', 'nan'):
|
||
pair_data[key]['ports'].append(pv)
|
||
if tls_ver_list and i < len(tls_ver_list):
|
||
tv = tls_ver_list[i]
|
||
if tv and tv not in ('None', '', 'nan'):
|
||
pair_data[key]['tls_vers'].append(tv)
|
||
if cph_list and i < len(cph_list):
|
||
cv = cph_list[i]
|
||
if cv and cv not in ('None', '', 'nan'):
|
||
pair_data[key]['ciphers'].append(cv[:50])
|
||
if snam_list and i < len(snam_list):
|
||
sv = snam_list[i]
|
||
if sv and sv not in ('None', '', 'nan', '+', '-'):
|
||
pair_data[key]['snis'].append(sv)
|
||
if cnam_list and i < len(cnam_list):
|
||
catv = cnam_list[i]
|
||
if catv and catv not in ('None', '', 'nan', '+', '-'):
|
||
pair_data[key]['categories'].append(catv)
|
||
|
||
# Build IP metadata lookup for edges
|
||
ip_meta = {}
|
||
for n in nodes:
|
||
ip_meta[n['ip']] = n
|
||
|
||
edges = []
|
||
for (src, dst), data in pair_data.items():
|
||
sm = ip_meta.get(src, {})
|
||
dm = ip_meta.get(dst, {})
|
||
durs = data.get('durations', [])
|
||
edge = {
|
||
'source': src,
|
||
'target': dst,
|
||
'comm_count': data['count'],
|
||
'total_bytes': round(data['total_bytes'], 2),
|
||
'total_packets': round(data.get('total_packets', 0), 2),
|
||
'avg_duration': round(sum(durs) / len(durs), 2) if durs else None,
|
||
'top_ports': _most_common(data.get('ports', []), 5),
|
||
'top_tls_vers': _most_common(data.get('tls_vers', []), 5),
|
||
'top_ciphers': _most_common(data.get('ciphers', []), 5),
|
||
'top_snis': _most_common(data.get('snis', []), 5),
|
||
'top_categories': _most_common(data.get('categories', []), 5),
|
||
'src_host': sm.get('host', ''),
|
||
'dst_host': dm.get('host', ''),
|
||
'src_org': sm.get('org', ''),
|
||
'dst_org': dm.get('org', ''),
|
||
'src_city': sm.get('city', ''),
|
||
'dst_city': dm.get('city', ''),
|
||
'src_country': sm.get('country', ''),
|
||
'dst_country': dm.get('country', ''),
|
||
'src_cluster': sm.get('cluster_id', -1),
|
||
'dst_cluster': dm.get('cluster_id', -1),
|
||
}
|
||
# 时间统计
|
||
times = data.get('times', [])
|
||
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)
|
||
else:
|
||
edge['first_seen'] = ''
|
||
edge['last_seen'] = ''
|
||
edge['peak_hours'] = ''
|
||
edge['time_count'] = 0
|
||
edges.append(edge)
|
||
|
||
# 按通信次数排序,限制返回前 500 条边
|
||
edges.sort(key=lambda e: e['comm_count'], reverse=True)
|
||
edges = edges[:500]
|
||
|
||
# ── 7. 存储结果 ──────────────────────────────────────────────────
|
||
n_clusters = len(cluster_info)
|
||
|
||
entry['cluster_labels'] = cluster_labels # ip → cluster_id
|
||
entry['cluster_map'] = cluster_info
|
||
entry['cluster_mode'] = 'network'
|
||
entry['n_clusters'] = n_clusters
|
||
entry['nodes'] = nodes
|
||
entry['edges_data'] = edges
|
||
entry['cluster_info'] = {'note': ''}
|
||
|
||
return {
|
||
'n_clusters': n_clusters,
|
||
'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()},
|
||
'note': f'{n_nodes} 个IP节点, {len(edges)} 条边, {n_clusters} 个社区',
|
||
}
|
||
|
||
|
||
# ═══════════════════════════════════════════════════════════════════════════════
|
||
# Step 4: Cluster detail + Node detail + 边关系
|
||
# ═══════════════════════════════════════════════════════════════════════════════
|
||
|
||
@require_GET
|
||
def cluster_detail(request, label: int):
|
||
"""返回指定簇的详细画像(节点列表、GeoIP 属性、流量特征等)。"""
|
||
session_id = request.GET.get('session_id')
|
||
if not session_id:
|
||
return JsonResponse({'error': '缺少 session_id'}, 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', [])
|
||
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', {})
|
||
geo_cache = entry.get('geo_cache', {})
|
||
|
||
if df is None:
|
||
return JsonResponse({'error': '数据未就绪'}, status=400)
|
||
if '_cluster_label' not in df.columns:
|
||
return JsonResponse({'error': '未执行聚类'}, status=400)
|
||
|
||
cluster_df = df.filter(pl.col('_cluster_label') == label)
|
||
rows = len(cluster_df)
|
||
if rows == 0:
|
||
return JsonResponse({'error': f'簇 {label} 无数据'}, status=404)
|
||
|
||
cluster_info = cluster_map.get(label, {})
|
||
|
||
def _top_values(col_name: str, top_n: int = 5) -> list:
|
||
if not col_name or col_name not in df.columns:
|
||
return []
|
||
try:
|
||
vals = (
|
||
cluster_df[col_name].cast(pl.Utf8).fill_null('(空)')
|
||
.value_counts().sort('count', descending=True).head(top_n)
|
||
)
|
||
total = vals['count'].sum()
|
||
result = []
|
||
for row in vals.iter_rows():
|
||
result.append({
|
||
'value': str(row[0])[:80],
|
||
'count': int(row[1]),
|
||
'pct': round(int(row[1]) / total * 100, 1) if total else 0,
|
||
})
|
||
return result
|
||
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')
|
||
snam_col = meta.get('snam_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')
|
||
|
||
unique_ips = []
|
||
if ips_col and ips_col in cluster_df.columns:
|
||
try:
|
||
ip_vals = cluster_df[ips_col].cast(pl.Utf8).unique().to_list()
|
||
unique_ips = sorted(str(v) for v in ip_vals if v not in (None, '', 'None'))
|
||
except Exception:
|
||
pass
|
||
|
||
ip_attrs = []
|
||
for ip in unique_ips[:50]:
|
||
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', ''),
|
||
})
|
||
|
||
profile = {
|
||
'label': label, 'size': rows,
|
||
'cluster_info': cluster_info,
|
||
'unique_ips': unique_ips[:100],
|
||
'unique_ips_count': len(unique_ips),
|
||
'ip_attributes': ip_attrs,
|
||
'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_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 [],
|
||
'top_cities': _top_values(city_col) if city_col else [],
|
||
'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)
|
||
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]}
|
||
except Exception:
|
||
pass
|
||
|
||
return JsonResponse(profile)
|
||
|
||
|
||
@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 with metadata
|
||
peers = set()
|
||
for e in related:
|
||
if e.get('source') == ip:
|
||
peers.add(e.get('target'))
|
||
else:
|
||
peers.add(e.get('source'))
|
||
|
||
# Enrich related edges with peer metadata
|
||
enriched_edges = []
|
||
for e in related[:100]:
|
||
ee = dict(e)
|
||
if e.get('source') == ip:
|
||
ee['peer_ip'] = e.get('target', '')
|
||
ee['peer_host'] = e.get('dst_host', '')
|
||
ee['peer_org'] = e.get('dst_org', '')
|
||
ee['peer_city'] = e.get('dst_city', '')
|
||
else:
|
||
ee['peer_ip'] = e.get('source', '')
|
||
ee['peer_host'] = e.get('src_host', '')
|
||
ee['peer_org'] = e.get('src_org', '')
|
||
ee['peer_city'] = e.get('src_city', '')
|
||
enriched_edges.append(ee)
|
||
|
||
total_bytes = sum(e.get('total_bytes', 0) or 0 for e in related)
|
||
|
||
return JsonResponse({
|
||
'node': node,
|
||
'node_texts': node.get('texts', []),
|
||
'related_edges': enriched_edges,
|
||
'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:
|
||
return JsonResponse({'error': '数据未就绪'}, status=400)
|
||
|
||
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)
|
||
if ips_col not in df.columns or ipd_col not in df.columns:
|
||
return JsonResponse({'error': 'IP列不在数据中'}, status=400)
|
||
|
||
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 bytes_list and i < len(bytes_list):
|
||
try:
|
||
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
|
||
edge = {
|
||
'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['comm_count'], reverse=True)
|
||
edges = edges[:200]
|
||
|
||
return JsonResponse({
|
||
'edges': edges,
|
||
'total_pairs': len(pair_counts),
|
||
'total_edges': len(edges),
|
||
'min_freq': min_freq,
|
||
})
|
||
except Exception as e:
|
||
return JsonResponse({'error': f'边关系计算失败: {e}'}, status=400)
|
||
|
||
|
||
@csrf_exempt
|
||
@require_POST
|
||
def edge_time_series(request):
|
||
"""返回指定边的时间序列数据(按时间桶聚合),用于绘制流量-时间图。
|
||
|
||
POST body: session_id, source, target, bucket (可选: 'auto'|'hour'|'minute'|'day')
|
||
返回: {time_series: [{t: "yyyy-mm-dd HH:MM (北京)", bytes: N, count: N}, ...], span_hours: N}
|
||
"""
|
||
body = json.loads(request.body)
|
||
session_id = body.get('session_id')
|
||
source = body.get('source', '')
|
||
target = body.get('target', '')
|
||
bucket = body.get('bucket', 'auto')
|
||
|
||
if not session_id or not source or not target:
|
||
return JsonResponse({'error': '缺少 session_id / source / target'}, status=400)
|
||
|
||
entry = _cached(session_id)
|
||
if entry is None:
|
||
return JsonResponse({'error': '会话已过期'}, status=400)
|
||
|
||
df = entry.get('filtered_df') if entry.get('filtered_df') is not None else entry.get('df')
|
||
if df is None:
|
||
return JsonResponse({'error': '数据未就绪'}, status=400)
|
||
|
||
meta = entry.get('meta', {})
|
||
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)
|
||
if not time_col or time_col not in df.columns:
|
||
return JsonResponse({'error': '缺少时间列,无法绘制时间序列'}, status=400)
|
||
|
||
# ── 1. 筛选该边的所有行 ──
|
||
try:
|
||
mask = (
|
||
(df[ips_col].cast(pl.Utf8) == source) &
|
||
(df[ipd_col].cast(pl.Utf8) == target)
|
||
)
|
||
edge_rows = df.filter(mask)
|
||
except Exception as e:
|
||
return JsonResponse({'error': f'数据筛选失败: {e}'}, status=400)
|
||
|
||
n_rows = len(edge_rows)
|
||
if n_rows == 0:
|
||
return JsonResponse({'error': '未找到该边的原始记录'}, status=400)
|
||
|
||
# ── 2. 提取时间列并转为 datetime ──
|
||
try:
|
||
times_series = edge_rows[time_col].cast(pl.Utf8)
|
||
except Exception:
|
||
return JsonResponse({'error': '时间列读取失败'}, status=400)
|
||
|
||
# 提取 bytes(如果有的话)
|
||
has_bytes = bytes_col and bytes_col in df.columns
|
||
bytes_series = None
|
||
if has_bytes:
|
||
try:
|
||
bytes_series = edge_rows[bytes_col].cast(pl.Float64)
|
||
except Exception:
|
||
has_bytes = False
|
||
|
||
# ── 3. 解析时间戳为 datetime ──
|
||
raw_times = times_series.to_list()
|
||
raw_bytes = bytes_series.to_list() if has_bytes else []
|
||
parsed_times = []
|
||
for row_idx, t_str in enumerate(raw_times):
|
||
try:
|
||
t_clean = str(t_str).strip() if t_str else ''
|
||
if not t_clean or t_clean in ('None', '', 'nan'):
|
||
continue
|
||
# 尝试多种格式
|
||
parsed = None
|
||
for fmt in [
|
||
'%Y-%m-%dT%H:%M:%S', '%Y-%m-%d %H:%M:%S',
|
||
'%Y-%m-%dT%H:%M:%S.%f', '%Y-%m-%d %H:%M:%S.%f',
|
||
'%Y/%m/%d %H:%M:%S', '%Y/%m/%dT%H:%M:%S',
|
||
'%d/%m/%Y %H:%M:%S', '%m/%d/%Y %H:%M:%S',
|
||
]:
|
||
try:
|
||
parsed = time.strptime(t_clean[:26], fmt)
|
||
break
|
||
except ValueError:
|
||
continue
|
||
if parsed is None:
|
||
# Fallback: try to extract Y-M-D H:M:S via regex
|
||
m = re.search(r'(\d{4})-(\d{2})-(\d{2})[T ](\d{2}):(\d{2}):(\d{2})', t_clean)
|
||
if m:
|
||
parsed = time.strptime(f"{m.group(1)}-{m.group(2)}-{m.group(3)} "
|
||
f"{m.group(4)}:{m.group(5)}:{m.group(6)}",
|
||
'%Y-%m-%d %H:%M:%S')
|
||
if parsed is None:
|
||
continue
|
||
# 转为北京时间 (UTC+8)
|
||
import calendar
|
||
ts_utc = calendar.timegm(parsed)
|
||
ts_bj = ts_utc + 8 * 3600 # 北京时间 = UTC+8
|
||
parsed_times.append((ts_bj, row_idx))
|
||
except Exception:
|
||
continue
|
||
|
||
if not parsed_times:
|
||
return JsonResponse({'error': '无法解析时间戳'}, status=400)
|
||
|
||
# ── 4. 确定时间桶粒度 ──
|
||
min_ts = min(t[0] for t in parsed_times)
|
||
max_ts = max(t[0] for t in parsed_times)
|
||
span_seconds = max_ts - min_ts
|
||
span_hours = span_seconds / 3600.0
|
||
|
||
if bucket == 'auto':
|
||
if span_hours <= 2:
|
||
bucket_seconds = 60 # 1 分钟桶
|
||
elif span_hours <= 24:
|
||
bucket_seconds = 300 # 5 分钟桶
|
||
elif span_hours <= 72:
|
||
bucket_seconds = 900 # 15 分钟桶
|
||
elif span_hours <= 168:
|
||
bucket_seconds = 3600 # 1 小时桶
|
||
else:
|
||
bucket_seconds = 14400 # 4 小时桶
|
||
elif bucket == 'minute':
|
||
bucket_seconds = 60
|
||
elif bucket == 'hour':
|
||
bucket_seconds = 3600
|
||
elif bucket == 'day':
|
||
bucket_seconds = 86400
|
||
else:
|
||
bucket_seconds = 300
|
||
|
||
# ── 5. 按时间桶聚合 ──
|
||
bucket_data: dict[int, dict] = {} # bucket_start_ts → {count, bytes}
|
||
for ts_bj, idx in parsed_times:
|
||
bucket_key = int(ts_bj // bucket_seconds) * bucket_seconds
|
||
if bucket_key not in bucket_data:
|
||
bucket_data[bucket_key] = {'count': 0, 'bytes': 0.0}
|
||
bucket_data[bucket_key]['count'] += 1
|
||
if has_bytes and idx < len(raw_bytes):
|
||
try:
|
||
bval = raw_bytes[idx]
|
||
if bval is not None:
|
||
bucket_data[bucket_key]['bytes'] += float(bval)
|
||
except Exception:
|
||
pass
|
||
|
||
# ── 6. 构建输出 ──
|
||
def fmt_bj(ts: int, bucket_sec: int) -> str:
|
||
"""格式化北京时间字符串。"""
|
||
tm = time.gmtime(ts)
|
||
if bucket_sec >= 86400:
|
||
return time.strftime('%m-%d', tm)
|
||
elif bucket_sec >= 3600:
|
||
return time.strftime('%m-%d %H:00', tm)
|
||
elif bucket_sec >= 60:
|
||
return time.strftime('%m-%d %H:%M', tm)
|
||
else:
|
||
return time.strftime('%H:%M:%S', tm)
|
||
|
||
time_series = []
|
||
for ts_key in sorted(bucket_data.keys()):
|
||
d = bucket_data[ts_key]
|
||
time_series.append({
|
||
't': fmt_bj(ts_key, bucket_seconds),
|
||
'ts': ts_key,
|
||
'count': d['count'],
|
||
'bytes': round(d['bytes'], 2),
|
||
})
|
||
|
||
return JsonResponse({
|
||
'time_series': time_series,
|
||
'span_hours': round(span_hours, 2),
|
||
'bucket_minutes': bucket_seconds // 60,
|
||
'n_raw': len(parsed_times),
|
||
'has_bytes': has_bytes,
|
||
})
|
||
|
||
|
||
@csrf_exempt
|
||
@require_POST
|
||
def cluster_nodes_edges(request):
|
||
"""返回指定簇的所有节点、内部边、关联外部边及邻接节点。
|
||
|
||
POST body: {session_id, cluster_label}
|
||
返回: {nodes, internal_edges, external_edges, adjacent_nodes, geo_bounds, cluster_info}
|
||
"""
|
||
body = json.loads(request.body)
|
||
session_id = body.get('session_id')
|
||
cluster_label = body.get('cluster_label')
|
||
|
||
if not session_id or cluster_label is None:
|
||
return JsonResponse({'error': '缺少 session_id / cluster_label'}, 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', [])
|
||
cluster_info = entry.get('cluster_map', {}).get(str(cluster_label), {})
|
||
|
||
if not nodes:
|
||
return JsonResponse({'error': '无节点数据'}, status=400)
|
||
|
||
# ── 1. 筛选簇内节点 ──
|
||
cluster_nodes = [n for n in nodes if n.get('cluster_id') == cluster_label]
|
||
if not cluster_nodes:
|
||
return JsonResponse({'error': f'簇 {cluster_label} 无数据'}, status=404)
|
||
|
||
cluster_ips = set(n['ip'] for n in cluster_nodes)
|
||
|
||
# ── 2. 查找所有关联边(至少一端在簇内) ──
|
||
internal_edges = []
|
||
external_edges = []
|
||
adjacent_ip_set = set()
|
||
|
||
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:
|
||
external_edges.append(e)
|
||
adjacent_ip_set.add(e['target'])
|
||
elif dst_in:
|
||
external_edges.append(e)
|
||
adjacent_ip_set.add(e['source'])
|
||
|
||
# ── 3. 查找邻接节点 ──
|
||
adjacent_nodes = [n for n in nodes if n['ip'] in adjacent_ip_set]
|
||
|
||
# ── 4. 计算 Geo bounds ──
|
||
lats = [n.get('lat') for n in cluster_nodes if n.get('lat') is not None]
|
||
lons = [n.get('lon') for n in cluster_nodes if n.get('lon') is not None]
|
||
lats += [n.get('lat') for n in adjacent_nodes if n.get('lat') is not None]
|
||
lons += [n.get('lon') for n in adjacent_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),
|
||
}
|
||
|
||
# ── 5. 簇级别统计 ──
|
||
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_bytes = sum(e.get('total_bytes', 0) or 0 for e in internal_edges)
|
||
total_external_bytes = sum(e.get('total_bytes', 0) or 0 for e in external_edges)
|
||
|
||
return JsonResponse({
|
||
'cluster_label': cluster_label,
|
||
'cluster_info': cluster_info,
|
||
'nodes': cluster_nodes,
|
||
'n_nodes': len(cluster_nodes),
|
||
'internal_edges': internal_edges,
|
||
'external_edges': external_edges,
|
||
'n_internal_edges': len(internal_edges),
|
||
'n_external_edges': len(external_edges),
|
||
'adjacent_nodes': adjacent_nodes,
|
||
'n_adjacent_nodes': len(adjacent_nodes),
|
||
'geo_bounds': geo_bounds,
|
||
'top_isps': _dist(cluster_nodes, 'isp'),
|
||
'top_cities': _dist(cluster_nodes, 'city'),
|
||
'top_countries': _dist(cluster_nodes, 'country'),
|
||
'total_internal_bytes': round(total_internal_bytes, 2),
|
||
'total_external_bytes': round(total_external_bytes, 2),
|
||
})
|
||
|
||
|
||
@require_GET
|
||
def column_values(request):
|
||
"""返回指定列的唯一值 Top-50(供筛选器下拉建议)。
|
||
|
||
支持两种模式:
|
||
- session_id:从已上传的数据中查(全量,精确 Top-K)
|
||
- scan_id:从已保存的文件中 streaming 单列扫描(低内存,仅读一列)
|
||
"""
|
||
session_id = request.GET.get('session_id', '')
|
||
scan_id = request.GET.get('scan_id', '')
|
||
col_name = request.GET.get('col', '')
|
||
|
||
if not col_name:
|
||
return JsonResponse({'error': '缺少 col'}, status=400)
|
||
|
||
# ── Mode 1: session_id (full data in memory) ──────────────────────
|
||
if session_id:
|
||
entry = _cached(session_id)
|
||
if entry is None:
|
||
return JsonResponse({'error': '会话已过期'}, status=400)
|
||
df = entry.get('df')
|
||
if df is None or col_name not in df.columns:
|
||
return JsonResponse({'error': '列不存在'}, status=400)
|
||
try:
|
||
values = (
|
||
df[col_name]
|
||
.cast(pl.Utf8)
|
||
.fill_null('(空)')
|
||
.value_counts(sort=True)
|
||
.head(50)
|
||
)
|
||
result = [
|
||
{'value': str(row[0])[:80], 'count': int(row[1])}
|
||
for row in values.iter_rows()
|
||
]
|
||
return JsonResponse({'col': col_name, 'values': result, 'total': len(result)})
|
||
except Exception as e:
|
||
return JsonResponse({'error': str(e)}, status=400)
|
||
|
||
# ── Mode 2: scan_id (streaming single-column scan) ────────────────
|
||
if scan_id:
|
||
entry = _cached(scan_id)
|
||
if entry is None:
|
||
return JsonResponse({'error': '扫描会话已过期,请重新选择文件'}, status=400)
|
||
|
||
scan_paths = entry.get('scan_paths', [])
|
||
col_map = entry.get('col_map', {})
|
||
|
||
if not scan_paths:
|
||
return JsonResponse({'error': '扫描文件已清理,请重新选择文件'}, status=400)
|
||
|
||
# Resolve the original column name from col_map
|
||
# col_map is {original → std}, we need original for the file
|
||
original_col = col_name
|
||
for orig, std in col_map.items():
|
||
if std == col_name:
|
||
original_col = orig
|
||
break
|
||
|
||
try:
|
||
# Streaming scan — only reads ONE column, O(1 col) memory
|
||
lf = pl.scan_csv(scan_paths[0], infer_schema_length=0)
|
||
if len(scan_paths) > 1:
|
||
frames = [pl.scan_csv(p, infer_schema_length=0) for p in scan_paths]
|
||
lf = pl.concat(frames, how='diagonal_relaxed')
|
||
|
||
# Select only the target column (by original name)
|
||
if original_col in lf.columns:
|
||
col_expr = pl.col(original_col)
|
||
elif col_name in lf.columns:
|
||
col_expr = pl.col(col_name)
|
||
else:
|
||
return JsonResponse({'error': f'列 "{col_name}" 不在文件中'}, status=400)
|
||
|
||
# Streaming Top-50 value counts
|
||
values = (
|
||
lf.select(col_expr.cast(pl.Utf8).fill_null('(空)').alias('_val'))
|
||
.collect(streaming=True)
|
||
.to_series()
|
||
.value_counts(sort=True)
|
||
.head(50)
|
||
)
|
||
|
||
result = [
|
||
{'value': str(row[0])[:80], 'count': int(row[1])}
|
||
for row in values.iter_rows()
|
||
]
|
||
return JsonResponse({'col': col_name, 'values': result, 'total': len(result)})
|
||
except Exception as e:
|
||
return JsonResponse({'error': f'列扫描失败: {e}'}, status=400)
|
||
|
||
return JsonResponse({'error': '缺少 session_id 或 scan_id'}, status=400)
|
||
|
||
|
||
@require_GET
|
||
def geoip_status(request):
|
||
"""返回当前 GeoIP 数据库状态。"""
|
||
from analysis import geoip
|
||
return JsonResponse({
|
||
'available': geoip.is_available(),
|
||
'source': geoip.source_name(),
|
||
'using_mmdb': 'mmdb' in geoip.source_name().lower(),
|
||
})
|
||
|
||
|
||
# ═══════════════════════════════════════════════════════════════════════════════
|
||
# Traffic timeline — dynamic flow animation
|
||
# ═══════════════════════════════════════════════════════════════════════════════
|
||
|
||
@csrf_exempt
|
||
@require_POST
|
||
def traffic_timeline_view(request):
|
||
"""Return time-windowed traffic data for global flow animation.
|
||
|
||
POST body: {session_id, window_seconds (10|30|60|300|600), max_edges (default 500)}
|
||
Returns per-window edge activity and node activity.
|
||
"""
|
||
body = json.loads(request.body)
|
||
session_id = body.get('session_id')
|
||
window_sec = int(body.get('window_seconds', 60))
|
||
max_edges = int(body.get('max_edges', 500))
|
||
|
||
if not session_id:
|
||
return JsonResponse({'error': '缺少 session_id'}, status=400)
|
||
if window_sec not in (10, 30, 60, 300, 600):
|
||
window_sec = 60
|
||
|
||
entry = _cached(session_id)
|
||
if entry is None:
|
||
return JsonResponse({'error': '会话已过期'}, status=400)
|
||
|
||
df = entry.get('filtered_df') if entry.get('filtered_df') is not None else entry.get('df')
|
||
if df is None:
|
||
return JsonResponse({'error': '数据未就绪'}, status=400)
|
||
meta = entry.get('meta', {})
|
||
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 or not time_col:
|
||
return JsonResponse({'error': '缺少IP或时间列'}, status=400)
|
||
|
||
try:
|
||
# Extract columns
|
||
src_list = df[ips_col].cast(pl.Utf8).to_list()
|
||
dst_list = df[ipd_col].cast(pl.Utf8).to_list()
|
||
time_list = df[time_col].cast(pl.Utf8).to_list()
|
||
bytes_raw = None
|
||
if bytes_col and bytes_col in df.columns:
|
||
try:
|
||
bytes_raw = df[bytes_col].cast(pl.Float64).to_list()
|
||
except Exception:
|
||
pass
|
||
|
||
# Parse timestamps → Beijing time Unix seconds
|
||
import calendar as cal_mod
|
||
bj_timestamps = []
|
||
for t_str in time_list:
|
||
try:
|
||
t_clean = str(t_str).strip() if t_str else ''
|
||
if not t_clean or t_clean in ('None', '', 'nan'):
|
||
continue
|
||
# Try ISO format or 'YYYY-MM-DD HH:MM:SS' format
|
||
for fmt in ['%Y-%m-%dT%H:%M:%S', '%Y-%m-%d %H:%M:%S',
|
||
'%Y-%m-%dT%H:%M:%S.%f', '%Y-%m-%d %H:%M:%S.%f']:
|
||
try:
|
||
parsed = time.strptime(t_clean[:26], fmt)
|
||
bj_ts = int(cal_mod.timegm(parsed)) + 8 * 3600
|
||
bj_timestamps.append(bj_ts)
|
||
break
|
||
except ValueError:
|
||
continue
|
||
except Exception:
|
||
continue
|
||
|
||
if not bj_timestamps:
|
||
return JsonResponse({'error': '无法解析时间戳'}, status=400)
|
||
|
||
min_ts = float(min(bj_timestamps))
|
||
max_ts = float(max(bj_timestamps))
|
||
|
||
# Build time windows
|
||
windows = []
|
||
t = min_ts
|
||
while t <= max_ts:
|
||
windows.append(t)
|
||
t += window_sec
|
||
|
||
# Aggregate per window
|
||
window_data = []
|
||
for wi, w_start in enumerate(windows):
|
||
w_end = w_start + window_sec
|
||
# Find rows in this window
|
||
edge_map = {} # (src,dst) → {count, bytes}
|
||
active_nodes = set()
|
||
row_count = 0
|
||
for idx, ts in enumerate(bj_timestamps):
|
||
if wi < len(windows) - 1 and ts >= w_end:
|
||
continue
|
||
if wi > 0 and ts < w_start:
|
||
continue
|
||
|
||
# In-window row
|
||
s = str(src_list[idx]) if idx < len(src_list) and src_list[idx] not in (None, '', 'None') else '?'
|
||
d = str(dst_list[idx]) if idx < len(dst_list) and dst_list[idx] not in (None, '', 'None') else '?'
|
||
key = (s, d)
|
||
if key not in edge_map:
|
||
edge_map[key] = {'count': 0, 'bytes': 0.0}
|
||
edge_map[key]['count'] += 1
|
||
if bytes_raw and idx < len(bytes_raw):
|
||
try:
|
||
edge_map[key]['bytes'] += float(bytes_raw[idx] or 0)
|
||
except Exception:
|
||
pass
|
||
active_nodes.add(s)
|
||
active_nodes.add(d)
|
||
row_count += 1
|
||
|
||
# Sort edges by activity
|
||
edges = []
|
||
for (src, dst), v in sorted(edge_map.items(), key=lambda x: -x[1]['count'])[:max_edges]:
|
||
edges.append({
|
||
'source': src,
|
||
'target': dst,
|
||
'count': v['count'],
|
||
'bytes': round(v['bytes'], 2),
|
||
})
|
||
|
||
# Node activity
|
||
node_activity = {}
|
||
nodes_data = entry.get('nodes', [])
|
||
for n in nodes_data:
|
||
ip = n.get('ip', '')
|
||
if ip in active_nodes:
|
||
node_activity[ip] = {'active': True}
|
||
|
||
if edges or active_nodes:
|
||
window_data.append({
|
||
'ts': w_start,
|
||
'ts_end': w_end,
|
||
't_label': time.strftime('%H:%M:%S', time.gmtime(w_start)),
|
||
'edges': edges,
|
||
'active_nodes': list(active_nodes)[:1000],
|
||
'row_count': row_count,
|
||
})
|
||
|
||
# Node coordinates for map
|
||
nodes_coords = {}
|
||
nodes_data = entry.get('nodes', [])
|
||
for n in nodes_data:
|
||
if n.get('lat') is not None and n.get('lon') is not None:
|
||
nodes_coords[n['ip']] = [n['lat'], n['lon']]
|
||
|
||
# ── Per-edge lifecycle: first_ts, last_ts (continuous rendering) ──
|
||
# Parse timestamps with microsecond precision using datetime
|
||
from datetime import datetime, timezone, timedelta
|
||
BJ_TZ = timezone(timedelta(hours=8))
|
||
bj_floats = []
|
||
for t_str in time_list:
|
||
try:
|
||
t_clean = str(t_str).strip() if t_str else ''
|
||
if not t_clean or t_clean in ('None', '', 'nan'):
|
||
bj_floats.append(None); continue
|
||
# Try common formats
|
||
dt = None
|
||
for fmt in ['%Y-%m-%dT%H:%M:%S.%f', '%Y-%m-%d %H:%M:%S.%f',
|
||
'%Y-%m-%dT%H:%M:%S', '%Y-%m-%d %H:%M:%S']:
|
||
try:
|
||
dt = datetime.strptime(t_clean[:26], fmt)
|
||
break
|
||
except ValueError: continue
|
||
if dt is None: bj_floats.append(None); continue
|
||
# Convert to Beijing time Unix timestamp
|
||
bj_floats.append(dt.replace(tzinfo=BJ_TZ).timestamp())
|
||
except Exception:
|
||
bj_floats.append(None)
|
||
|
||
edge_lifecycle = {} # (src,dst) → {count,bytes,first_ts,last_ts}
|
||
for idx in range(len(src_list)):
|
||
ts = bj_floats[idx] if idx < len(bj_floats) else None
|
||
if ts is None: continue
|
||
s = str(src_list[idx]) if idx < len(src_list) and src_list[idx] not in (None, '', 'None') else '?'
|
||
d = str(dst_list[idx]) if idx < len(dst_list) and dst_list[idx] not in (None, '', 'None') else '?'
|
||
key = (s, d)
|
||
if key not in edge_lifecycle:
|
||
edge_lifecycle[key] = {'count': 0, 'bytes': 0.0, 'first_ts': ts, 'last_ts': ts}
|
||
e = edge_lifecycle[key]
|
||
e['count'] += 1
|
||
if bytes_raw and idx < len(bytes_raw):
|
||
try: e['bytes'] += float(bytes_raw[idx] or 0)
|
||
except Exception: pass
|
||
if ts < e['first_ts']: e['first_ts'] = ts
|
||
if ts > e['last_ts']: e['last_ts'] = ts
|
||
|
||
edges_flat = []
|
||
for (src, dst), e in sorted(edge_lifecycle.items(), key=lambda x: -x[1]['count'])[:max_edges]:
|
||
edges_flat.append({
|
||
'source': src, 'target': dst,
|
||
'count': e['count'], 'bytes': round(e['bytes'], 2),
|
||
'first_ts': e['first_ts'], 'last_ts': e['last_ts'],
|
||
})
|
||
|
||
return JsonResponse({
|
||
'edges': edges_flat,
|
||
'window_seconds': window_sec,
|
||
'min_ts': min_ts,
|
||
'max_ts': max_ts,
|
||
'nodes_coords': nodes_coords,
|
||
})
|
||
except Exception as e:
|
||
return JsonResponse({'error': f'时间线构建失败: {e}'}, status=400)
|
||
|
||
|
||
@csrf_exempt
|
||
@require_POST
|
||
def entity_traffic_timeline_view(request):
|
||
"""Return per-entity traffic timeline for local flow animation.
|
||
|
||
POST body: {session_id, entity_ip, window_seconds}
|
||
Returns timeline focused on one IP's related edges.
|
||
"""
|
||
body = json.loads(request.body)
|
||
session_id = body.get('session_id')
|
||
entity_ip = body.get('entity_ip', '')
|
||
window_sec = int(body.get('window_seconds', 60))
|
||
|
||
if not session_id or not entity_ip:
|
||
return JsonResponse({'error': '缺少 session_id / entity_ip'}, status=400)
|
||
if window_sec not in (10, 30, 60, 300, 600):
|
||
window_sec = 60
|
||
|
||
entry = _cached(session_id)
|
||
if entry is None:
|
||
return JsonResponse({'error': '会话已过期'}, status=400)
|
||
|
||
df = entry.get('filtered_df') if entry.get('filtered_df') is not None else entry.get('df')
|
||
if df is None:
|
||
return JsonResponse({'error': '数据未就绪'}, status=400)
|
||
meta = entry.get('meta', {})
|
||
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 or not time_col:
|
||
return JsonResponse({'error': '缺少IP或时间列'}, status=400)
|
||
|
||
try:
|
||
# Filter rows where entity_ip is src OR dst
|
||
src_list = df[ips_col].cast(pl.Utf8).to_list()
|
||
dst_list = df[ipd_col].cast(pl.Utf8).to_list()
|
||
time_list = df[time_col].cast(pl.Utf8).to_list()
|
||
bytes_raw = None
|
||
if bytes_col and bytes_col in df.columns:
|
||
try:
|
||
bytes_raw = df[bytes_col].cast(pl.Float64).to_list()
|
||
except Exception:
|
||
pass
|
||
|
||
# Parse timestamps for filtered rows
|
||
import calendar as cal_mod
|
||
filtered_data = [] # [(ts_bj, src, dst, bytes_val)]
|
||
for idx in range(len(df)):
|
||
s = str(src_list[idx]) if src_list[idx] not in (None, '', 'None') else ''
|
||
d = str(dst_list[idx]) if dst_list[idx] not in (None, '', 'None') else ''
|
||
if entity_ip not in (s, d):
|
||
continue
|
||
t_str = str(time_list[idx]) if time_list[idx] else ''
|
||
if not t_str or t_str in ('None', '', 'nan'):
|
||
continue
|
||
bj_ts = None
|
||
for fmt in ['%Y-%m-%dT%H:%M:%S', '%Y-%m-%d %H:%M:%S',
|
||
'%Y-%m-%dT%H:%M:%S.%f', '%Y-%m-%d %H:%M:%S.%f']:
|
||
try:
|
||
parsed = time.strptime(t_str[:26], fmt)
|
||
bj_ts = int(cal_mod.timegm(parsed)) + 8 * 3600
|
||
break
|
||
except ValueError:
|
||
continue
|
||
if bj_ts is None:
|
||
continue
|
||
bv = 0.0
|
||
if bytes_raw and idx < len(bytes_raw):
|
||
try:
|
||
bv = float(bytes_raw[idx] or 0)
|
||
except Exception:
|
||
pass
|
||
filtered_data.append((bj_ts, s, d, bv))
|
||
|
||
if not filtered_data:
|
||
return JsonResponse({'error': f'IP {entity_ip} 无时序数据'}, status=400)
|
||
|
||
min_ts = min(d[0] for d in filtered_data)
|
||
max_ts = max(d[0] for d in filtered_data)
|
||
|
||
# Build windows
|
||
windows = []
|
||
t = min_ts
|
||
while t <= max_ts:
|
||
windows.append(t)
|
||
t += window_sec
|
||
|
||
window_data = []
|
||
peer_set = set()
|
||
total_bytes = 0.0
|
||
total_count = 0
|
||
|
||
for wi, w_start in enumerate(windows):
|
||
w_end = w_start + window_sec
|
||
edge_map = {}
|
||
out_bytes = 0.0
|
||
in_bytes = 0.0
|
||
for (ts, s, d, bv) in filtered_data:
|
||
if wi < len(windows) - 1 and ts >= w_end:
|
||
continue
|
||
if wi > 0 and ts < w_start:
|
||
continue
|
||
key = (s, d)
|
||
if key not in edge_map:
|
||
edge_map[key] = {'count': 0, 'bytes': 0.0}
|
||
edge_map[key]['count'] += 1
|
||
edge_map[key]['bytes'] += bv
|
||
if s == entity_ip:
|
||
out_bytes += bv
|
||
if d == entity_ip:
|
||
in_bytes += bv
|
||
peer = d if s == entity_ip else s
|
||
peer_set.add(peer)
|
||
total_bytes += bv
|
||
total_count += 1
|
||
|
||
edges = []
|
||
for (src, dst), v in sorted(edge_map.items(), key=lambda x: -x[1]['count'])[:50]:
|
||
edges.append({
|
||
'source': src, 'target': dst,
|
||
'count': v['count'], 'bytes': round(v['bytes'], 2),
|
||
})
|
||
|
||
if edges:
|
||
window_data.append({
|
||
'ts': w_start,
|
||
't_label': time.strftime('%H:%M:%S', time.gmtime(w_start)),
|
||
'edges': edges,
|
||
'out_bytes': round(out_bytes, 2),
|
||
'in_bytes': round(in_bytes, 2),
|
||
})
|
||
|
||
# Aggregate stats
|
||
hour_counts = [0] * 24
|
||
for (ts, s, d, bv) in filtered_data:
|
||
tm = time.gmtime(ts)
|
||
hour_counts[tm.tm_hour] += 1
|
||
|
||
return JsonResponse({
|
||
'entity_ip': entity_ip,
|
||
'windows': window_data,
|
||
'window_seconds': window_sec,
|
||
'min_ts': min_ts,
|
||
'max_ts': max_ts,
|
||
'n_windows': len(window_data),
|
||
'n_peers': len(peer_set),
|
||
'total_bytes': round(total_bytes, 2),
|
||
'total_count': total_count,
|
||
'peers': sorted(peer_set)[:50],
|
||
'hourly_distribution': [{'hour': h, 'count': c} for h, c in enumerate(hour_counts)],
|
||
})
|
||
except Exception as e:
|
||
return JsonResponse({'error': f'实体时间线构建失败: {e}'}, status=400)
|
||
|
||
|
||
# ── Main page ────────────────────────────────────────────────────────────────
|
||
|
||
def index(request):
|
||
"""Render the simple analysis workbench page."""
|
||
return render(request, 'simple_analysis/simple_analysis.html')
|