feat: v3 refactor with MMDB GeoIP, network clustering, node/edge viz, offline tile cache, dual export
- GeoIP: MMDB (GeoLite2-City + ASN) support with text fallback - Upload: two-phase flow with advanced preset filter builder - Clustering: IP communication network community discovery (/24 + HDBSCAN) - Map: per-IP node rendering, node click detail, edge click detail - Offline: Cache API tile caching with fallback - Export: node table + edge table CSV download - Fix: null-guard DOM operations throughout Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
+177
-68
@@ -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 段。
|
MMDB 查询使用 ``maxminddb`` 库,文本格式使用二分查找 O(log N)。
|
||||||
可通过编辑 ``data/geoip_data.txt`` 扩展。
|
|
||||||
"""
|
"""
|
||||||
import bisect
|
import bisect
|
||||||
import logging
|
import logging
|
||||||
@@ -18,11 +19,13 @@ logger = logging.getLogger(__name__)
|
|||||||
# Data structures
|
# Data structures
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
# [(start_int, end_int, lat, lon, city, country), ...]
|
# MMDB readers (lazy-loaded, None if unavailable)
|
||||||
# Built at module load time from data/geoip_data.txt
|
_mmdb_city: Optional[object] = None # maxminddb.Reader for City DB
|
||||||
_GEO_DATA: list[tuple[int, int, float, float, str, str]] = []
|
_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] = []
|
_STARTS: list[int] = []
|
||||||
_RANGES: list[tuple[int, int, float, float, str, str]] = []
|
_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]:
|
def ip_to_int(ip_str: str) -> Optional[int]:
|
||||||
"""Convert an IPv4 dotted-string to its 32-bit integer representation.
|
"""Convert an IPv4 dotted-string to its 32-bit integer representation.
|
||||||
|
|
||||||
Returns ``None`` if *ip_str* is not a valid IPv4 address (including
|
Returns ``None`` if *ip_str* is not a valid IPv4 address.
|
||||||
when any octet is outside 0-255).
|
|
||||||
|
|
||||||
Examples
|
|
||||||
--------
|
|
||||||
>>> ip_to_int('8.8.8.8')
|
|
||||||
134744072
|
|
||||||
>>> ip_to_int('1.0.0.0')
|
|
||||||
16777216
|
|
||||||
"""
|
"""
|
||||||
parts = ip_str.strip().split('.')
|
parts = ip_str.strip().split('.')
|
||||||
if len(parts) != 4:
|
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]]]:
|
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:
|
if not _GEO_DATA:
|
||||||
return [], []
|
return [], []
|
||||||
|
|
||||||
sorted_data = sorted(_GEO_DATA, key=lambda x: x[0])
|
sorted_data = sorted(_GEO_DATA, key=lambda x: x[0])
|
||||||
starts = [entry[0] for entry in sorted_data]
|
starts = [entry[0] for entry in sorted_data]
|
||||||
return starts, sorted_data
|
return starts, sorted_data
|
||||||
|
|
||||||
|
|
||||||
def _load_data_file() -> None:
|
def _load_data_file() -> None:
|
||||||
"""Load IP range data from ``data/geoip_data.txt`` into ``_GEO_DATA``.
|
"""Load IP range data from ``data/geoip_data.txt`` (text fallback)."""
|
||||||
|
|
||||||
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
|
|
||||||
"""
|
|
||||||
data_path = Path(__file__).parent.parent / 'data' / 'geoip_data.txt'
|
data_path = Path(__file__).parent.parent / 'data' / 'geoip_data.txt'
|
||||||
|
|
||||||
if not data_path.exists():
|
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
|
return
|
||||||
|
|
||||||
loaded: list[tuple[int, int, float, float, str, str]] = []
|
loaded: list[tuple[int, int, float, float, str, str]] = []
|
||||||
skipped = 0
|
skipped = 0
|
||||||
|
|
||||||
with open(data_path, 'r', encoding='utf-8') as f:
|
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()
|
stripped = raw.strip()
|
||||||
if not stripped or stripped.startswith('#'):
|
if not stripped or stripped.startswith('#'):
|
||||||
continue
|
continue
|
||||||
|
|
||||||
parts = [p.strip() for p in stripped.split(',')]
|
parts = [p.strip() for p in stripped.split(',')]
|
||||||
if len(parts) != 6:
|
if len(parts) != 6:
|
||||||
skipped += 1
|
skipped += 1
|
||||||
continue
|
continue
|
||||||
|
|
||||||
start_ip_str, end_ip_str, lat_str, lon_str, city, country = parts
|
start_ip_str, end_ip_str, lat_str, lon_str, city, country = parts
|
||||||
|
|
||||||
start_int = ip_to_int(start_ip_str)
|
start_int = ip_to_int(start_ip_str)
|
||||||
end_int = ip_to_int(end_ip_str)
|
end_int = ip_to_int(end_ip_str)
|
||||||
|
|
||||||
if start_int is None or end_int is None:
|
if start_int is None or end_int is None:
|
||||||
skipped += 1
|
skipped += 1
|
||||||
continue
|
continue
|
||||||
|
|
||||||
try:
|
try:
|
||||||
lat = float(lat_str)
|
lat = float(lat_str)
|
||||||
lon = float(lon_str)
|
lon = float(lon_str)
|
||||||
except ValueError:
|
except ValueError:
|
||||||
skipped += 1
|
skipped += 1
|
||||||
continue
|
continue
|
||||||
|
|
||||||
loaded.append((start_int, end_int, lat, lon, city, country))
|
loaded.append((start_int, end_int, lat, lon, city, country))
|
||||||
|
|
||||||
|
global _GEO_DATA, _STARTS, _RANGES
|
||||||
_GEO_DATA.clear()
|
_GEO_DATA.clear()
|
||||||
_GEO_DATA.extend(loaded)
|
_GEO_DATA.extend(loaded)
|
||||||
|
|
||||||
global _STARTS, _RANGES
|
|
||||||
_STARTS, _RANGES = _build_index()
|
_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)
|
len(loaded), data_path, skipped)
|
||||||
|
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
# Public API
|
# MMDB lookup helpers
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
def lookup(ip_str: str) -> Optional[dict]:
|
def _lookup_mmdb(ip_str: str) -> Optional[dict]:
|
||||||
"""查询 IP 对应的地理位置。
|
"""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
|
try:
|
||||||
locate the range *ip_str* falls within, then checks the end bound.
|
city_result = _mmdb_city.get(ip_str)
|
||||||
|
if city_result is None:
|
||||||
|
return None
|
||||||
|
|
||||||
Parameters
|
# Extract location
|
||||||
----------
|
location = city_result.get('location', {})
|
||||||
ip_str:
|
lat = location.get('latitude')
|
||||||
IPv4 address as a dotted string (e.g. ``'8.8.8.8'``).
|
lon = location.get('longitude')
|
||||||
|
|
||||||
Returns
|
if lat is None or lon is None:
|
||||||
-------
|
return None
|
||||||
dict or None
|
|
||||||
A dictionary with keys ``lat``, ``lon``, ``city``, ``country``
|
|
||||||
if the IP is found in the database; ``None`` otherwise.
|
|
||||||
|
|
||||||
Examples
|
# Extract names (English preferred)
|
||||||
--------
|
city = None
|
||||||
>>> result = lookup('8.8.8.8')
|
city_names = city_result.get('city', {}).get('names', {})
|
||||||
>>> result['city']
|
if city_names:
|
||||||
'Mountain View'
|
city = city_names.get('en') or next(iter(city_names.values()), None)
|
||||||
>>> lookup('10.0.0.1') is None
|
|
||||||
True
|
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)
|
target = ip_to_int(ip_str)
|
||||||
if target is None:
|
if target is None:
|
||||||
return None
|
return None
|
||||||
|
|
||||||
if not _STARTS:
|
if not _STARTS:
|
||||||
return None
|
return None
|
||||||
|
|
||||||
@@ -178,13 +229,71 @@ def lookup(ip_str: str) -> Optional[dict]:
|
|||||||
'lon': lon,
|
'lon': lon,
|
||||||
'city': city,
|
'city': city,
|
||||||
'country': country,
|
'country': country,
|
||||||
|
'country_name': '',
|
||||||
|
'region': '',
|
||||||
}
|
}
|
||||||
|
|
||||||
return None
|
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
|
# Module-level initialisation
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
_load_data_file()
|
_load_mmdb()
|
||||||
|
if _mmdb_city is None:
|
||||||
|
_load_data_file()
|
||||||
|
|
||||||
|
logger.info('[GEOIP] active source: %s', source_name())
|
||||||
|
|||||||
@@ -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 功能完善
|
## 2026-07-23 — Simple Analysis v2 功能完善
|
||||||
|
|
||||||
### 概览
|
### 概览
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@@ -4,11 +4,13 @@ from . import views
|
|||||||
app_name = 'simple_analysis'
|
app_name = 'simple_analysis'
|
||||||
urlpatterns = [
|
urlpatterns = [
|
||||||
path('', views.index, name='index'),
|
path('', views.index, name='index'),
|
||||||
|
path('quick-scan/', views.quick_scan, name='quick_scan'),
|
||||||
path('upload/', views.upload_csv, name='upload'),
|
path('upload/', views.upload_csv, name='upload'),
|
||||||
path('filter/', views.filter_data, name='filter'),
|
path('filter/', views.filter_data, name='filter'),
|
||||||
path('filter/advanced/', views.advanced_filter, name='advanced_filter'),
|
path('filter/advanced/', views.advanced_filter, name='advanced_filter'),
|
||||||
path('column-values/', views.column_values, name='column_values'),
|
path('column-values/', views.column_values, name='column_values'),
|
||||||
path('cluster/', views.run_clustering, name='cluster'),
|
path('cluster/', views.run_clustering, name='cluster'),
|
||||||
path('cluster/<int:label>/', views.cluster_detail, name='cluster_detail'),
|
path('cluster/<int:label>/', views.cluster_detail, name='cluster_detail'),
|
||||||
|
path('node/', views.node_detail, name='node_detail'),
|
||||||
path('edges/', views.edge_relationships, name='edges'),
|
path('edges/', views.edge_relationships, name='edges'),
|
||||||
]
|
]
|
||||||
|
|||||||
+579
-515
File diff suppressed because it is too large
Load Diff
Reference in New Issue
Block a user