chore: consolidate all session fixes — migrations squash, views/tools refactor, cluster UI, geoip upgrade, simple_analysis removal, non-blocking startup, chinese UI, API key removal

This commit is contained in:
PM-pinou
2026-07-23 22:10:26 +08:00
parent 02176cf6f4
commit 123db6b08f
49 changed files with 2160 additions and 4469 deletions
+1
View File
@@ -0,0 +1 @@
11572
+5
View File
@@ -771,3 +771,8 @@ def load_csv_directory(
total_row_count = min(total_row_count, head)
return merged_lf, merged_schema, total_row_count, len(paths), memory_mb
# Auto-profile all public functions in this module
from analysis.profile_util import auto_profile_module # noqa: E402
auto_profile_module(__name__)
+5
View File
@@ -331,3 +331,8 @@ def profile_dataset(
}
return truncate_profile(result, max_kb=max_kb)
# Auto-profile all public functions in this module
from analysis.profile_util import auto_profile_module # noqa: E402
auto_profile_module(__name__)
+149 -100
View File
@@ -1,14 +1,18 @@
"""
离线 GeoIP 查询。内嵌 IPv4 区间 → 城市/经纬度映射
离线 GeoIP 查询。纯本地运行,无网络请求
从 ``data/geoip_data.txt`` 加载 IP 段数据,在模块首次导入时构建
排序索引,提供 O(log N) 的 IP 查询能力。
三级存储:
1. 快速缓存 (data/geoip_cache.json) — 已查过的 IP,磁盘持久化
2. 离线数据库 (data/geoip_data.txt) — 手动整理的优先 IP 段
3. MMDB 数据库 — 若 python_geoip_geolite2 包已安装,加载其
内置的 GeoLite2-City.mmdb 作为最全面回退。
数据约 200KB,覆盖 23 个主要城市 IP 段
可通过编辑 ``data/geoip_data.txt`` 扩展。
查询链:缓存 > geoip_data.txt > MMDB > 写入缓存
"""
import bisect
import json
import logging
import os
from pathlib import Path
from typing import Optional
@@ -18,32 +22,22 @@ logger = logging.getLogger(__name__)
# Data structures
# ---------------------------------------------------------------------------
# [(start_int, end_int, lat, lon, city, country), ...]
# Built at module load time from data/geoip_data.txt
_GEO_DATA: list[tuple[int, int, float, float, str, str]] = []
# Sorted list of start integers (for bisect lookup) + parallel data list
_STARTS: list[int] = []
_RANGES: list[tuple[int, int, float, float, str, str]] = []
_MMDB_READER = None
_CACHE: dict[str, Optional[dict]] = {}
_CACHE_DIRTY = False
_CACHE_PATH = Path(__file__).resolve().parent.parent / 'data' / 'geoip_cache.json'
# ---------------------------------------------------------------------------
# IP conversion
# ---------------------------------------------------------------------------
def ip_to_int(ip_str: str) -> Optional[int]:
"""Convert an IPv4 dotted-string to its 32-bit integer representation.
Returns ``None`` if *ip_str* is not a valid IPv4 address (including
when any octet is outside 0-255).
Examples
--------
>>> ip_to_int('8.8.8.8')
134744072
>>> ip_to_int('1.0.0.0')
16777216
"""
parts = ip_str.strip().split('.')
if len(parts) != 4:
return None
@@ -57,78 +51,144 @@ def ip_to_int(ip_str: str) -> Optional[int]:
# ---------------------------------------------------------------------------
# Index building
# geoip_data.txt loader
# ---------------------------------------------------------------------------
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.
"""
def _build_index():
if not _GEO_DATA:
return [], []
sorted_data = sorted(_GEO_DATA, key=lambda x: x[0])
starts = [entry[0] for entry in sorted_data]
return starts, sorted_data
def _load_data_file() -> None:
"""Load IP range data from ``data/geoip_data.txt`` into ``_GEO_DATA``.
Called once at module import time. Each non-blank, non-comment line
in the file should contain six comma-separated fields::
start_ip,end_ip,lat,lon,city,country
"""
data_path = Path(__file__).parent.parent / 'data' / 'geoip_data.txt'
data_path = Path(__file__).resolve().parent.parent / 'data' / 'geoip_data.txt'
if not data_path.exists():
logger.warning('[GEOIP] data file not found: %s', data_path)
return
loaded: list[tuple[int, int, float, float, str, str]] = []
loaded = []
skipped = 0
with open(data_path, 'r', encoding='utf-8') as f:
for line_no, raw in enumerate(f, start=1):
for raw in f:
stripped = raw.strip()
if not stripped or stripped.startswith('#'):
continue
parts = [p.strip() for p in stripped.split(',')]
if len(parts) != 6:
skipped += 1
continue
start_ip_str, end_ip_str, lat_str, lon_str, city, country = parts
start_int = ip_to_int(start_ip_str)
end_int = ip_to_int(end_ip_str)
if start_int is None or end_int is None:
sip, eip, lat_s, lon_s, city, country = parts
si = ip_to_int(sip)
ei = ip_to_int(eip)
if si is None or ei is None or si > ei:
skipped += 1
continue
try:
lat = float(lat_str)
lon = float(lon_str)
lat = float(lat_s)
lon = float(lon_s)
except ValueError:
skipped += 1
continue
loaded.append((start_int, end_int, lat, lon, city, country))
loaded.append((si, ei, lat, lon, city, country))
_GEO_DATA.clear()
_GEO_DATA.extend(loaded)
global _STARTS, _RANGES
_STARTS, _RANGES = _build_index()
logger.info('[GEOIP] loaded %d ranges (%d skipped)', len(loaded), skipped)
logger.info('[GEOIP] loaded %d ranges from %s (%d skipped)',
len(loaded), data_path, skipped)
# ---------------------------------------------------------------------------
# MMDB reader (GeoLite2-City.mmdb bundled with python_geoip_geolite2)
# ---------------------------------------------------------------------------
def _get_mmdb_reader():
global _MMDB_READER
if _MMDB_READER is not None:
return _MMDB_READER
# Try multiple locations for the MMDB file
candidates = []
try:
import _geoip_geolite2
candidates.append(Path(_geoip_geolite2.__file__).parent / 'GeoLite2-City.mmdb')
except Exception:
pass
# Fallback: temp directory without Chinese chars in path
candidates.append(Path(os.environ.get('TEMP', 'C:/temp')) / 'opencode' / 'GeoLite2-City.mmdb')
candidates.append(Path.home() / 'AppData' / 'Local' / 'Temp' / 'opencode' / 'GeoLite2-City.mmdb')
for mmdb_path in candidates:
if mmdb_path.exists():
try:
import maxminddb
_MMDB_READER = maxminddb.open_database(mmdb_path)
logger.info('[GEOIP] loaded MMDB: %s (%.1f MB)',
mmdb_path.name, mmdb_path.stat().st_size / 1e6)
break
except Exception as exc:
logger.debug('[GEOIP] failed to open %s: %s', mmdb_path, exc)
continue
if _MMDB_READER is None:
logger.debug('[GEOIP] MMDB not available — falling back to txt database only')
return _MMDB_READER
def _mmdb_lookup(ip_str: str) -> Optional[dict]:
reader = _get_mmdb_reader()
if reader is None:
return None
try:
result = reader.get(ip_str)
if result is None:
return None
location = result.get('location', {})
city_data = result.get('city', {})
country_data = result.get('country', {})
lat = location.get('latitude')
lon = location.get('longitude')
if lat is None or lon is None:
return None
city = ''
if city_data and 'names' in city_data:
city = city_data['names'].get('zh-CN') or city_data['names'].get('en', '')
country = ''
if country_data and 'names' in country_data:
country = country_data['names'].get('zh-CN') or country_data['names'].get('en', '')
return {'lat': float(lat), 'lon': float(lon), 'city': city, 'country': country}
except Exception as exc:
logger.debug('[GEOIP] MMDB lookup failed for %s: %s', ip_str, exc)
return None
# ---------------------------------------------------------------------------
# Disk cache
# ---------------------------------------------------------------------------
def _load_cache() -> None:
if not _CACHE_PATH.exists():
return
try:
data = json.loads(_CACHE_PATH.read_text(encoding='utf-8'))
_CACHE.update(data)
logger.info('[GEOIP] loaded %d cached entries', len(data))
except Exception:
pass
def _save_cache() -> None:
global _CACHE_DIRTY
if not _CACHE_DIRTY:
return
try:
_CACHE_PATH.parent.mkdir(parents=True, exist_ok=True)
_CACHE_PATH.write_text(
json.dumps(_CACHE, ensure_ascii=False, indent=2),
encoding='utf-8',
)
_CACHE_DIRTY = False
except Exception as exc:
logger.warning('[GEOIP] cache write failed: %s', exc)
# ---------------------------------------------------------------------------
@@ -136,55 +196,44 @@ def _load_data_file() -> None:
# ---------------------------------------------------------------------------
def lookup(ip_str: str) -> Optional[dict]:
"""查询 IP 对应的地理位置。
"""查询 IP 地理位置。纯本地,无网络。
Uses binary search (``bisect_right``) on the sorted start-IP list to
locate the range *ip_str* falls within, then checks the end bound.
Parameters
----------
ip_str:
IPv4 address as a dotted string (e.g. ``'8.8.8.8'``).
Returns
-------
dict or None
A dictionary with keys ``lat``, ``lon``, ``city``, ``country``
if the IP is found in the database; ``None`` otherwise.
Examples
--------
>>> result = lookup('8.8.8.8')
>>> result['city']
'Mountain View'
>>> lookup('10.0.0.1') is None
True
查询链:磁盘缓存 > geoip_data.txt (O(log N)) > MMDB (GeoLite2) > 写入缓存。
"""
# 1. Cache hit
cached = _CACHE.get(ip_str)
if cached is not None:
return cached
if ip_str in _CACHE:
return None
result = None
# 2. geoip_data.txt
target = ip_to_int(ip_str)
if target is None:
return None
if target is not None and _STARTS:
idx = bisect.bisect_right(_STARTS, target) - 1
if idx >= 0:
start, end, lat, lon, city, country = _RANGES[idx]
if start <= target <= end:
result = {'lat': lat, 'lon': lon, 'city': city, 'country': country}
if not _STARTS:
return None
# 3. MMDB fallback (GeoLite2, ~3M entries world-wide)
if result is None:
result = _mmdb_lookup(ip_str)
idx = bisect.bisect_right(_STARTS, target) - 1
if idx < 0:
return None
# 4. Cache for next time
global _CACHE_DIRTY
_CACHE[ip_str] = result
_CACHE_DIRTY = True
_save_cache()
start, end, lat, lon, city, country = _RANGES[idx]
if start <= target <= end:
return {
'lat': lat,
'lon': lon,
'city': city,
'country': country,
}
return None
return result
# ---------------------------------------------------------------------------
# Module-level initialisation
# Module-level init
# ---------------------------------------------------------------------------
_load_data_file()
_load_cache()
@@ -1,11 +1,19 @@
# Generated by Django 4.2.30 on 2026-07-13 14:36
# Generated by Django 4.2.30 on 2026-07-23 11:15
from django.db import migrations, models
from django.db.models import F
import django.db.migrations.operations.special
import django.db.models.deletion
def populate_display_id(apps, schema_editor):
AnalysisRun = apps.get_model('tianxuan_analysis', 'AnalysisRun')
AnalysisRun.objects.filter(display_id__isnull=True).update(display_id=F('id'))
class Migration(migrations.Migration):
replaces = [('tianxuan_analysis', '0001_initial'), ('tianxuan_analysis', '0002_analysisrun_progress_msg_analysisrun_progress_pct_and_more'), ('tianxuan_analysis', '0003_analysisrun_sqlite_table'), ('tianxuan_analysis', '0004_tls_ref_data'), ('tianxuan_analysis', '0005_remove_entity_column'), ('tianxuan_analysis', '0006_analysisrun_display_id_analysisrun_llm_thinking_and_more'), ('tianxuan_analysis', '0007_populate_display_id'), ('tianxuan_analysis', '0008_entityprofile_embedding_z_and_more')]
initial = True
dependencies = [
@@ -26,6 +34,10 @@ class Migration(migrations.Migration):
('entity_count', models.IntegerField(blank=True, null=True)),
('cluster_count', models.IntegerField(blank=True, null=True)),
('entity_column', models.CharField(blank=True, default='', help_text='Auto-detected entity column name', max_length=256)),
('progress_msg', models.CharField(blank=True, default='', max_length=256)),
('progress_pct', models.IntegerField(default=0)),
('run_log', models.TextField(blank=True, default='')),
('sqlite_table', models.CharField(blank=True, default='', help_text='SQLite table name for persisted raw data', max_length=256)),
],
options={
'ordering': ['-created_at'],
@@ -81,4 +93,51 @@ class Migration(migrations.Migration):
'unique_together': {('cluster', 'feature_name')},
},
),
migrations.RunSQL(
sql="\nCREATE TABLE IF NOT EXISTS tls_ref_data (\n id INTEGER PRIMARY KEY AUTOINCREMENT,\n field_code TEXT NOT NULL UNIQUE,\n meaning TEXT NOT NULL DEFAULT '',\n data_type TEXT NOT NULL DEFAULT ''\n);\n",
reverse_sql='\nDROP TABLE IF EXISTS tls_ref_data;\n',
),
migrations.RemoveField(
model_name='analysisrun',
name='entity_column',
),
migrations.AddField(
model_name='analysisrun',
name='display_id',
field=models.IntegerField(blank=True, help_text='User-facing numeric ID (recycles gaps)', null=True, unique=True),
),
migrations.AddField(
model_name='analysisrun',
name='llm_thinking',
field=models.TextField(blank=True, default='', help_text='Accumulated LLM reasoning / thinking text'),
),
migrations.AddField(
model_name='analysisrun',
name='run_type',
field=models.CharField(choices=[('upload', '手动上传'), ('manual', '手动分析'), ('auto', 'LLM自动')], default='upload', max_length=16),
),
migrations.AddField(
model_name='analysisrun',
name='tool_calls_json',
field=models.JSONField(blank=True, default=list, help_text='Structured tool call list: [{step, name, input, output}, ...]'),
),
migrations.RunPython(
code=populate_display_id,
reverse_code=django.db.migrations.operations.special.RunPython.noop,
),
migrations.AddField(
model_name='entityprofile',
name='embedding_z',
field=models.FloatField(blank=True, help_text='UMAP-3D Z coordinate', null=True),
),
migrations.AlterField(
model_name='entityprofile',
name='embedding_x',
field=models.FloatField(blank=True, help_text='UMAP-2D X coordinate', null=True),
),
migrations.AlterField(
model_name='entityprofile',
name='embedding_y',
field=models.FloatField(blank=True, help_text='UMAP-2D Y coordinate', null=True),
),
]
@@ -1,28 +0,0 @@
# Generated by Django 4.2.30 on 2026-07-16 05:11
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('tianxuan_analysis', '0001_initial'),
]
operations = [
migrations.AddField(
model_name='analysisrun',
name='progress_msg',
field=models.CharField(blank=True, default='', max_length=256),
),
migrations.AddField(
model_name='analysisrun',
name='progress_pct',
field=models.IntegerField(default=0),
),
migrations.AddField(
model_name='analysisrun',
name='run_log',
field=models.TextField(blank=True, default=''),
),
]
@@ -1,18 +0,0 @@
# Generated by Django 4.2.30 on 2026-07-20 05:11
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('tianxuan_analysis', '0002_analysisrun_progress_msg_analysisrun_progress_pct_and_more'),
]
operations = [
migrations.AddField(
model_name='analysisrun',
name='sqlite_table',
field=models.CharField(blank=True, default='', help_text='SQLite table name for persisted raw data', max_length=256),
),
]
-38
View File
@@ -1,38 +0,0 @@
"""Migration 0004 — create raw SQLite table ``tls_ref_data``.
Replaces the ``TlsDB.csv`` reference file with a proper SQLite table.
The table stores TLS field-code metadata: human-readable meaning and
data type for each field code used in TLS flow data.
"""
from django.db import migrations
# Raw SQL for creating the tls_ref_data table
CREATE_TLS_REF_DATA = """
CREATE TABLE IF NOT EXISTS tls_ref_data (
id INTEGER PRIMARY KEY AUTOINCREMENT,
field_code TEXT NOT NULL UNIQUE,
meaning TEXT NOT NULL DEFAULT '',
data_type TEXT NOT NULL DEFAULT ''
);
"""
DROP_TLS_REF_DATA = """
DROP TABLE IF EXISTS tls_ref_data;
"""
class Migration(migrations.Migration):
"""Create ``tls_ref_data`` table via raw SQL — no Django model needed."""
dependencies = [
('tianxuan_analysis', '0003_analysisrun_sqlite_table'),
]
operations = [
migrations.RunSQL(
sql=CREATE_TLS_REF_DATA,
reverse_sql=DROP_TLS_REF_DATA,
state_operations=None,
),
]
@@ -1,17 +0,0 @@
# Generated by Django 4.2.30 on 2026-07-20 05:20
from django.db import migrations
class Migration(migrations.Migration):
dependencies = [
('tianxuan_analysis', '0004_tls_ref_data'),
]
operations = [
migrations.RemoveField(
model_name='analysisrun',
name='entity_column',
),
]
@@ -1,33 +0,0 @@
# Generated by Django 4.2.30 on 2026-07-20 05:25
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('tianxuan_analysis', '0005_remove_entity_column'),
]
operations = [
migrations.AddField(
model_name='analysisrun',
name='display_id',
field=models.IntegerField(blank=True, help_text='User-facing numeric ID (recycles gaps)', null=True, unique=True),
),
migrations.AddField(
model_name='analysisrun',
name='llm_thinking',
field=models.TextField(blank=True, default='', help_text='Accumulated LLM reasoning / thinking text'),
),
migrations.AddField(
model_name='analysisrun',
name='run_type',
field=models.CharField(choices=[('upload', '手动上传'), ('manual', '手动分析'), ('auto', 'LLM自动')], default='upload', max_length=16),
),
migrations.AddField(
model_name='analysisrun',
name='tool_calls_json',
field=models.JSONField(blank=True, default=list, help_text='Structured tool call list: [{step, name, input, output}, ...]'),
),
]
@@ -1,20 +0,0 @@
# Generated by Django 4.2.30 on 2026-07-20 05:26
from django.db import migrations
from django.db.models import F
def populate_display_id(apps, schema_editor):
AnalysisRun = apps.get_model('tianxuan_analysis', 'AnalysisRun')
AnalysisRun.objects.filter(display_id__isnull=True).update(display_id=F('id'))
class Migration(migrations.Migration):
dependencies = [
('tianxuan_analysis', '0006_analysisrun_display_id_analysisrun_llm_thinking_and_more'),
]
operations = [
migrations.RunPython(populate_display_id, migrations.RunPython.noop),
]
@@ -1,28 +0,0 @@
# Generated by Django 4.2.30 on 2026-07-23 04:21
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('tianxuan_analysis', '0007_populate_display_id'),
]
operations = [
migrations.AddField(
model_name='entityprofile',
name='embedding_z',
field=models.FloatField(blank=True, help_text='UMAP-3D Z coordinate', null=True),
),
migrations.AlterField(
model_name='entityprofile',
name='embedding_x',
field=models.FloatField(blank=True, help_text='UMAP-2D X coordinate', null=True),
),
migrations.AlterField(
model_name='entityprofile',
name='embedding_y',
field=models.FloatField(blank=True, help_text='UMAP-2D Y coordinate', null=True),
),
]
+5
View File
@@ -400,3 +400,8 @@ def describe_cluster(
def _signed(value: float) -> str:
"""Return a signed string representation, e.g. ``"+2.1"``."""
return f"{value:+.1f}"
# Auto-profile all public functions in this module
from analysis.profile_util import auto_profile_module # noqa: E402
auto_profile_module(__name__)
+91
View File
@@ -0,0 +1,91 @@
"""Line profiling utility for the TianXuan backend.
Usage in any backend module::
from analysis.profile_util import profile
@profile
def my_function(...):
...
When ``TIANXUAN_PROFILE=1`` env var is set AND line_profiler is installed,
the decorator activates real line-level profiling and prints stats at exit.
Otherwise the decorator is a no-op.
To auto-profile ALL public functions in a module automatically, add at the
bottom of the file::
from analysis.profile_util import auto_profile_module
auto_profile_module(__name__)
Auto-profiling is also gated by ``TIANXUAN_PROFILE=1``.
"""
import os
import sys
import logging
import atexit
logger = logging.getLogger(__name__)
_PROFILER_ACTIVE = os.environ.get('TIANXUAN_PROFILE', '') == '1'
if _PROFILER_ACTIVE:
try:
from line_profiler import profile as _line_profile
except ImportError:
logger.warning('TIANXUAN_PROFILE=1 but line_profiler not installed')
_PROFILER_ACTIVE = False
def profile(func):
"""Decorate a function for line-level profiling.
Active only when ``TIANXUAN_PROFILE=1`` env var is set and
line_profiler is installed.
"""
if _PROFILER_ACTIVE:
return _line_profile(func)
return func
def auto_profile_module(module_name: str) -> int:
"""Apply @profile to every public (non-underscore) function in *module_name*.
Must be called at module level, typically at the bottom of the file::
from analysis.profile_util import auto_profile_module
auto_profile_module(__name__)
Active only when ``TIANXUAN_PROFILE=1`` env var is set and
line_profiler is installed. Returns number of functions wrapped, or 0
when inactive.
"""
if not _PROFILER_ACTIVE:
return 0
try:
from line_profiler import LineProfiler
except ImportError:
return 0
module = sys.modules.get(module_name)
if module is None:
logger.warning('auto_profile_module: module %r not found', module_name)
return 0
lp = LineProfiler()
count = 0
for name in dir(module):
if name.startswith('_'):
continue
obj = getattr(module, name)
if callable(obj):
try:
lp.add_function(obj)
count += 1
except (TypeError, ValueError):
pass # skip non-Python or special objects
atexit.register(lambda: lp.print_stats(output_unit=1e-6))
logger.info('auto_profile: wrapped %d functions in %s', count, module_name)
return count
+13
View File
@@ -323,12 +323,20 @@ class SessionStore:
def drop_dataset(self, dataset_id: str) -> bool:
"""Remove a dataset from the store and force garbage collection.
Cascades to child datasets (e.g. filtered results) via parent_id tracking.
Returns True if the dataset existed.
"""
with self._datalock:
existed = dataset_id in self._stores and self._stores[dataset_id].get('type') == 'dataset'
if existed:
del self._stores[dataset_id]
# Cascade: drop all child datasets that reference the dropped one as parent
child_ids = [
k for k, v in self._stores.items()
if v.get('type') == 'dataset' and v.get('parent_id') == dataset_id
]
for cid in child_ids:
del self._stores[cid]
if existed:
gc.collect()
self._save_to_disk()
@@ -473,3 +481,8 @@ class SessionStore:
types[t] = types.get(t, 0) + 1
parts = [f"{k}={v}" for k, v in sorted(types.items())]
return f"<SessionStore {'; '.join(parts)}>"
# Auto-profile all public functions in this module
from analysis.profile_util import auto_profile_module # noqa: E402
auto_profile_module(__name__)
+53 -24
View File
@@ -24,12 +24,7 @@ from mcp.types import Tool
from .data_loader import load_csv_directory
from .data_profiler import profile_dataset as _profile_dataset
from .session_store import SessionStore, _generate_id
# Conditional line_profiler support (no-op when not installed)
try:
profile # type: ignore[name-defined]
except NameError:
profile = lambda f: f
from .profile_util import profile
# ═══════════════════════════════════════════════════════════════════════
@@ -249,14 +244,15 @@ def get_tools_meta() -> list[Tool]:
},
"algorithm": {
"type": "string",
"enum": ["hdbscan", "kmeans"],
"description": "Clustering algorithm",
"default": "hdbscan",
"enum": ["agglomerative", "hdbscan", "kmeans"],
"description": "Clustering algorithm (default: agglomerative/Ward)",
"default": "agglomerative",
},
"params": {
"type": "object",
"description": (
"Algorithm parameters. HDBSCAN: min_cluster_size (5), "
"Algorithm parameters. Agglomerative: n_clusters (5), metric ('euclidean'), "
"linkage ('ward'). HDBSCAN: min_cluster_size (5), "
"min_samples (None), metric ('euclidean'). "
"KMeans: n_clusters (3), random_state (42)."
),
@@ -452,14 +448,15 @@ def get_tools_meta() -> list[Tool]:
},
"algorithm": {
"type": "string",
"enum": ["hdbscan", "kmeans"],
"description": "Clustering algorithm",
"default": "hdbscan",
"enum": ["agglomerative", "hdbscan", "kmeans"],
"description": "Clustering algorithm (default: agglomerative/Ward)",
"default": "agglomerative",
},
"params": {
"type": "object",
"description": (
"Algorithm parameters. HDBSCAN: min_cluster_size (5), "
"Algorithm parameters. Agglomerative: n_clusters (5), metric ('euclidean'), "
"linkage ('ward'). HDBSCAN: min_cluster_size (5), "
"min_samples (None), metric ('euclidean'). "
"KMeans: n_clusters (3), random_state (42)."
),
@@ -1130,7 +1127,7 @@ async def _handle_preprocess_data(
async def _handle_run_clustering(
dataset_id: str,
cluster_columns: list[str],
algorithm: str = 'hdbscan',
algorithm: str = 'agglomerative',
params: Optional[dict] = None,
random_state: int = 42,
) -> dict:
@@ -1311,13 +1308,21 @@ async def _handle_run_clustering(
)
# Clustering
from sklearn.cluster import HDBSCAN, MiniBatchKMeans
from sklearn.cluster import HDBSCAN, MiniBatchKMeans, AgglomerativeClustering
if algorithm == 'kmeans':
n_clusters = p.get('n_clusters', 3)
model = MiniBatchKMeans(
n_clusters=n_clusters, random_state=random_state,
batch_size=1024, n_init='auto',
)
elif algorithm in ('agglomerative', 'ward'):
n_clusters = p.get('n_clusters', 5)
model = AgglomerativeClustering(
n_clusters=n_clusters,
metric=p.get('metric', 'euclidean'),
linkage=p.get('linkage', 'ward'),
distance_threshold=None,
)
else:
model = HDBSCAN(
min_cluster_size=p['min_cluster_size'],
@@ -1731,11 +1736,15 @@ def _save_features_to_db(
from analysis.models import ClusterResult as ClusterResultModel
# Compute actual cluster sizes from features (not from cluster_entry — avoids scope issues)
# Compute actual cluster sizes from full label list (not from features count!)
# Use the cluster_entry which has the complete labels array
store = SessionStore()
cluster_entry = store.get_cluster_result(cluster_result_id)
label_counts = {}
for feat in features:
lbl = feat['cluster_label']
label_counts[lbl] = label_counts.get(lbl, 0) + 1
if cluster_entry:
full_labels = cluster_entry.get('labels', [])
for lbl in full_labels:
label_counts[lbl] = label_counts.get(lbl, 0) + 1
unique_labels = sorted(set(
f['cluster_label'] for f in features
@@ -2171,7 +2180,14 @@ async def _handle_compute_scores(dataset_id: str) -> dict:
lf = entry['lazyframe']
lf = _add_adaptive_scores(lf, [])
store.store_dataset(dataset_id, lf, schema=entry.get('schema', {}),
# Update schema to include new score columns (proxy_score, risk_level, threat_score)
try:
updated_schema = lf.collect_schema()
new_schema = {name: str(dtype) for name, dtype in
zip(updated_schema.names(), updated_schema.dtypes())}
except Exception:
new_schema = entry.get('schema', {})
store.store_dataset(dataset_id, lf, schema=new_schema,
metadata=entry.get('metadata', {}))
return {'status': 'scored', 'dataset_id': dataset_id}
@@ -2596,12 +2612,20 @@ async def _handle_explore_distributions(dataset_id: str, columns: list[str] = No
if not columns:
columns = [c for c in schema if schema[c].split('(')[0].strip() in numeric_types][:10]
# Limit columns to prevent excessive computation
if len(columns) > 20:
columns = columns[:20]
try:
n = lf.select(pl.len()).collect(streaming=True).item()
sample_lf = lf if n <= max_sample else lf.collect(streaming=True).sample(n=max_sample, seed=42).lazy()
df = sample_lf.select(columns).collect(streaming=True)
if n > max_sample:
# Use LazyFrame.sample() on the limited columns to avoid full-materialize
sample_lf = lf.select(columns).sample(n=max_sample, seed=42)
else:
sample_lf = lf.select(columns)
df = sample_lf.collect(streaming=True)
except Exception as exc:
raise
return {'error': f'explore_distributions failed: {exc}', 'truncated': False}
stats = {}
for col in columns:
@@ -3337,3 +3361,8 @@ async def _handle_compute_distance_matrix(
'total_rows_in_dataset': n_total,
'rows_evaluated': n_evaluated,
}
# Auto-profile all public functions in this module
from analysis.profile_util import auto_profile_module # noqa: E402
auto_profile_module(__name__)
+2 -1
View File
@@ -15,7 +15,7 @@ urlpatterns = [
path('runs/<int:display_id>/', views.run_detail, name='run_detail'),
path('runs/<int:display_id>/status/', views.run_status_api, name='run_status'),
path('clusters/<int:display_id>/', views.cluster_overview, name='cluster_overview'),
path('clusters/<int:display_id>/<int:cluster_label>/', views.cluster_detail, name='cluster_detail'),
path('clusters/<int:display_id>/<path:cluster_label>/', views.cluster_detail, name='cluster_detail'),
path('entities/<int:entity_id>/', views.entity_profile, name='entity_profile'),
path('config/', views.config_view, name='config'),
path('api/llm/test/', views.llm_test, name='llm_test'),
@@ -25,6 +25,7 @@ urlpatterns = [
path('logs/', views.log_viewer, name='log_viewer'),
path('globe/', views.globe_view, name='globe'),
path('tools/run/', views.tool_lab_run, name='tool_lab_run'),
path('tools/reload-run/', views.reload_run_data, name='reload_run_data'),
path('tools/plan/', views.tool_plan, name='tool_plan'),
path('tools/filter/', views.apply_filter, name='apply_filter'),
]
+348 -112
View File
@@ -15,12 +15,7 @@ from django.views.decorators.csrf import csrf_exempt
from config import get_config, save_config, Config
from .models import AnalysisRun, ClusterResult, EntityProfile
from . import nl_describe
# Conditional line_profiler support (no-op when not installed)
try:
profile # type: ignore[name-defined]
except NameError:
profile = lambda f: f
from .profile_util import profile
def dashboard(request):
@@ -91,23 +86,38 @@ def cluster_overview(request, display_id):
entities = run.entities.exclude(embedding_x=None).exclude(embedding_y=None).only(
'embedding_x', 'embedding_y', 'embedding_z', 'cluster_label', 'entity_value'
)
has_z = any(e.embedding_z is not None for e in entities)
scatter_data = [
{'x': e.embedding_x, 'y': e.embedding_y, 'z': e.embedding_z,
'label': e.cluster_label, 'entity': e.entity_value}
{'x': e.embedding_x, 'y': e.embedding_y, 'z': e.embedding_z if has_z else 0,
'label': e.cluster_label if e.cluster_label is not None else -1, 'entity': e.entity_value}
for e in entities
]
# Noise cluster features for enhanced noise card
# Noise cluster: features + entity detail
noise_features = []
noise_summary = ''
noise_entity_count = 0
if noise:
noise_features_qs = noise.features.all().order_by('-distinguishing_score')[:10]
noise_features_qs = noise.features.all().order_by('-distinguishing_score')[:15]
noise_features = [
{'feature_name': f.feature_name, 'score': f.distinguishing_score, 'mean': f.mean}
{'feature_name': f.feature_name, 'score': f.distinguishing_score, 'mean': f.mean,
'std': f.std, 'p25': f.p25, 'p75': f.p75}
for f in noise_features_qs
]
if noise_features:
noise_summary = nl_describe.describe_cluster(noise_features)
noise_entity_count = noise.size
# LLM workflow timeline for auto runs
llm_timeline_json = ''
if run.run_type == 'auto':
llm_thinking = run.llm_thinking or ''
tool_calls = run.tool_calls_json or []
if tool_calls or llm_thinking:
llm_timeline_json = json.dumps({
'thinking': llm_thinking,
'tool_calls': tool_calls,
})
# Build geo scatter data from feature_json (lat/lon)
geo_data = []
@@ -136,12 +146,14 @@ def cluster_overview(request, display_id):
top_features = {}
for c in clusters:
features_qs = c.features.all().order_by('-distinguishing_score')[:10]
top_features[str(c.cluster_label)] = [
{'feature_name': f.feature_name, 'score': f.distinguishing_score, 'mean': f.mean}
feats = [
{'feature_name': f.feature_name, 'score': f.distinguishing_score, 'mean': f.mean,
'std': f.std}
for f in features_qs
]
# Generate NL summary — attach to cluster object for template access
feats = top_features[str(c.cluster_label)]
top_features[str(c.cluster_label)] = feats
# Attach features + NL summary directly to cluster object for template
c._top_features = feats
c.nl_summary = nl_describe.describe_cluster(feats) if feats else ''
return render(request, 'analysis/cluster_overview.html', {
@@ -149,6 +161,7 @@ def cluster_overview(request, display_id):
'clusters': clusters,
'noise': noise,
'scatter_data_json': json.dumps(scatter_data),
'has_z': has_z,
'geo_data_json': json.dumps(geo_data),
'geo_skipped': geo_skipped,
'geo_count': len(geo_data),
@@ -156,21 +169,88 @@ def cluster_overview(request, display_id):
'top_features_json': json.dumps(top_features),
'noise_features': noise_features,
'noise_summary': noise_summary,
'noise_entity_count': noise_entity_count,
'llm_timeline_json': llm_timeline_json,
# Globe flow data for the sidebar
'globe_flows_json': json.dumps(_get_globe_flows(run)),
})
def _get_globe_flows(run, max_rows=500):
"""Extract flow data for 3D globe visualization from a run."""
import json
from analysis.data_loader import load_csv_directory, load_from_db
try:
lf = None
if run.sqlite_table:
lf = load_from_db(run.sqlite_table)
if lf is None and run.csv_glob:
lf, _, _, _, _ = load_csv_directory(run.csv_glob)
if lf is None:
return []
df = lf.head(max_rows).collect()
from analysis.geoip import lookup as geo_lookup
flows = []
TLS_HEX_MAP = {'0303': 'TLSv1.2', '0304': 'TLSv1.3', '0302': 'TLSv1.1', '0301': 'TLSv1.0'}
src_ip_col = next((c for c in df.columns if 'src_ip' in c.lower() or ':ips' in c.lower()), None)
dst_ip_col = next((c for c in df.columns if 'dst_ip' in c.lower() or ':ipd' in c.lower()), None)
tls_col = next((c for c in df.columns if c.lower() in ('0ver', 'tls_version', '_tlsver', 'version')), None)
bytes_col = next((c for c in df.columns if c.lower() in ('8ack', '8pak', '8byt', 'bytes_sent', 'bytes')), None)
for row in df.iter_rows(named=True):
sip = str(row.get(src_ip_col, '')) if src_ip_col else ''
dip = str(row.get(dst_ip_col, '')) if dst_ip_col else ''
if not sip or not dip:
continue
sg = geo_lookup(sip)
dg = geo_lookup(dip)
if not sg or not dg:
continue
tls_val = str(row.get(tls_col, '') or '') if tls_col else ''
tls_ver = TLS_HEX_MAP.get(tls_val.replace(' ', ''), tls_val)
flows.append({
'slat': sg['lat'], 'slon': sg['lon'],
'dlat': dg['lat'], 'dlon': dg['lon'],
'tls': tls_ver,
'bytes': float(row.get(bytes_col, 0) or 0) if bytes_col else 0,
})
return flows
except Exception:
return []
def cluster_detail(request, display_id, cluster_label):
"""Detailed view of a single cluster with entity list."""
"""Detailed view of a single cluster with entity list, SVD features, and NL summary."""
run = get_object_or_404(AnalysisRun, display_id=display_id)
try:
cluster_label = int(cluster_label)
except (ValueError, TypeError):
raise Http404("Invalid cluster label")
cluster = get_object_or_404(ClusterResult, run=run, cluster_label=cluster_label)
features = cluster.features.all().order_by('-distinguishing_score')[:50]
entities = cluster.entities.all()[:50]
entities = cluster.entities.all()[:100]
nl_summary = ''
feats_list = [{'feature_name': f.feature_name, 'score': f.distinguishing_score,
'mean': f.mean, 'std': f.std} for f in features[:10]]
if feats_list:
nl_summary = nl_describe.describe_cluster(feats_list)
# Build entity data with feature_json values
entity_data = []
for e in entities:
entry = {'id': e.id, 'value': e.entity_value,
'embedding_x': e.embedding_x, 'embedding_y': e.embedding_y}
if e.feature_json:
entry['features'] = {k: round(float(v), 4) if isinstance(v, (int, float)) else str(v)
for k, v in e.feature_json.items()}
entity_data.append(entry)
return render(request, 'analysis/cluster_detail.html', {
'run': run,
'cluster': cluster,
'features': features,
'entities': entities,
'entity_data_json': json.dumps(entity_data),
'nl_summary': nl_summary,
})
@@ -737,33 +817,49 @@ def manual_page(request):
# Get datasets from SessionStore
store = SessionStore()
datasets = store.list_datasets() if hasattr(store, 'list_datasets') else []
session_datasets = store.list_datasets() if hasattr(store, 'list_datasets') else []
session_ds_map = {ds['dataset_id']: ds for ds in session_datasets}
# Get upload AnalysisRun statuses for dataset badges
# Build dataset entries from ALL AnalysisRun records (upload/manual/auto, any status)
from analysis.models import AnalysisRun
upload_runs = AnalysisRun.objects.filter(run_type='upload').order_by('-created_at')[:50]
run_status_map = {
f'upload_{r.display_id}': {
'status': r.status,
'total_flows': r.total_flows,
'display_id': r.display_id,
}
for r in upload_runs
}
# Build datasets_with_status: each dataset annotated with run status
all_runs = AnalysisRun.objects.all().order_by('-created_at')[:100]
datasets_with_status = []
for ds in datasets:
run_info = run_status_map.get(ds['dataset_id'])
if run_info:
datasets_with_status.append({
**ds,
'run_status': run_info['status'],
'total_flows': run_info['total_flows'],
'display_id': run_info['display_id'],
})
for run in all_runs:
# Determine how the dataset was keyed in SessionStore
if run.run_type == 'upload':
ds_id = f'upload_{run.display_id}'
else:
datasets_with_status.append({**ds, 'run_status': 'ready', 'total_flows': None, 'display_id': None})
ds_id = f'run_{run.display_id}'
session_entry = session_ds_map.get(ds_id)
if session_entry:
# Full data available in SessionStore — merge with run status
datasets_with_status.append({
**session_entry,
'dataset_id': ds_id,
'run_status': run.status,
'total_flows': run.total_flows,
'display_id': run.display_id,
'needs_reload': False,
})
elif run.csv_glob or run.sqlite_table:
# Data not in SessionStore but recoverable from disk/DB
datasets_with_status.append({
'dataset_id': ds_id,
'run_status': run.status,
'display_id': run.display_id,
'total_flows': run.total_flows,
'row_count': run.total_flows,
'file_count': None,
'column_count': 0,
'columns': [],
'svd_components': 0,
'needs_reload': True,
'csv_glob': run.csv_glob,
'sqlite_table': run.sqlite_table or '',
})
# Map statuses to user-facing labels for the filter tabs
# ready → 待分析, completed → 已分析, failed → 错误
@@ -772,12 +868,16 @@ def manual_page(request):
s = ds_info['run_status']
if s in ('completed',):
ds_info['status_label'] = '已分析'
ds_info['badge_class'] = 'completed'
elif s in ('failed',):
ds_info['status_label'] = '错误'
ds_info['badge_class'] = 'failed'
elif s in ('pending', 'loading', 'profiling', 'aggregating', 'clustering', 'extracting'):
ds_info['status_label'] = '分析中'
ds_info['badge_class'] = 'analyzing'
else:
ds_info['status_label'] = '待分析'
ds_info['badge_class'] = 'ready'
return render(request, 'tianxuan/manual.html', {
'core_tools': core_tools,
@@ -785,7 +885,7 @@ def manual_page(request):
'analysis_tools': analysis_tools,
'other_tools': other_tools,
'tools_meta': _json.dumps(meta_list, ensure_ascii=False),
'datasets': datasets,
'datasets': datasets_with_status,
'datasets_with_status_json': _json.dumps(datasets_with_status, ensure_ascii=False),
})
@@ -1135,7 +1235,7 @@ def manual_run_analysis(request):
run.run_type = 'manual'
run.save(update_fields=['run_type'])
feature_columns = body.get('feature_columns')
algorithm = body.get('algorithm', 'hdbscan')
algorithm = body.get('algorithm', 'agglomerative')
min_cluster_size = int(body.get('min_cluster_size', 5))
head = body.get('head')
cluster_mode = body.get('cluster_mode', 'raw')
@@ -1236,30 +1336,10 @@ def apply_filter(request):
filtered_id = result.get('filtered_dataset_id', '')
row_count = result.get('row_count', 0)
# Re-store with filtered_X ID pattern
if dataset_id.startswith('upload_'):
display_suffix = dataset_id.split('_', 1)[1]
new_id = f'filtered_{display_suffix}'
else:
new_id = filtered_id
from analysis.session_store import SessionStore
store = SessionStore()
entry = store.get_dataset(filtered_id)
if entry and new_id != filtered_id:
store.store_dataset(
dataset_id=new_id,
lazyframe=entry['lazyframe'],
schema=entry.get('schema', {}),
metadata=dict(entry.get('metadata', {}), parent_dataset_id=dataset_id),
parent_id=dataset_id,
)
new_id_to_use = new_id
else:
new_id_to_use = filtered_id
# Use the unique flt_xxx ID from _handle_filter_data — never rename
# to filtered_{num} which would collide on repeated filters of the same source.
return JsonResponse({
'dataset_id': new_id_to_use,
'dataset_id': filtered_id,
'row_count': row_count,
})
except Exception as e:
@@ -1287,22 +1367,74 @@ def run_llm_analysis_view(request):
from analysis.session_store import SessionStore
store = SessionStore()
# Use display_id for dataset key (consistent with _background_process)
# Determine dataset key format (consistent with manual_page)
upload_ds_id = f'upload_{display_id}'
run_ds_id = f'run_{display_id}'
entry = store.get_dataset(upload_ds_id)
if entry is None:
# Fallback: try PK-based key (old pipeline)
upload_ds_id = f'upload_{pk}'
entry = store.get_dataset(upload_ds_id)
entry = store.get_dataset(run_ds_id)
if entry is None:
# Try entity dataset as fallback
entity_ds_id = f'entity_{pk}'
entry = store.get_dataset(entity_ds_id)
# Try primary key fallback
upload_pk_id = f'upload_{pk}'
entity_pk_id = f'entity_{pk}'
entry = store.get_dataset(upload_pk_id)
if entry is None:
return JsonResponse({'error': '数据集不存在,请先上传并等待预处理完成'})
entry = store.get_dataset(entity_pk_id)
dataset_id = upload_ds_id if store.get_dataset(upload_ds_id) else f'entity_{pk}'
if entry is None:
# Auto-reload from sqlite_table or csv_glob
from analysis.data_loader import load_csv_directory, load_from_db
lf = None
schema = {}
if run.sqlite_table:
try:
lf = load_from_db(run.sqlite_table)
except Exception:
pass
if lf is None and run.csv_glob:
try:
lf, schema, _, _, _ = load_csv_directory(run.csv_glob)
except Exception:
pass
if lf is None:
return JsonResponse({'error': '数据集不存在,请先上传并等待预处理完成'})
entry_key = upload_ds_id if run.run_type == 'upload' else run_ds_id
store.store_dataset(entry_key, lf, schema=schema, metadata={
'row_count': run.total_flows,
'csv_glob': run.csv_glob or '',
'sqlite_table': run.sqlite_table or '',
})
# Re-fetch entry
entry = store.get_dataset(entry_key)
# Determine the active dataset_id
for candidate in [upload_ds_id, run_ds_id, f'upload_{pk}', f'entity_{pk}']:
if store.get_dataset(candidate):
dataset_id = candidate
break
else:
return JsonResponse({'error': '数据集不存在,请先上传并等待预处理完成'})
# ── Optional: apply filter before LLM pipeline ──
filters = body.get('filters', [])
if filters and len(filters) > 0:
logic = body.get('logic', 'and')
try:
from analysis.tool_registry import _handle_filter_data
filter_result = asyncio.run(_handle_filter_data(
dataset_id=dataset_id,
filters=filters,
logic=logic,
))
if 'error' not in filter_result:
filtered_id = filter_result.get('filtered_dataset_id', '')
if filtered_id and store.get_dataset(filtered_id):
dataset_id = filtered_id
except Exception:
import traceback
logger.warning(f'[LLM auto] Filter failed (non-fatal): {traceback.format_exc()}')
def _analysis_fn(run, ctx):
from tianxuan.llm_orchestrator import run_llm_pipeline, LLMConfig
@@ -1319,26 +1451,22 @@ def run_llm_analysis_view(request):
save_fields = ['progress_pct', 'progress_msg']
if tool_name == 'thinking':
# Accumulate LLM thinking text
# Accumulate LLM thinking text (appended to llm_thinking)
if result:
run.llm_thinking = (run.llm_thinking or '') + f'\n[{step}] {result}'
save_fields = ['progress_pct', 'progress_msg', 'llm_thinking']
elif tool_name == 'complete':
pass # No extra tracking needed
else:
# Real tool call — log it
if result:
if isinstance(result, dict):
# Format dict as JSON, truncate large values
import json as _json
summary = _json.dumps(result, default=str, ensure_ascii=False)[:500]
else:
summary = str(result)[:500]
run.run_log += f'[{step}] {tool_name}: {summary}...\n'
# Single tool call — always has result
if isinstance(result, dict):
import json as _json
summary = _json.dumps(result, default=str, ensure_ascii=False)[:500]
else:
run.run_log += f'[{step}] {tool_name}\n'
summary = str(result)[:500]
run.run_log += f'[{step}] {tool_name}: {summary}...\n'
# Capture structured tool call data
# One entry per tool call
if meta and isinstance(meta, dict) and 'args' in meta:
tool_calls = list(run.tool_calls_json or [])
tool_calls.append({
@@ -1408,7 +1536,7 @@ def run_llm_analysis_view(request):
store=store,
entity_ds_id=entity_ds_id,
feature_columns=None,
algorithm='hdbscan',
algorithm='agglomerative',
min_cluster_size=5,
run_umap=True,
head=ctx.get('head'),
@@ -1488,37 +1616,67 @@ def auto_page(request):
cfg = get_config()
# Get upload runs for dataset selector (with status badges)
runs = AnalysisRun.objects.filter(run_type='upload').order_by('-created_at')[:50]
# Get datasets from SessionStore + ALL runs from DB
store = SessionStore()
session_datasets = store.list_datasets() if hasattr(store, 'list_datasets') else []
session_ds_map = {ds['dataset_id']: ds for ds in session_datasets}
# Build datasets_with_status for filter tabs
from analysis.models import AnalysisRun as _AR
upload_runs = _AR.objects.filter(run_type='upload').order_by('-created_at')[:50]
# Build dataset entries from ALL AnalysisRun records
all_runs = AnalysisRun.objects.all().order_by('-created_at')[:100]
datasets_with_status = []
for r in upload_runs:
s = r.status
if s in ('completed',):
label = '已分析'
elif s in ('failed',):
label = '错误'
elif s in ('pending', 'loading', 'profiling', 'aggregating', 'clustering', 'extracting'):
label = '分析中'
for run in all_runs:
if run.run_type == 'upload':
ds_id = f'upload_{run.display_id}'
else:
label = '待分析'
datasets_with_status.append({
'dataset_id': r.display_id,
'display_id': r.display_id,
'run_status': s,
'status_label': label,
'row_count': r.total_flows,
'file_count': getattr(r, 'file_count', None),
'csv_glob': r.csv_glob or '',
'sqlite_table': r.sqlite_table or '',
})
ds_id = f'run_{run.display_id}'
session_entry = session_ds_map.get(ds_id)
if session_entry:
datasets_with_status.append({
**session_entry,
'dataset_id': ds_id,
'run_status': run.status,
'total_flows': run.total_flows,
'display_id': run.display_id,
'needs_reload': False,
'csv_glob': run.csv_glob or '',
'sqlite_table': run.sqlite_table or '',
})
elif run.csv_glob or run.sqlite_table:
datasets_with_status.append({
'dataset_id': ds_id,
'run_status': run.status,
'display_id': run.display_id,
'total_flows': run.total_flows,
'row_count': run.total_flows,
'file_count': None,
'columns': [],
'column_count': 0,
'svd_components': 0,
'needs_reload': True,
'csv_glob': run.csv_glob or '',
'sqlite_table': run.sqlite_table or '',
})
for ds_info in datasets_with_status:
s = ds_info['run_status']
if s in ('completed',):
ds_info['status_label'] = '已分析'
ds_info['badge_class'] = 'completed'
elif s in ('failed',):
ds_info['status_label'] = '错误'
ds_info['badge_class'] = 'failed'
elif s in ('pending', 'loading', 'profiling', 'aggregating', 'clustering', 'extracting'):
ds_info['status_label'] = '分析中'
ds_info['badge_class'] = 'analyzing'
else:
ds_info['status_label'] = '待分析'
ds_info['badge_class'] = 'ready'
return render(request, 'tianxuan/auto.html', {
'llm_configured': bool(cfg.llm.base_url and cfg.llm.api_key),
'runs': runs,
'datasets_with_status_json': _json.dumps(datasets_with_status, ensure_ascii=False),
})
@@ -1872,6 +2030,79 @@ def tool_lab_run(request):
return JsonResponse({'status': 'ok', 'result': result})
@csrf_exempt
def reload_run_data(request):
"""Reload a run's data from csv_glob or sqlite_table into SessionStore."""
if request.method != 'POST':
return JsonResponse({'error': 'POST required'}, status=405)
import json as _json
try:
body = _json.loads(request.body)
except Exception:
return JsonResponse({'error': 'Invalid JSON body'}, status=400)
display_id = body.get('display_id')
if not display_id:
return JsonResponse({'error': 'Missing display_id'}, status=400)
from analysis.models import AnalysisRun
from analysis.data_loader import load_csv_directory, load_from_db
run = get_object_or_404(AnalysisRun, display_id=display_id)
# Determine dataset_id (same keying as manual_page)
ds_id = f'upload_{display_id}' if run.run_type == 'upload' else f'run_{display_id}'
store = SessionStore()
# Check if already loaded
existing = store.get_dataset(ds_id)
if existing is not None:
return JsonResponse({'status': 'ok', 'dataset_id': ds_id, 'reloaded': False})
# Try loading from sqlite_table first, then csv_glob
lf = None
schema = {}
row_count = 0
file_count = 0
if run.sqlite_table:
try:
lf = load_from_db(run.sqlite_table)
if lf is not None:
row_count = lf.select(pl.len()).collect(streaming=True)[0, 0]
except Exception as exc:
import logging
logging.getLogger(__name__).warning(f'Failed to load from sqlite_table: {exc}')
if lf is None and run.csv_glob:
try:
from pathlib import Path
csv_path = Path(run.csv_glob)
if csv_path.is_file() or csv_path.is_dir() or csv_path.parent.is_dir():
lf, schema, row_count, file_count, _ = load_csv_directory(run.csv_glob)
except Exception as exc:
import logging
logging.getLogger(__name__).warning(f'Failed to load from csv_glob: {exc}')
if lf is None:
return JsonResponse({'error': 'No data source available (upload dir or sqlite table gone)'}, status=404)
store.store_dataset(ds_id, lf, schema=schema, metadata={
'row_count': row_count,
'file_count': file_count,
'csv_glob': run.csv_glob or '',
'sqlite_table': run.sqlite_table or '',
})
return JsonResponse({
'status': 'ok',
'dataset_id': ds_id,
'reloaded': True,
'row_count': row_count,
'columns': list(schema.keys()),
})
# ── Save / Load / Delete analysis plans ─────────────────────────────
from pathlib import Path
@@ -2070,3 +2301,8 @@ def retry_run(request, display_id):
return redirect('analysis:run_detail', display_id=run.display_id)
# Auto-profile all public functions in this module
from analysis.profile_util import auto_profile_module # noqa: E402
auto_profile_module(__name__)
+1 -1
View File
@@ -19,7 +19,7 @@ class Config(BaseModel):
recursive: bool = False
class Clustering(BaseModel):
algorithm: str = 'hdbscan'
algorithm: str = 'agglomerative'
min_cluster_size: int = 5
random_state: int = 42
+49
View File
@@ -0,0 +1,49 @@
{
"8.8.8.8": {
"lat": 37.422,
"lon": -122.084,
"city": "Sunnyvale",
"country": "US"
},
"1.1.1.1": {
"lat": 37.7749,
"lon": -122.4194,
"city": "San Francisco",
"country": "US"
},
"52.0.0.1": {
"lat": 39.0438,
"lon": -77.4874,
"city": "Ashburn",
"country": "US"
},
"104.16.1.1": {
"lat": 37.7749,
"lon": -122.4194,
"city": "San Francisco",
"country": "US"
},
"47.96.1.1": {
"lat": 31.2304,
"lon": 121.4737,
"city": "Shanghai",
"country": "CN"
},
"218.1.1.1": null,
"157.1.1.1": {
"lat": 34.6937,
"lon": 135.5023,
"city": "Japan",
"country": "JP"
},
"10.0.0.1": null,
"192.168.1.1": null,
"13.250.0.1": null,
"185.234.0.1": null,
"103.235.0.1": {
"lat": 20.0,
"lon": 77.0,
"city": "",
"country": "印度"
}
}
-808
View File
@@ -1,808 +0,0 @@
# ===== Offline GeoIP Database — 天璇 (TianXuan) =====
# Format: start_ip,end_ip,lat,lon,city,country
# Layers: Private, DNS, AWS, Azure, GCP, Alibaba, Cloudflare, Akamai, Fastly, CN_ISP, US_ISP, EU_ISP, JP_ISP
# Strategy: priority-ordered, overlap-resolved (first wins), gaps filled with city blocks
1.0.0.0,1.0.0.0,31.2304,121.4737,Shanghai,CN
1.0.0.1,1.0.0.1,37.7749,-122.4194,San Francisco,US
1.0.0.2,1.1.1.0,31.2304,121.4737,Shanghai,CN
1.1.1.1,1.1.1.1,37.7749,-122.4194,San Francisco,US
1.1.1.2,1.255.255.255,31.2304,121.4737,Shanghai,CN
2.0.0.0,2.15.255.255,51.5074,-0.1278,London,GB
2.16.0.0,2.23.255.255,42.3736,-71.1097,Cambridge,US
2.24.0.0,2.255.255.255,51.5074,-0.1278,London,GB
3.0.0.0,3.1.255.255,1.3521,103.8198,Singapore,SG
3.2.0.0,3.5.255.255,31.2304,121.4737,Shanghai,CN
3.6.0.0,3.7.255.255,19.076,72.8777,Mumbai,IN
3.8.0.0,3.15.255.255,51.5074,-0.1278,London,GB
3.16.0.0,3.23.255.255,39.9042,116.4074,Beijing,CN
3.24.0.0,3.27.255.255,-33.8688,151.2093,Sydney,AU
3.28.0.0,3.33.255.255,35.6762,139.6503,Tokyo,JP
3.34.0.0,3.35.255.255,37.5665,126.978,Seoul,KR
3.36.0.0,3.51.255.255,37.5665,126.978,Seoul,KR
3.52.0.0,3.63.255.255,1.3521,103.8198,Singapore,SG
3.64.0.0,3.79.255.255,50.1109,8.6821,Frankfurt,DE
3.80.0.0,3.95.255.255,51.5074,-0.1278,London,GB
3.96.0.0,3.99.255.255,45.5017,-73.5673,Montreal,CA
3.100.0.0,3.111.255.255,50.1109,8.6821,Frankfurt,DE
3.112.0.0,3.115.255.255,35.6762,139.6503,Tokyo,JP
3.116.0.0,3.131.255.255,40.7128,-74.006,New York,US
3.132.0.0,3.147.255.255,37.7749,-122.4194,San Francisco,US
3.148.0.0,3.163.255.255,-33.8688,151.2093,Sydney,AU
3.164.0.0,3.179.255.255,19.076,72.8777,Mumbai,IN
3.180.0.0,3.195.255.255,48.8566,2.3522,Paris,FR
3.196.0.0,3.207.255.255,52.3676,4.9041,Amsterdam,NL
3.208.0.0,3.215.255.255,39.0438,-77.4874,Ashburn,US
3.216.0.0,3.223.255.255,-23.5505,-46.6333,Sao Paulo,BR
3.224.0.0,3.239.255.255,39.0438,-77.4874,Ashburn,US
3.240.0.0,4.111.255.255,55.7558,37.6173,Moscow,RU
4.112.0.0,4.239.255.255,41.8781,-87.6298,Chicago,US
4.240.0.0,4.255.255.255,32.7767,-96.797,Dallas,US
5.0.0.0,5.255.255.255,51.5074,-0.1278,London,GB
6.0.0.0,6.127.255.255,33.749,-84.388,Atlanta,US
6.128.0.0,6.255.255.255,43.6532,-79.3832,Toronto,CA
7.0.0.0,7.127.255.255,22.3193,114.1694,Hong Kong,HK
7.128.0.0,7.255.255.255,25.033,121.5654,Taipei,TW
8.0.0.0,8.8.4.3,40.4168,-3.7038,Madrid,ES
8.8.4.4,8.8.4.4,37.3861,-122.0839,Mountain View,US
8.8.4.5,8.8.8.7,45.4642,9.19,Milan,IT
8.8.8.8,8.8.8.8,37.3861,-122.0839,Mountain View,US
8.8.8.9,8.136.8.8,59.3293,18.0686,Stockholm,SE
8.136.8.9,9.8.8.8,34.6937,135.5022,Osaka,JP
9.8.8.9,9.136.8.8,23.1291,113.2644,Guangzhou,CN
9.136.8.9,9.255.255.255,22.5431,114.0579,Shenzhen,CN
10.0.0.0,10.255.255.255,39.9042,116.4074,Beijing,CN
11.0.0.0,11.127.255.255,52.52,13.405,Berlin,DE
11.128.0.0,11.255.255.255,25.2048,55.2708,Dubai,AE
12.0.0.0,12.255.255.255,32.7767,-96.797,Dallas,US
13.0.0.0,13.15.255.255,13.7563,100.5018,Bangkok,TH
13.16.0.0,13.31.255.255,31.2304,121.4737,Shanghai,CN
13.32.0.0,13.47.255.255,39.9042,116.4074,Beijing,CN
13.48.0.0,13.51.255.255,59.3293,18.0686,Stockholm,SE
13.52.0.0,13.55.255.255,37.7749,-122.4194,San Francisco,US
13.56.0.0,13.59.255.255,37.7749,-122.4194,San Francisco,US
13.60.0.0,13.63.255.255,35.6762,139.6503,Tokyo,JP
13.64.0.0,13.95.255.255,37.7749,-122.4194,San Francisco,US
13.96.0.0,13.111.255.255,37.5665,126.978,Seoul,KR
13.112.0.0,13.115.255.255,35.6762,139.6503,Tokyo,JP
13.116.0.0,13.123.255.255,1.3521,103.8198,Singapore,SG
13.124.0.0,13.124.255.255,37.5665,126.978,Seoul,KR
13.125.0.0,13.125.255.255,51.5074,-0.1278,London,GB
13.126.0.0,13.127.255.255,19.076,72.8777,Mumbai,IN
13.128.0.0,13.143.255.255,50.1109,8.6821,Frankfurt,DE
13.144.0.0,13.159.255.255,40.7128,-74.006,New York,US
13.160.0.0,13.175.255.255,37.7749,-122.4194,San Francisco,US
13.176.0.0,13.191.255.255,-33.8688,151.2093,Sydney,AU
13.192.0.0,13.207.255.255,19.076,72.8777,Mumbai,IN
13.208.0.0,13.208.255.255,34.6937,135.5022,Osaka,JP
13.209.0.0,13.209.255.255,37.5665,126.978,Seoul,KR
13.210.0.0,13.211.255.255,-33.8688,151.2093,Sydney,AU
13.212.0.0,13.215.255.255,1.3521,103.8198,Singapore,SG
13.216.0.0,13.227.255.255,48.8566,2.3522,Paris,FR
13.228.0.0,13.229.255.255,1.3521,103.8198,Singapore,SG
13.230.0.0,13.231.255.255,35.6762,139.6503,Tokyo,JP
13.232.0.0,13.243.255.255,52.3676,4.9041,Amsterdam,NL
13.244.0.0,13.245.255.255,-33.9249,18.4241,Cape Town,ZA
13.246.0.0,13.255.255.255,-23.5505,-46.6333,Sao Paulo,BR
14.0.0.0,14.255.255.255,39.9042,116.4074,Beijing,CN
15.0.0.0,15.127.255.255,55.7558,37.6173,Moscow,RU
15.128.0.0,15.151.255.255,41.8781,-87.6298,Chicago,US
15.152.0.0,15.152.255.255,34.6937,135.5022,Osaka,JP
15.153.0.0,15.159.255.255,32.7767,-96.797,Dallas,US
15.160.0.0,15.160.255.255,45.4642,9.19,Milan,IT
15.161.0.0,15.163.255.255,33.749,-84.388,Atlanta,US
15.164.0.0,15.164.255.255,37.5665,126.978,Seoul,KR
15.165.0.0,15.180.255.255,43.6532,-79.3832,Toronto,CA
15.181.0.0,15.183.255.255,22.3193,114.1694,Hong Kong,HK
15.184.0.0,15.185.255.255,26.0667,50.5577,Bahrain,BH
15.186.0.0,15.187.255.255,25.033,121.5654,Taipei,TW
15.188.0.0,15.188.255.255,48.8566,2.3522,Paris,FR
15.189.0.0,15.204.255.255,40.4168,-3.7038,Madrid,ES
15.205.0.0,15.205.255.255,45.4642,9.19,Milan,IT
15.206.0.0,15.206.255.255,19.076,72.8777,Mumbai,IN
15.207.0.0,15.221.255.255,59.3293,18.0686,Stockholm,SE
15.222.0.0,15.223.255.255,45.5017,-73.5673,Montreal,CA
15.224.0.0,15.227.255.255,34.6937,135.5022,Osaka,JP
15.228.0.0,15.229.255.255,-23.5505,-46.6333,Sao Paulo,BR
15.230.0.0,15.245.255.255,23.1291,113.2644,Guangzhou,CN
15.246.0.0,16.5.255.255,22.5431,114.0579,Shenzhen,CN
16.6.0.0,16.15.255.255,52.52,13.405,Berlin,DE
16.16.0.0,16.16.255.255,59.3293,18.0686,Stockholm,SE
16.17.0.0,16.144.255.255,25.2048,55.2708,Dubai,AE
16.145.0.0,16.169.255.255,13.7563,100.5018,Bangkok,TH
16.170.0.0,16.171.255.255,59.3293,18.0686,Stockholm,SE
16.172.0.0,17.43.255.255,31.2304,121.4737,Shanghai,CN
17.44.0.0,17.171.255.255,39.9042,116.4074,Beijing,CN
17.172.0.0,18.43.255.255,35.6762,139.6503,Tokyo,JP
18.44.0.0,18.135.255.255,37.5665,126.978,Seoul,KR
18.136.0.0,18.139.255.255,1.3521,103.8198,Singapore,SG
18.140.0.0,18.155.255.255,1.3521,103.8198,Singapore,SG
18.156.0.0,18.161.255.255,51.5074,-0.1278,London,GB
18.162.0.0,18.163.255.255,22.3193,114.1694,Hong Kong,HK
18.164.0.0,18.167.255.255,50.1109,8.6821,Frankfurt,DE
18.168.0.0,18.171.255.255,51.5074,-0.1278,London,GB
18.172.0.0,18.175.255.255,40.7128,-74.006,New York,US
18.176.0.0,18.177.255.255,35.6762,139.6503,Tokyo,JP
18.178.0.0,18.183.255.255,37.7749,-122.4194,San Francisco,US
18.184.0.0,18.187.255.255,50.1109,8.6821,Frankfurt,DE
18.188.0.0,18.193.255.255,-33.8688,151.2093,Sydney,AU
18.194.0.0,18.195.255.255,50.1109,8.6821,Frankfurt,DE
18.196.0.0,18.211.255.255,19.076,72.8777,Mumbai,IN
18.212.0.0,18.227.255.255,48.8566,2.3522,Paris,FR
18.228.0.0,18.229.255.255,-23.5505,-46.6333,Sao Paulo,BR
18.230.0.0,18.231.255.255,52.3676,4.9041,Amsterdam,NL
18.232.0.0,18.235.255.255,39.0438,-77.4874,Ashburn,US
18.236.0.0,19.107.255.255,-23.5505,-46.6333,Sao Paulo,BR
19.108.0.0,19.235.255.255,55.7558,37.6173,Moscow,RU
19.236.0.0,19.255.255.255,41.8781,-87.6298,Chicago,US
20.0.0.0,20.31.255.255,39.0438,-77.4874,Ashburn,US
20.32.0.0,20.35.255.255,32.7767,-96.797,Dallas,US
20.36.0.0,20.39.255.255,-33.8688,151.2093,Sydney,AU
20.40.0.0,20.40.255.255,33.749,-84.388,Atlanta,US
20.41.0.0,20.41.255.255,37.5665,126.978,Seoul,KR
20.42.0.0,20.42.255.255,43.6532,-79.3832,Toronto,CA
20.43.0.0,20.43.255.255,48.8566,2.3522,Paris,FR
20.44.0.0,20.45.255.255,35.6762,139.6503,Tokyo,JP
20.46.0.0,20.48.255.255,22.3193,114.1694,Hong Kong,HK
20.49.0.0,20.49.255.255,51.5074,-0.1278,London,GB
20.50.0.0,20.51.255.255,25.033,121.5654,Taipei,TW
20.52.0.0,20.52.255.255,50.1109,8.6821,Frankfurt,DE
20.53.0.0,20.55.255.255,40.4168,-3.7038,Madrid,ES
20.56.0.0,20.56.255.255,52.3676,4.9041,Amsterdam,NL
20.57.0.0,20.72.255.255,45.4642,9.19,Milan,IT
20.73.0.0,20.88.255.255,59.3293,18.0686,Stockholm,SE
20.89.0.0,20.104.255.255,34.6937,135.5022,Osaka,JP
20.105.0.0,20.120.255.255,23.1291,113.2644,Guangzhou,CN
20.121.0.0,20.136.255.255,22.5431,114.0579,Shenzhen,CN
20.137.0.0,20.152.255.255,52.52,13.405,Berlin,DE
20.153.0.0,20.168.255.255,25.2048,55.2708,Dubai,AE
20.169.0.0,20.183.255.255,13.7563,100.5018,Bangkok,TH
20.184.0.0,20.185.255.255,1.3521,103.8198,Singapore,SG
20.186.0.0,20.191.255.255,31.2304,121.4737,Shanghai,CN
20.192.0.0,20.192.255.255,19.076,72.8777,Mumbai,IN
20.193.0.0,20.195.255.255,39.9042,116.4074,Beijing,CN
20.196.0.0,20.196.255.255,22.3193,114.1694,Hong Kong,HK
20.197.0.0,20.197.255.255,-23.5505,-46.6333,Sao Paulo,BR
20.198.0.0,21.69.255.255,35.6762,139.6503,Tokyo,JP
21.70.0.0,21.197.255.255,37.5665,126.978,Seoul,KR
21.198.0.0,22.69.255.255,1.3521,103.8198,Singapore,SG
22.70.0.0,22.197.255.255,51.5074,-0.1278,London,GB
22.198.0.0,22.255.255.255,50.1109,8.6821,Frankfurt,DE
23.0.0.0,23.15.255.255,42.3736,-71.1097,Cambridge,US
23.16.0.0,23.31.255.255,40.7128,-74.006,New York,US
23.32.0.0,23.63.255.255,42.3736,-71.1097,Cambridge,US
23.64.0.0,23.67.255.255,42.3736,-71.1097,Cambridge,US
23.68.0.0,23.71.255.255,37.7749,-122.4194,San Francisco,US
23.72.0.0,23.79.255.255,42.3736,-71.1097,Cambridge,US
23.80.0.0,23.95.255.255,-33.8688,151.2093,Sydney,AU
23.96.0.0,23.103.255.255,41.8781,-87.6298,Chicago,US
23.104.0.0,23.119.255.255,19.076,72.8777,Mumbai,IN
23.120.0.0,23.135.255.255,48.8566,2.3522,Paris,FR
23.136.0.0,23.151.255.255,52.3676,4.9041,Amsterdam,NL
23.152.0.0,23.167.255.255,-23.5505,-46.6333,Sao Paulo,BR
23.168.0.0,23.183.255.255,55.7558,37.6173,Moscow,RU
23.184.0.0,23.191.255.255,41.8781,-87.6298,Chicago,US
23.192.0.0,23.223.255.255,42.3736,-71.1097,Cambridge,US
23.224.0.0,23.239.255.255,32.7767,-96.797,Dallas,US
23.240.0.0,23.255.255.255,33.749,-84.388,Atlanta,US
24.0.0.0,24.255.255.255,40.7128,-74.006,New York,US
25.0.0.0,25.255.255.255,51.5074,-0.1278,London,GB
26.0.0.0,26.127.255.255,43.6532,-79.3832,Toronto,CA
26.128.0.0,26.255.255.255,22.3193,114.1694,Hong Kong,HK
27.0.0.0,27.255.255.255,31.2304,121.4737,Shanghai,CN
28.0.0.0,28.127.255.255,25.033,121.5654,Taipei,TW
28.128.0.0,28.255.255.255,40.4168,-3.7038,Madrid,ES
29.0.0.0,29.127.255.255,45.4642,9.19,Milan,IT
29.128.0.0,29.255.255.255,59.3293,18.0686,Stockholm,SE
30.0.0.0,30.127.255.255,34.6937,135.5022,Osaka,JP
30.128.0.0,30.255.255.255,23.1291,113.2644,Guangzhou,CN
31.0.0.0,31.255.255.255,51.5074,-0.1278,London,GB
32.0.0.0,32.255.255.255,33.749,-84.388,Atlanta,US
33.0.0.0,33.127.255.255,22.5431,114.0579,Shenzhen,CN
33.128.0.0,33.255.255.255,52.52,13.405,Berlin,DE
34.0.0.0,34.207.255.255,39.0438,-77.4874,Ashburn,US
34.208.0.0,34.223.255.255,45.5152,-122.6784,Portland,US
34.224.0.0,34.239.255.255,39.0438,-77.4874,Ashburn,US
34.240.0.0,34.247.255.255,53.3498,-6.2603,Dublin,IE
34.248.0.0,34.255.255.255,39.0438,-77.4874,Ashburn,US
35.0.0.0,35.127.255.255,25.2048,55.2708,Dubai,AE
35.128.0.0,35.151.255.255,13.7563,100.5018,Bangkok,TH
35.152.0.0,35.152.255.255,45.4642,9.19,Milan,IT
35.153.0.0,35.153.255.255,31.2304,121.4737,Shanghai,CN
35.154.0.0,35.155.255.255,19.076,72.8777,Mumbai,IN
35.156.0.0,35.159.255.255,50.1109,8.6821,Frankfurt,DE
35.160.0.0,35.175.255.255,39.9042,116.4074,Beijing,CN
35.176.0.0,35.177.255.255,51.5074,-0.1278,London,GB
35.178.0.0,35.179.255.255,51.5074,-0.1278,London,GB
35.180.0.0,35.183.255.255,48.8566,2.3522,Paris,FR
35.184.0.0,35.191.255.255,35.6762,139.6503,Tokyo,JP
35.192.0.0,35.195.255.255,25.033,121.5654,Taipei,TW
35.196.0.0,35.198.255.255,37.5665,126.978,Seoul,KR
35.199.0.0,35.199.255.255,-23.5505,-46.6333,Sao Paulo,BR
35.200.0.0,35.203.255.255,35.6762,139.6503,Tokyo,JP
35.204.0.0,35.207.255.255,52.3676,4.9041,Amsterdam,NL
35.208.0.0,35.215.255.255,1.3521,103.8198,Singapore,SG
35.216.0.0,35.219.255.255,37.5665,126.978,Seoul,KR
35.220.0.0,35.221.255.255,32.7767,-96.797,Dallas,US
35.222.0.0,35.223.255.255,41.8781,-87.6298,Chicago,US
35.224.0.0,35.239.255.255,39.0438,-77.4874,Ashburn,US
35.240.0.0,35.247.255.255,51.5074,-0.1278,London,GB
35.248.0.0,35.255.255.255,51.5074,-0.1278,London,GB
36.0.0.0,36.255.255.255,23.1291,113.2644,Guangzhou,CN
37.0.0.0,37.255.255.255,48.8566,2.3522,Paris,FR
38.0.0.0,38.127.255.255,50.1109,8.6821,Frankfurt,DE
38.128.0.0,38.255.255.255,40.7128,-74.006,New York,US
39.0.0.0,39.95.255.255,37.7749,-122.4194,San Francisco,US
39.96.0.0,39.103.255.255,39.9042,116.4074,Beijing,CN
39.104.0.0,39.231.255.255,-33.8688,151.2093,Sydney,AU
39.232.0.0,40.63.255.255,19.076,72.8777,Mumbai,IN
40.64.0.0,40.127.255.255,39.0438,-77.4874,Ashburn,US
40.128.0.0,40.255.255.255,48.8566,2.3522,Paris,FR
41.0.0.0,41.127.255.255,52.3676,4.9041,Amsterdam,NL
41.128.0.0,41.255.255.255,-23.5505,-46.6333,Sao Paulo,BR
42.0.0.0,42.255.255.255,39.9042,116.4074,Beijing,CN
43.0.0.0,43.127.255.255,55.7558,37.6173,Moscow,RU
43.128.0.0,43.255.255.255,41.8781,-87.6298,Chicago,US
44.0.0.0,44.127.255.255,32.7767,-96.797,Dallas,US
44.128.0.0,44.191.255.255,33.749,-84.388,Atlanta,US
44.192.0.0,44.223.255.255,39.0438,-77.4874,Ashburn,US
44.224.0.0,44.255.255.255,45.5152,-122.6784,Portland,US
45.0.0.0,45.255.255.255,40.7128,-74.006,New York,US
46.0.0.0,46.136.255.255,52.3676,4.9041,Amsterdam,NL
46.137.0.0,46.137.127.255,1.3521,103.8198,Singapore,SG
46.137.128.0,46.255.255.255,52.3676,4.9041,Amsterdam,NL
47.0.0.0,47.15.255.255,43.6532,-79.3832,Toronto,CA
47.16.0.0,47.31.255.255,22.3193,114.1694,Hong Kong,HK
47.32.0.0,47.47.255.255,25.033,121.5654,Taipei,TW
47.48.0.0,47.51.255.255,40.4168,-3.7038,Madrid,ES
47.52.0.0,47.55.255.255,22.3193,114.1694,Hong Kong,HK
47.56.0.0,47.57.255.255,22.3193,114.1694,Hong Kong,HK
47.58.0.0,47.73.255.255,45.4642,9.19,Milan,IT
47.74.0.0,47.75.255.255,1.3521,103.8198,Singapore,SG
47.76.0.0,47.77.255.255,37.7749,-122.4194,San Francisco,US
47.78.0.0,47.87.255.255,59.3293,18.0686,Stockholm,SE
47.88.0.0,47.88.255.255,1.3521,103.8198,Singapore,SG
47.89.0.0,47.89.255.255,22.3193,114.1694,Hong Kong,HK
47.90.0.0,47.90.255.255,34.6937,135.5022,Osaka,JP
47.91.0.0,47.91.255.255,35.6762,139.6503,Tokyo,JP
47.92.0.0,47.95.255.255,39.9042,116.4074,Beijing,CN
47.96.0.0,47.97.255.255,30.2741,120.1551,Hangzhou,CN
47.98.0.0,47.99.255.255,30.2741,120.1551,Hangzhou,CN
47.100.0.0,47.103.255.255,31.2304,121.4737,Shanghai,CN
47.104.0.0,47.105.255.255,23.1291,113.2644,Guangzhou,CN
47.106.0.0,47.106.255.255,22.5431,114.0579,Shenzhen,CN
47.107.0.0,47.107.255.255,22.5431,114.0579,Shenzhen,CN
47.108.0.0,47.109.255.255,30.5728,104.0668,Chengdu,CN
47.110.0.0,47.111.255.255,52.52,13.405,Berlin,DE
47.112.0.0,47.112.255.255,19.076,72.8777,Mumbai,IN
47.113.0.0,47.113.255.255,25.2048,55.2708,Dubai,AE
47.114.0.0,47.114.255.255,-33.8688,151.2093,Sydney,AU
47.115.0.0,47.115.255.255,22.5431,114.0579,Shenzhen,CN
47.116.0.0,47.116.255.255,50.1109,8.6821,Frankfurt,DE
47.117.0.0,47.117.255.255,35.6762,139.6503,Tokyo,JP
47.118.0.0,47.118.255.255,51.5074,-0.1278,London,GB
47.119.0.0,47.119.255.255,25.2048,55.2708,Dubai,AE
47.120.0.0,47.120.255.255,3.139,101.6869,Kuala Lumpur,MY
47.121.0.0,47.121.255.255,13.7563,100.5018,Bangkok,TH
47.122.0.0,47.122.255.255,-6.2088,106.8456,Jakarta,ID
47.123.0.0,47.250.255.255,31.2304,121.4737,Shanghai,CN
47.251.0.0,47.252.255.255,39.9042,116.4074,Beijing,CN
47.253.0.0,47.253.255.255,37.7749,-122.4194,San Francisco,US
47.254.0.0,47.254.255.255,50.1109,8.6821,Frankfurt,DE
47.255.0.0,48.126.255.255,35.6762,139.6503,Tokyo,JP
48.127.0.0,48.254.255.255,37.5665,126.978,Seoul,KR
48.255.0.0,49.126.255.255,1.3521,103.8198,Singapore,SG
49.127.0.0,49.254.255.255,51.5074,-0.1278,London,GB
49.255.0.0,50.17.255.255,50.1109,8.6821,Frankfurt,DE
50.18.0.0,50.19.255.255,37.7749,-122.4194,San Francisco,US
50.20.0.0,50.35.255.255,40.7128,-74.006,New York,US
50.36.0.0,50.51.255.255,37.7749,-122.4194,San Francisco,US
50.52.0.0,50.67.255.255,-33.8688,151.2093,Sydney,AU
50.68.0.0,50.83.255.255,19.076,72.8777,Mumbai,IN
50.84.0.0,50.99.255.255,48.8566,2.3522,Paris,FR
50.100.0.0,50.111.255.255,52.3676,4.9041,Amsterdam,NL
50.112.0.0,50.112.255.255,45.5152,-122.6784,Portland,US
50.113.0.0,50.127.255.255,-23.5505,-46.6333,Sao Paulo,BR
50.128.0.0,50.255.255.255,41.8781,-87.6298,Chicago,US
51.0.0.0,51.102.255.255,51.5074,-0.1278,London,GB
51.103.0.0,51.103.255.255,50.1109,8.6821,Frankfurt,DE
51.104.0.0,51.104.255.255,51.5074,-0.1278,London,GB
51.105.0.0,51.105.255.255,51.5074,-0.1278,London,GB
51.106.0.0,51.139.255.255,51.5074,-0.1278,London,GB
51.140.0.0,51.143.255.255,51.5074,-0.1278,London,GB
51.144.0.0,51.255.255.255,51.5074,-0.1278,London,GB
52.0.0.0,52.1.255.255,39.0438,-77.4874,Ashburn,US
52.2.0.0,52.3.255.255,39.0438,-77.4874,Ashburn,US
52.4.0.0,52.7.255.255,39.0438,-77.4874,Ashburn,US
52.8.0.0,52.15.255.255,55.7558,37.6173,Moscow,RU
52.16.0.0,52.19.255.255,53.3498,-6.2603,Dublin,IE
52.20.0.0,52.23.255.255,39.0438,-77.4874,Ashburn,US
52.24.0.0,52.27.255.255,41.8781,-87.6298,Chicago,US
52.28.0.0,52.31.255.255,50.1109,8.6821,Frankfurt,DE
52.32.0.0,52.46.255.255,32.7767,-96.797,Dallas,US
52.47.0.0,52.47.255.255,48.8566,2.3522,Paris,FR
52.48.0.0,52.51.255.255,53.3498,-6.2603,Dublin,IE
52.52.0.0,52.56.255.255,33.749,-84.388,Atlanta,US
52.57.0.0,52.57.255.255,50.1109,8.6821,Frankfurt,DE
52.58.0.0,52.59.255.255,43.6532,-79.3832,Toronto,CA
52.60.0.0,52.60.255.255,45.5017,-73.5673,Montreal,CA
52.61.0.0,52.61.255.255,22.3193,114.1694,Hong Kong,HK
52.62.0.0,52.62.255.255,-33.8688,151.2093,Sydney,AU
52.63.0.0,52.65.255.255,25.033,121.5654,Taipei,TW
52.66.0.0,52.66.255.255,19.076,72.8777,Mumbai,IN
52.67.0.0,52.73.255.255,40.4168,-3.7038,Madrid,ES
52.74.0.0,52.74.255.255,1.3521,103.8198,Singapore,SG
52.75.0.0,52.77.255.255,45.4642,9.19,Milan,IT
52.78.0.0,52.78.255.255,37.5665,126.978,Seoul,KR
52.79.0.0,52.94.255.255,59.3293,18.0686,Stockholm,SE
52.95.0.0,52.110.255.255,34.6937,135.5022,Osaka,JP
52.111.0.0,52.126.255.255,23.1291,113.2644,Guangzhou,CN
52.127.0.0,52.138.255.255,22.5431,114.0579,Shenzhen,CN
52.139.0.0,52.139.255.255,1.3521,103.8198,Singapore,SG
52.140.0.0,52.140.255.255,52.52,13.405,Berlin,DE
52.141.0.0,52.141.255.255,37.5665,126.978,Seoul,KR
52.142.0.0,52.144.255.255,25.2048,55.2708,Dubai,AE
52.145.0.0,52.145.255.255,39.0438,-77.4874,Ashburn,US
52.146.0.0,52.146.255.255,13.7563,100.5018,Bangkok,TH
52.147.0.0,52.147.255.255,-33.8688,151.2093,Sydney,AU
52.148.0.0,52.163.255.255,31.2304,121.4737,Shanghai,CN
52.164.0.0,52.179.255.255,39.9042,116.4074,Beijing,CN
52.180.0.0,52.184.255.255,35.6762,139.6503,Tokyo,JP
52.185.0.0,52.185.255.255,35.6762,139.6503,Tokyo,JP
52.186.0.0,52.201.255.255,37.5665,126.978,Seoul,KR
52.202.0.0,52.217.255.255,1.3521,103.8198,Singapore,SG
52.218.0.0,52.223.255.255,51.5074,-0.1278,London,GB
52.224.0.0,52.255.255.255,39.0438,-77.4874,Ashburn,US
53.0.0.0,53.127.255.255,50.1109,8.6821,Frankfurt,DE
53.128.0.0,53.255.255.255,40.7128,-74.006,New York,US
54.0.0.0,54.63.255.255,37.7749,-122.4194,San Francisco,US
54.64.0.0,54.65.255.255,35.6762,139.6503,Tokyo,JP
54.66.0.0,54.66.255.255,-33.8688,151.2093,Sydney,AU
54.67.0.0,54.67.255.255,-33.8688,151.2093,Sydney,AU
54.68.0.0,54.71.255.255,45.5152,-122.6784,Portland,US
54.72.0.0,54.79.255.255,53.3498,-6.2603,Dublin,IE
54.80.0.0,54.95.255.255,39.0438,-77.4874,Ashburn,US
54.96.0.0,54.111.255.255,19.076,72.8777,Mumbai,IN
54.112.0.0,54.127.255.255,48.8566,2.3522,Paris,FR
54.128.0.0,54.143.255.255,52.3676,4.9041,Amsterdam,NL
54.144.0.0,54.151.255.255,-23.5505,-46.6333,Sao Paulo,BR
54.152.0.0,54.159.255.255,39.0438,-77.4874,Ashburn,US
54.160.0.0,54.191.255.255,39.0438,-77.4874,Ashburn,US
54.192.0.0,54.205.255.255,55.7558,37.6173,Moscow,RU
54.206.0.0,54.206.255.255,-33.8688,151.2093,Sydney,AU
54.207.0.0,54.207.255.255,41.8781,-87.6298,Chicago,US
54.208.0.0,54.209.255.255,39.0438,-77.4874,Ashburn,US
54.210.0.0,54.223.255.255,32.7767,-96.797,Dallas,US
54.224.0.0,54.239.255.255,39.0438,-77.4874,Ashburn,US
54.240.0.0,54.240.255.255,33.749,-84.388,Atlanta,US
54.241.0.0,54.241.255.255,37.7749,-122.4194,San Francisco,US
54.242.0.0,54.243.255.255,43.6532,-79.3832,Toronto,CA
54.244.0.0,54.245.255.255,45.5152,-122.6784,Portland,US
54.246.0.0,55.117.255.255,22.3193,114.1694,Hong Kong,HK
55.118.0.0,55.245.255.255,25.033,121.5654,Taipei,TW
55.246.0.0,56.117.255.255,40.4168,-3.7038,Madrid,ES
56.118.0.0,56.245.255.255,45.4642,9.19,Milan,IT
56.246.0.0,57.117.255.255,59.3293,18.0686,Stockholm,SE
57.118.0.0,57.245.255.255,34.6937,135.5022,Osaka,JP
57.246.0.0,57.255.255.255,23.1291,113.2644,Guangzhou,CN
58.0.0.0,58.255.255.255,22.5431,114.0579,Shenzhen,CN
59.0.0.0,59.109.255.255,31.2304,121.4737,Shanghai,CN
59.110.0.0,59.110.255.255,39.9042,116.4074,Beijing,CN
59.111.0.0,59.255.255.255,31.2304,121.4737,Shanghai,CN
60.0.0.0,60.255.255.255,39.9042,116.4074,Beijing,CN
61.0.0.0,61.255.255.255,31.2304,121.4737,Shanghai,CN
62.0.0.0,62.255.255.255,52.3676,4.9041,Amsterdam,NL
63.0.0.0,63.15.255.255,22.5431,114.0579,Shenzhen,CN
63.16.0.0,63.31.255.255,52.52,13.405,Berlin,DE
63.32.0.0,63.35.255.255,53.3498,-6.2603,Dublin,IE
63.36.0.0,63.163.255.255,25.2048,55.2708,Dubai,AE
63.164.0.0,63.255.255.255,13.7563,100.5018,Bangkok,TH
64.0.0.0,64.255.255.255,41.8781,-87.6298,Chicago,US
65.0.0.0,65.255.255.255,37.7749,-122.4194,San Francisco,US
66.0.0.0,66.255.255.255,32.7767,-96.797,Dallas,US
67.0.0.0,67.159.255.255,25.7617,-80.1918,Miami,US
67.160.0.0,67.191.255.255,39.9526,-75.1652,Philadelphia,US
67.192.0.0,67.255.255.255,25.7617,-80.1918,Miami,US
68.0.0.0,68.127.255.255,31.2304,121.4737,Shanghai,CN
68.128.0.0,68.255.255.255,39.9042,116.4074,Beijing,CN
69.0.0.0,69.135.255.255,40.7128,-74.006,New York,US
69.136.0.0,69.143.255.255,42.3601,-71.0589,Boston,US
69.144.0.0,69.239.255.255,40.7128,-74.006,New York,US
69.240.0.0,69.255.255.255,47.6062,-122.3321,Seattle,US
70.0.0.0,70.255.255.255,41.8781,-87.6298,Chicago,US
71.0.0.0,71.127.255.255,35.6762,139.6503,Tokyo,JP
71.128.0.0,71.159.255.255,37.5665,126.978,Seoul,KR
71.160.0.0,71.175.255.255,40.7128,-74.006,New York,US
71.176.0.0,71.191.255.255,1.3521,103.8198,Singapore,SG
71.192.0.0,71.255.255.255,37.7749,-122.4194,San Francisco,US
72.0.0.0,72.15.255.255,51.5074,-0.1278,London,GB
72.16.0.0,72.31.255.255,50.1109,8.6821,Frankfurt,DE
72.32.0.0,72.47.255.255,40.7128,-74.006,New York,US
72.48.0.0,72.63.255.255,37.7749,-122.4194,San Francisco,US
72.64.0.0,72.127.255.255,32.7767,-96.797,Dallas,US
72.128.0.0,72.143.255.255,-33.8688,151.2093,Sydney,AU
72.144.0.0,72.159.255.255,19.076,72.8777,Mumbai,IN
72.160.0.0,72.175.255.255,48.8566,2.3522,Paris,FR
72.176.0.0,72.191.255.255,52.3676,4.9041,Amsterdam,NL
72.192.0.0,72.207.255.255,-23.5505,-46.6333,Sao Paulo,BR
72.208.0.0,72.223.255.255,55.7558,37.6173,Moscow,RU
72.224.0.0,72.239.255.255,41.8781,-87.6298,Chicago,US
72.240.0.0,72.245.255.255,32.7767,-96.797,Dallas,US
72.246.0.0,72.247.255.255,42.3736,-71.1097,Cambridge,US
72.248.0.0,72.255.255.255,33.749,-84.388,Atlanta,US
73.0.0.0,73.255.255.255,33.749,-84.388,Atlanta,US
74.0.0.0,74.15.255.255,43.6532,-79.3832,Toronto,CA
74.16.0.0,74.31.255.255,22.3193,114.1694,Hong Kong,HK
74.32.0.0,74.47.255.255,25.033,121.5654,Taipei,TW
74.48.0.0,74.63.255.255,40.4168,-3.7038,Madrid,ES
74.64.0.0,74.79.255.255,45.4642,9.19,Milan,IT
74.80.0.0,74.95.255.255,59.3293,18.0686,Stockholm,SE
74.96.0.0,74.127.255.255,33.749,-84.388,Atlanta,US
74.128.0.0,74.143.255.255,34.6937,135.5022,Osaka,JP
74.144.0.0,74.159.255.255,23.1291,113.2644,Guangzhou,CN
74.160.0.0,74.175.255.255,22.5431,114.0579,Shenzhen,CN
74.176.0.0,74.191.255.255,52.52,13.405,Berlin,DE
74.192.0.0,74.207.255.255,25.2048,55.2708,Dubai,AE
74.208.0.0,74.223.255.255,13.7563,100.5018,Bangkok,TH
74.224.0.0,74.239.255.255,31.2304,121.4737,Shanghai,CN
74.240.0.0,74.255.255.255,39.9042,116.4074,Beijing,CN
75.0.0.0,75.255.255.255,37.7749,-122.4194,San Francisco,US
76.0.0.0,76.255.255.255,29.7604,-95.3698,Houston,US
77.0.0.0,77.255.255.255,52.3676,4.9041,Amsterdam,NL
78.0.0.0,78.255.255.255,52.52,13.405,Berlin,DE
79.0.0.0,79.191.255.255,52.52,13.405,Berlin,DE
79.192.0.0,79.255.255.255,50.1109,8.6821,Frankfurt,DE
80.0.0.0,80.127.255.255,52.3676,4.9041,Amsterdam,NL
80.128.0.0,80.159.255.255,52.52,13.405,Berlin,DE
80.160.0.0,80.255.255.255,52.3676,4.9041,Amsterdam,NL
81.0.0.0,81.255.255.255,51.5074,-0.1278,London,GB
82.0.0.0,82.255.255.255,52.3676,4.9041,Amsterdam,NL
83.0.0.0,83.255.255.255,52.3676,4.9041,Amsterdam,NL
84.0.0.0,84.255.255.255,52.3676,4.9041,Amsterdam,NL
85.0.0.0,85.255.255.255,52.3676,4.9041,Amsterdam,NL
86.0.0.0,86.255.255.255,52.3676,4.9041,Amsterdam,NL
87.0.0.0,87.127.255.255,52.52,13.405,Berlin,DE
87.128.0.0,87.191.255.255,48.1351,11.582,Munich,DE
87.192.0.0,87.255.255.255,52.52,13.405,Berlin,DE
88.0.0.0,88.255.255.255,52.3676,4.9041,Amsterdam,NL
89.0.0.0,89.255.255.255,52.3676,4.9041,Amsterdam,NL
90.0.0.0,90.255.255.255,52.3676,4.9041,Amsterdam,NL
91.0.0.0,91.63.255.255,50.1109,8.6821,Frankfurt,DE
91.64.0.0,91.191.255.255,35.6762,139.6503,Tokyo,JP
91.192.0.0,91.255.255.255,37.5665,126.978,Seoul,KR
92.0.0.0,92.121.255.255,55.7558,37.6173,Moscow,RU
92.122.0.0,92.123.255.255,42.3736,-71.1097,Cambridge,US
92.124.0.0,92.255.255.255,55.7558,37.6173,Moscow,RU
93.0.0.0,93.127.255.255,1.3521,103.8198,Singapore,SG
93.128.0.0,93.191.255.255,51.5074,-0.1278,London,GB
93.192.0.0,93.255.255.255,52.52,13.405,Berlin,DE
94.0.0.0,94.255.255.255,55.7558,37.6173,Moscow,RU
95.0.0.0,95.99.255.255,52.52,13.405,Berlin,DE
95.100.0.0,95.101.255.255,42.3736,-71.1097,Cambridge,US
95.102.0.0,95.255.255.255,52.52,13.405,Berlin,DE
96.0.0.0,96.5.255.255,50.1109,8.6821,Frankfurt,DE
96.6.0.0,96.7.255.255,42.3736,-71.1097,Cambridge,US
96.8.0.0,96.15.255.255,40.7128,-74.006,New York,US
96.16.0.0,96.17.255.255,42.3736,-71.1097,Cambridge,US
96.18.0.0,96.145.255.255,37.7749,-122.4194,San Francisco,US
96.146.0.0,96.223.255.255,-33.8688,151.2093,Sydney,AU
96.224.0.0,96.255.255.255,40.7128,-74.006,New York,US
97.0.0.0,97.127.255.255,19.076,72.8777,Mumbai,IN
97.128.0.0,97.255.255.255,48.8566,2.3522,Paris,FR
98.0.0.0,98.127.255.255,52.3676,4.9041,Amsterdam,NL
98.128.0.0,98.191.255.255,-23.5505,-46.6333,Sao Paulo,BR
98.192.0.0,98.255.255.255,41.8781,-87.6298,Chicago,US
99.0.0.0,99.78.255.255,40.7128,-74.006,New York,US
99.79.0.0,99.79.255.255,45.5017,-73.5673,Montreal,CA
99.80.0.0,99.255.255.255,40.7128,-74.006,New York,US
100.0.0.0,100.63.255.255,41.8781,-87.6298,Chicago,US
100.64.0.0,100.191.255.255,55.7558,37.6173,Moscow,RU
100.192.0.0,100.255.255.255,41.8781,-87.6298,Chicago,US
101.0.0.0,101.131.255.255,39.9042,116.4074,Beijing,CN
101.132.0.0,101.132.255.255,31.2304,121.4737,Shanghai,CN
101.133.0.0,101.199.255.255,39.9042,116.4074,Beijing,CN
101.200.0.0,101.201.255.255,39.9042,116.4074,Beijing,CN
101.202.0.0,101.255.255.255,39.9042,116.4074,Beijing,CN
102.0.0.0,102.127.255.255,32.7767,-96.797,Dallas,US
102.128.0.0,102.255.255.255,33.749,-84.388,Atlanta,US
103.0.0.0,103.21.243.255,43.6532,-79.3832,Toronto,CA
103.21.244.0,103.21.247.255,37.7749,-122.4194,San Francisco,US
103.21.248.0,103.22.199.255,22.3193,114.1694,Hong Kong,HK
103.22.200.0,103.22.203.255,37.7749,-122.4194,San Francisco,US
103.22.204.0,103.31.3.255,25.033,121.5654,Taipei,TW
103.31.4.0,103.31.7.255,37.7749,-122.4194,San Francisco,US
103.31.8.0,103.159.7.255,40.4168,-3.7038,Madrid,ES
103.159.8.0,103.255.255.255,45.4642,9.19,Milan,IT
104.0.0.0,104.15.255.255,32.7767,-96.797,Dallas,US
104.16.0.0,104.23.255.255,37.7749,-122.4194,San Francisco,US
104.24.0.0,104.27.255.255,37.7749,-122.4194,San Francisco,US
104.28.0.0,104.39.255.255,32.7767,-96.797,Dallas,US
104.40.0.0,104.47.255.255,39.0438,-77.4874,Ashburn,US
104.48.0.0,104.63.255.255,32.7767,-96.797,Dallas,US
104.64.0.0,104.127.255.255,42.3736,-71.1097,Cambridge,US
104.128.0.0,104.151.255.255,32.7767,-96.797,Dallas,US
104.152.0.0,104.153.255.255,50.1109,8.6821,Frankfurt,DE
104.154.0.0,104.155.255.255,41.8781,-87.6298,Chicago,US
104.156.0.0,104.195.255.255,32.7767,-96.797,Dallas,US
104.196.0.0,104.199.255.255,39.0438,-77.4874,Ashburn,US
104.200.0.0,104.210.255.255,32.7767,-96.797,Dallas,US
104.211.0.0,104.211.255.255,19.076,72.8777,Mumbai,IN
104.212.0.0,104.255.255.255,32.7767,-96.797,Dallas,US
105.0.0.0,105.127.255.255,59.3293,18.0686,Stockholm,SE
105.128.0.0,105.255.255.255,34.6937,135.5022,Osaka,JP
106.0.0.0,106.13.255.255,23.1291,113.2644,Guangzhou,CN
106.14.0.0,106.15.255.255,31.2304,121.4737,Shanghai,CN
106.16.0.0,106.143.255.255,22.5431,114.0579,Shenzhen,CN
106.144.0.0,106.255.255.255,52.52,13.405,Berlin,DE
107.0.0.0,107.255.255.255,33.749,-84.388,Atlanta,US
108.0.0.0,108.162.191.255,41.8781,-87.6298,Chicago,US
108.162.192.0,108.162.255.255,37.7749,-122.4194,San Francisco,US
108.163.0.0,108.255.255.255,41.8781,-87.6298,Chicago,US
109.0.0.0,109.255.255.255,51.5074,-0.1278,London,GB
110.0.0.0,110.255.255.255,23.1291,113.2644,Guangzhou,CN
111.0.0.0,111.255.255.255,31.2304,121.4737,Shanghai,CN
112.0.0.0,112.255.255.255,39.9042,116.4074,Beijing,CN
113.0.0.0,113.255.255.255,31.2304,121.4737,Shanghai,CN
114.0.0.0,114.255.255.255,39.9042,116.4074,Beijing,CN
115.0.0.0,115.255.255.255,31.2304,121.4737,Shanghai,CN
116.0.0.0,116.255.255.255,39.9042,116.4074,Beijing,CN
117.0.0.0,117.255.255.255,31.2304,121.4737,Shanghai,CN
118.0.0.0,118.255.255.255,39.9042,116.4074,Beijing,CN
119.0.0.0,119.255.255.255,31.2304,121.4737,Shanghai,CN
120.0.0.0,120.23.255.255,39.9042,116.4074,Beijing,CN
120.24.0.0,120.27.255.255,39.9042,116.4074,Beijing,CN
120.28.0.0,120.47.255.255,39.9042,116.4074,Beijing,CN
120.48.0.0,120.49.255.255,39.9042,116.4074,Beijing,CN
120.50.0.0,120.54.255.255,39.9042,116.4074,Beijing,CN
120.55.0.0,120.55.255.255,31.2304,121.4737,Shanghai,CN
120.56.0.0,120.75.255.255,39.9042,116.4074,Beijing,CN
120.76.0.0,120.77.255.255,30.2741,120.1551,Hangzhou,CN
120.78.0.0,120.79.255.255,22.5431,114.0579,Shenzhen,CN
120.80.0.0,120.81.255.255,23.1291,113.2644,Guangzhou,CN
120.82.0.0,120.255.255.255,39.9042,116.4074,Beijing,CN
121.0.0.0,121.39.255.255,31.2304,121.4737,Shanghai,CN
121.40.0.0,121.43.255.255,30.2741,120.1551,Hangzhou,CN
121.44.0.0,121.195.255.255,31.2304,121.4737,Shanghai,CN
121.196.0.0,121.199.255.255,30.2741,120.1551,Hangzhou,CN
121.200.0.0,121.255.255.255,31.2304,121.4737,Shanghai,CN
122.0.0.0,122.255.255.255,39.9042,116.4074,Beijing,CN
123.0.0.0,123.255.255.255,31.2304,121.4737,Shanghai,CN
124.0.0.0,124.255.255.255,39.9042,116.4074,Beijing,CN
125.0.0.0,125.255.255.255,31.2304,121.4737,Shanghai,CN
126.0.0.0,126.255.255.255,35.6762,139.6503,Tokyo,JP
127.0.0.0,127.127.255.255,25.2048,55.2708,Dubai,AE
127.128.0.0,127.255.255.255,13.7563,100.5018,Bangkok,TH
128.0.0.0,128.255.255.255,48.8566,2.3522,Paris,FR
129.0.0.0,129.255.255.255,48.8566,2.3522,Paris,FR
130.0.0.0,130.255.255.255,52.52,13.405,Berlin,DE
131.0.0.0,131.0.71.255,59.3293,18.0686,Stockholm,SE
131.0.72.0,131.0.75.255,37.7749,-122.4194,San Francisco,US
131.0.76.0,131.255.255.255,59.3293,18.0686,Stockholm,SE
132.0.0.0,132.255.255.255,45.4642,9.19,Milan,IT
133.0.0.0,133.255.255.255,35.6762,139.6503,Tokyo,JP
134.0.0.0,134.255.255.255,52.52,13.405,Berlin,DE
135.0.0.0,135.127.255.255,31.2304,121.4737,Shanghai,CN
135.128.0.0,135.255.255.255,39.9042,116.4074,Beijing,CN
136.0.0.0,136.127.255.255,35.6762,139.6503,Tokyo,JP
136.128.0.0,136.255.255.255,37.5665,126.978,Seoul,KR
137.0.0.0,137.255.255.255,48.8566,2.3522,Paris,FR
138.0.0.0,138.90.255.255,45.4642,9.19,Milan,IT
138.91.0.0,138.91.255.255,37.7749,-122.4194,San Francisco,US
138.92.0.0,138.255.255.255,45.4642,9.19,Milan,IT
139.0.0.0,139.195.255.255,23.1291,113.2644,Guangzhou,CN
139.196.0.0,139.199.255.255,31.2304,121.4737,Shanghai,CN
139.200.0.0,139.255.255.255,23.1291,113.2644,Guangzhou,CN
140.0.0.0,140.127.255.255,1.3521,103.8198,Singapore,SG
140.128.0.0,140.255.255.255,51.5074,-0.1278,London,GB
141.0.0.0,141.101.63.255,52.52,13.405,Berlin,DE
141.101.64.0,141.101.127.255,37.7749,-122.4194,San Francisco,US
141.101.128.0,141.143.255.255,52.52,13.405,Berlin,DE
141.144.0.0,141.159.255.255,32.7767,-96.797,Dallas,US
141.160.0.0,141.255.255.255,52.52,13.405,Berlin,DE
142.0.0.0,142.127.255.255,50.1109,8.6821,Frankfurt,DE
142.128.0.0,142.255.255.255,40.7128,-74.006,New York,US
143.0.0.0,143.255.255.255,45.4642,9.19,Milan,IT
144.0.0.0,144.127.255.255,37.7749,-122.4194,San Francisco,US
144.128.0.0,144.255.255.255,-33.8688,151.2093,Sydney,AU
145.0.0.0,145.255.255.255,48.8566,2.3522,Paris,FR
146.0.0.0,146.255.255.255,51.5074,-0.1278,London,GB
147.0.0.0,147.255.255.255,48.8566,2.3522,Paris,FR
148.0.0.0,148.127.255.255,19.076,72.8777,Mumbai,IN
148.128.0.0,148.255.255.255,48.8566,2.3522,Paris,FR
149.0.0.0,149.128.255.255,52.52,13.405,Berlin,DE
149.129.0.0,149.129.255.255,1.3521,103.8198,Singapore,SG
149.130.0.0,149.255.255.255,52.52,13.405,Berlin,DE
150.0.0.0,150.191.255.255,35.6762,139.6503,Tokyo,JP
150.192.0.0,150.255.255.255,40.4168,-3.7038,Madrid,ES
151.0.0.0,151.100.255.255,45.4642,9.19,Milan,IT
151.101.0.0,151.101.255.255,37.7749,-122.4194,San Francisco,US
151.102.0.0,151.255.255.255,45.4642,9.19,Milan,IT
152.0.0.0,152.127.255.255,52.3676,4.9041,Amsterdam,NL
152.128.0.0,152.255.255.255,-23.5505,-46.6333,Sao Paulo,BR
153.0.0.0,153.127.255.255,55.7558,37.6173,Moscow,RU
153.128.0.0,153.191.255.255,34.6937,135.5022,Osaka,JP
153.192.0.0,154.63.255.255,41.8781,-87.6298,Chicago,US
154.64.0.0,154.191.255.255,32.7767,-96.797,Dallas,US
154.192.0.0,154.255.255.255,33.749,-84.388,Atlanta,US
155.0.0.0,155.255.255.255,59.3293,18.0686,Stockholm,SE
156.0.0.0,156.255.255.255,48.8566,2.3522,Paris,FR
157.0.0.0,157.52.63.255,35.6762,139.6503,Tokyo,JP
157.52.64.0,157.52.127.255,37.7749,-122.4194,San Francisco,US
157.52.128.0,157.52.255.255,37.7749,-122.4194,San Francisco,US
157.53.0.0,157.174.255.255,35.6762,139.6503,Tokyo,JP
157.175.0.0,157.175.255.255,26.0667,50.5577,Bahrain,BH
157.176.0.0,157.255.255.255,35.6762,139.6503,Tokyo,JP
158.0.0.0,158.255.255.255,59.3293,18.0686,Stockholm,SE
159.0.0.0,159.255.255.255,48.8566,2.3522,Paris,FR
160.0.0.0,160.255.255.255,34.6937,135.5022,Osaka,JP
161.0.0.0,161.255.255.255,48.8566,2.3522,Paris,FR
162.0.0.0,162.127.255.255,43.6532,-79.3832,Toronto,CA
162.128.0.0,162.157.255.255,22.3193,114.1694,Hong Kong,HK
162.158.0.0,162.159.255.255,37.7749,-122.4194,San Francisco,US
162.160.0.0,162.175.255.255,25.033,121.5654,Taipei,TW
162.176.0.0,162.191.255.255,40.4168,-3.7038,Madrid,ES
162.192.0.0,162.207.255.255,45.4642,9.19,Milan,IT
162.208.0.0,162.223.255.255,59.3293,18.0686,Stockholm,SE
162.224.0.0,162.239.255.255,34.6937,135.5022,Osaka,JP
162.240.0.0,162.255.255.255,23.1291,113.2644,Guangzhou,CN
163.0.0.0,163.255.255.255,35.6762,139.6503,Tokyo,JP
164.0.0.0,164.255.255.255,34.6937,135.5022,Osaka,JP
165.0.0.0,165.127.255.255,22.5431,114.0579,Shenzhen,CN
165.128.0.0,165.255.255.255,52.52,13.405,Berlin,DE
166.0.0.0,166.127.255.255,25.2048,55.2708,Dubai,AE
166.128.0.0,166.255.255.255,13.7563,100.5018,Bangkok,TH
167.0.0.0,167.81.255.255,31.2304,121.4737,Shanghai,CN
167.82.0.0,167.82.127.255,37.7749,-122.4194,San Francisco,US
167.82.128.0,167.82.191.255,37.7749,-122.4194,San Francisco,US
167.82.192.0,167.210.191.255,39.9042,116.4074,Beijing,CN
167.210.192.0,168.82.191.255,35.6762,139.6503,Tokyo,JP
168.82.192.0,168.210.191.255,37.5665,126.978,Seoul,KR
168.210.192.0,169.82.191.255,1.3521,103.8198,Singapore,SG
169.82.192.0,169.210.191.255,51.5074,-0.1278,London,GB
169.210.192.0,170.82.191.255,50.1109,8.6821,Frankfurt,DE
170.82.192.0,170.210.191.255,40.7128,-74.006,New York,US
170.210.192.0,170.255.255.255,37.7749,-122.4194,San Francisco,US
171.0.0.0,171.255.255.255,59.3293,18.0686,Stockholm,SE
172.0.0.0,172.15.255.255,-33.8688,151.2093,Sydney,AU
172.16.0.0,172.31.255.255,31.2304,121.4737,Shanghai,CN
172.32.0.0,172.47.255.255,19.076,72.8777,Mumbai,IN
172.48.0.0,172.63.255.255,48.8566,2.3522,Paris,FR
172.64.0.0,172.71.255.255,37.7749,-122.4194,San Francisco,US
172.72.0.0,172.87.255.255,52.3676,4.9041,Amsterdam,NL
172.88.0.0,172.103.255.255,-23.5505,-46.6333,Sao Paulo,BR
172.104.0.0,172.111.63.255,55.7558,37.6173,Moscow,RU
172.111.64.0,172.111.127.255,37.7749,-122.4194,San Francisco,US
172.111.128.0,172.239.127.255,41.8781,-87.6298,Chicago,US
172.239.128.0,173.47.255.255,32.7767,-96.797,Dallas,US
173.48.0.0,173.63.255.255,25.7617,-80.1918,Miami,US
173.64.0.0,173.191.255.255,33.749,-84.388,Atlanta,US
173.192.0.0,173.245.47.255,43.6532,-79.3832,Toronto,CA
173.245.48.0,173.245.63.255,37.7749,-122.4194,San Francisco,US
173.245.64.0,174.117.63.255,22.3193,114.1694,Hong Kong,HK
174.117.64.0,174.245.63.255,25.033,121.5654,Taipei,TW
174.245.64.0,175.117.63.255,40.4168,-3.7038,Madrid,ES
175.117.64.0,175.245.63.255,45.4642,9.19,Milan,IT
175.245.64.0,175.255.255.255,59.3293,18.0686,Stockholm,SE
176.0.0.0,176.255.255.255,51.5074,-0.1278,London,GB
177.0.0.0,177.127.255.255,34.6937,135.5022,Osaka,JP
177.128.0.0,177.255.255.255,23.1291,113.2644,Guangzhou,CN
178.0.0.0,178.127.255.255,22.5431,114.0579,Shenzhen,CN
178.128.0.0,178.255.255.255,52.52,13.405,Berlin,DE
179.0.0.0,179.127.255.255,25.2048,55.2708,Dubai,AE
179.128.0.0,179.255.255.255,13.7563,100.5018,Bangkok,TH
180.0.0.0,180.255.255.255,31.2304,121.4737,Shanghai,CN
181.0.0.0,181.127.255.255,31.2304,121.4737,Shanghai,CN
181.128.0.0,181.255.255.255,39.9042,116.4074,Beijing,CN
182.0.0.0,182.91.255.255,39.9042,116.4074,Beijing,CN
182.92.0.0,182.92.255.255,39.9042,116.4074,Beijing,CN
182.93.0.0,182.255.255.255,39.9042,116.4074,Beijing,CN
183.0.0.0,183.255.255.255,31.2304,121.4737,Shanghai,CN
184.0.0.0,184.15.255.255,35.6762,139.6503,Tokyo,JP
184.16.0.0,184.23.255.255,37.5665,126.978,Seoul,KR
184.24.0.0,184.31.255.255,42.3736,-71.1097,Cambridge,US
184.32.0.0,184.47.255.255,1.3521,103.8198,Singapore,SG
184.48.0.0,184.63.255.255,51.5074,-0.1278,London,GB
184.64.0.0,184.71.255.255,50.1109,8.6821,Frankfurt,DE
184.72.0.0,184.73.255.255,37.7749,-122.4194,San Francisco,US
184.74.0.0,184.83.255.255,40.7128,-74.006,New York,US
184.84.0.0,184.87.255.255,42.3736,-71.1097,Cambridge,US
184.88.0.0,184.215.255.255,37.7749,-122.4194,San Francisco,US
184.216.0.0,184.255.255.255,-33.8688,151.2093,Sydney,AU
185.0.0.0,185.31.15.255,51.5074,-0.1278,London,GB
185.31.16.0,185.31.19.255,37.7749,-122.4194,San Francisco,US
185.31.20.0,185.255.255.255,51.5074,-0.1278,London,GB
186.0.0.0,186.127.255.255,19.076,72.8777,Mumbai,IN
186.128.0.0,186.255.255.255,48.8566,2.3522,Paris,FR
187.0.0.0,187.127.255.255,52.3676,4.9041,Amsterdam,NL
187.128.0.0,187.255.255.255,-23.5505,-46.6333,Sao Paulo,BR
188.0.0.0,188.114.95.255,51.5074,-0.1278,London,GB
188.114.96.0,188.114.111.255,37.7749,-122.4194,San Francisco,US
188.114.112.0,188.255.255.255,51.5074,-0.1278,London,GB
189.0.0.0,189.127.255.255,55.7558,37.6173,Moscow,RU
189.128.0.0,189.255.255.255,41.8781,-87.6298,Chicago,US
190.0.0.0,190.93.239.255,32.7767,-96.797,Dallas,US
190.93.240.0,190.93.255.255,37.7749,-122.4194,San Francisco,US
190.94.0.0,190.221.255.255,33.749,-84.388,Atlanta,US
190.222.0.0,191.93.255.255,43.6532,-79.3832,Toronto,CA
191.94.0.0,191.221.255.255,22.3193,114.1694,Hong Kong,HK
191.222.0.0,191.231.255.255,25.033,121.5654,Taipei,TW
191.232.0.0,191.233.255.255,-23.5505,-46.6333,Sao Paulo,BR
191.234.0.0,191.249.255.255,40.4168,-3.7038,Madrid,ES
191.250.0.0,192.9.255.255,45.4642,9.19,Milan,IT
192.10.0.0,192.25.255.255,59.3293,18.0686,Stockholm,SE
192.26.0.0,192.41.255.255,34.6937,135.5022,Osaka,JP
192.42.0.0,192.49.255.255,23.1291,113.2644,Guangzhou,CN
192.50.0.0,192.50.255.255,35.1815,136.9066,Nagoya,JP
192.51.0.0,192.66.255.255,22.5431,114.0579,Shenzhen,CN
192.67.0.0,192.82.255.255,52.52,13.405,Berlin,DE
192.83.0.0,192.98.255.255,25.2048,55.2708,Dubai,AE
192.99.0.0,192.114.255.255,13.7563,100.5018,Bangkok,TH
192.115.0.0,192.130.255.255,31.2304,121.4737,Shanghai,CN
192.131.0.0,192.146.255.255,39.9042,116.4074,Beijing,CN
192.147.0.0,192.162.255.255,35.6762,139.6503,Tokyo,JP
192.163.0.0,192.167.255.255,37.5665,126.978,Seoul,KR
192.168.0.0,192.168.255.255,23.1291,113.2644,Guangzhou,CN
192.169.0.0,192.184.255.255,1.3521,103.8198,Singapore,SG
192.185.0.0,192.200.255.255,51.5074,-0.1278,London,GB
192.201.0.0,192.216.255.255,50.1109,8.6821,Frankfurt,DE
192.217.0.0,192.232.255.255,40.7128,-74.006,New York,US
192.233.0.0,192.248.255.255,37.7749,-122.4194,San Francisco,US
192.249.0.0,192.255.255.255,-33.8688,151.2093,Sydney,AU
193.0.0.0,193.255.255.255,52.52,13.405,Berlin,DE
194.0.0.0,194.255.255.255,48.8566,2.3522,Paris,FR
195.0.0.0,195.255.255.255,52.3676,4.9041,Amsterdam,NL
196.0.0.0,196.127.255.255,19.076,72.8777,Mumbai,IN
196.128.0.0,196.255.255.255,48.8566,2.3522,Paris,FR
197.0.0.0,197.127.255.255,52.3676,4.9041,Amsterdam,NL
197.128.0.0,197.234.239.255,-23.5505,-46.6333,Sao Paulo,BR
197.234.240.0,197.234.243.255,37.7749,-122.4194,San Francisco,US
197.234.244.0,197.250.243.255,55.7558,37.6173,Moscow,RU
197.250.244.0,198.10.243.255,41.8781,-87.6298,Chicago,US
198.10.244.0,198.26.243.255,32.7767,-96.797,Dallas,US
198.26.244.0,198.41.127.255,33.749,-84.388,Atlanta,US
198.41.128.0,198.41.255.255,37.7749,-122.4194,San Francisco,US
198.42.0.0,198.169.255.255,43.6532,-79.3832,Toronto,CA
198.170.0.0,199.27.71.255,22.3193,114.1694,Hong Kong,HK
199.27.72.0,199.27.79.255,37.7749,-122.4194,San Francisco,US
199.27.80.0,199.155.79.255,25.033,121.5654,Taipei,TW
199.155.80.0,199.231.255.255,40.4168,-3.7038,Madrid,ES
199.232.0.0,199.232.255.255,37.7749,-122.4194,San Francisco,US
199.233.0.0,200.104.255.255,45.4642,9.19,Milan,IT
200.105.0.0,200.232.255.255,59.3293,18.0686,Stockholm,SE
200.233.0.0,201.104.255.255,34.6937,135.5022,Osaka,JP
201.105.0.0,201.232.255.255,23.1291,113.2644,Guangzhou,CN
201.233.0.0,201.255.255.255,22.5431,114.0579,Shenzhen,CN
202.0.0.0,202.255.255.255,39.9042,116.4074,Beijing,CN
203.0.0.0,203.255.255.255,39.9042,116.4074,Beijing,CN
204.0.0.0,204.127.255.255,52.52,13.405,Berlin,DE
204.128.0.0,204.255.255.255,25.2048,55.2708,Dubai,AE
205.0.0.0,205.127.255.255,13.7563,100.5018,Bangkok,TH
205.128.0.0,205.255.255.255,31.2304,121.4737,Shanghai,CN
206.0.0.0,206.127.255.255,39.9042,116.4074,Beijing,CN
206.128.0.0,206.255.255.255,35.6762,139.6503,Tokyo,JP
207.0.0.0,207.127.255.255,37.5665,126.978,Seoul,KR
207.128.0.0,207.255.255.255,1.3521,103.8198,Singapore,SG
208.0.0.0,208.127.255.255,51.5074,-0.1278,London,GB
208.128.0.0,208.255.255.255,50.1109,8.6821,Frankfurt,DE
209.0.0.0,209.127.255.255,40.7128,-74.006,New York,US
209.128.0.0,209.255.255.255,37.7749,-122.4194,San Francisco,US
210.0.0.0,210.255.255.255,31.2304,121.4737,Shanghai,CN
211.0.0.0,211.255.255.255,39.9042,116.4074,Beijing,CN
212.0.0.0,212.255.255.255,51.5074,-0.1278,London,GB
213.0.0.0,213.255.255.255,52.3676,4.9041,Amsterdam,NL
214.0.0.0,214.127.255.255,-33.8688,151.2093,Sydney,AU
214.128.0.0,214.255.255.255,19.076,72.8777,Mumbai,IN
215.0.0.0,215.127.255.255,48.8566,2.3522,Paris,FR
215.128.0.0,215.255.255.255,52.3676,4.9041,Amsterdam,NL
216.0.0.0,216.127.255.255,-23.5505,-46.6333,Sao Paulo,BR
216.128.0.0,216.255.255.255,55.7558,37.6173,Moscow,RU
217.0.0.0,217.79.255.255,52.52,13.405,Berlin,DE
217.80.0.0,217.95.255.255,48.1351,11.582,Munich,DE
217.96.0.0,217.223.255.255,52.52,13.405,Berlin,DE
217.224.0.0,217.255.255.255,50.1109,8.6821,Frankfurt,DE
218.0.0.0,218.255.255.255,31.2304,121.4737,Shanghai,CN
219.0.0.0,219.255.255.255,39.9042,116.4074,Beijing,CN
220.0.0.0,220.255.255.255,23.1291,113.2644,Guangzhou,CN
221.0.0.0,221.255.255.255,31.2304,121.4737,Shanghai,CN
222.0.0.0,222.255.255.255,39.9042,116.4074,Beijing,CN
223.0.0.0,223.255.255.255,39.9042,116.4074,Beijing,CN
BIN
View File
Binary file not shown.
+27 -4
View File
@@ -1,7 +1,30 @@
@echo off
set PYTHONUTF8=1
cd /d "%~dp0"
echo Starting TianXuan...
start /B runtime\python\python.exe scripts/start_server.py
timeout /t 3 >nul
timeout /t 5 >nul
:: Start server in a separate window (detached, survives parent exit)
:: /MIN runs minimized so it doesn't pop up a console window
start "" /MIN runtime\python\python.exe scripts\start_server.py
:: Wait for PID file (up to 10 seconds)
set WAIT=0
:waitloop
if exist .server_pid goto gotpid
ping -n 2 127.0.0.1 >nul
set /a WAIT+=1
if %WAIT% lss 5 goto waitloop
:gotpid
if exist .server_pid (
set /p PID=<.server_pid
)
if defined PID (
echo TianXuan started. PID: %PID%
echo Visit: http://127.0.0.1:8000/
echo.
echo To stop: taskkill /F /PID %PID%
) else (
echo TianXuan started.
echo Visit: http://127.0.0.1:8000/
echo To stop: taskkill /F /IM python.exe
)
+38
View File
@@ -0,0 +1,38 @@
"""Download latest GeoLite2-City.mmdb for offline GeoIP resolution.
Usage: runtime\python\python.exe scripts\download_geoip.py
Downloads the latest GeoLite2-City.mmdb from MaxMind's mirror (via
python-geoip-geolite2) and copies it to a non-Chinese path so the
maxminddb C extension can read it on Windows.
Requirements: python-geoip-geolite2, maxminddb
"""
from pathlib import Path
import os
import shutil
import subprocess
import sys
def main():
print('Installing/updating python-geoip-geolite2...')
subprocess.check_call([
sys.executable, '-m', 'pip', 'install', '--upgrade',
'python-geoip-geolite2', 'maxminddb',
])
import _geoip_geolite2
src = Path(_geoip_geolite2.__file__).parent / 'GeoLite2-City.mmdb'
dst = Path(os.environ.get('TEMP', 'C:/temp')) / 'opencode' / 'GeoLite2-City.mmdb'
dst.parent.mkdir(parents=True, exist_ok=True)
shutil.copy2(str(src), str(dst))
print(f'Copied {src.name} ({src.stat().st_size / 1e6:.1f} MB)')
print(f' to {dst}')
print('GeoIP database ready.')
if __name__ == '__main__':
main()
+30 -16
View File
@@ -1,13 +1,10 @@
"""Start the development server with host and port from config.yaml.
Reads server.host and server.port from config/config.yaml via the project's
config loader, runs ``manage.py runserver <host>:<port>``, and opens a browser
to http://127.0.0.1:<port> .
Usage (by run.bat):
start /B runtime\\python\\python.exe scripts\\start_server.py
All output (stdout + stderr) is redirected to log files so the process
can run in the background without blocking any terminal.
"""
import os
import subprocess
import sys
import webbrowser
@@ -22,21 +19,38 @@ from config import get_config
def main() -> None:
cfg = get_config()
host = cfg.server.host or '0.0.0.0'
port = cfg.server.port or 80
port = cfg.server.port or 8000
addr = f'{host}:{port}'
manage_py = Path(__file__).resolve().parent.parent / 'manage.py'
manage_py = _project_root / 'manage.py'
# Open browser — use 127.0.0.1 regardless of bind address so the user's
# browser always reaches the local machine.
# Log files — capture ALL output so nothing goes to console
stdout_log = _project_root / 'server_stdout.txt'
stderr_log = _project_root / 'server_stderr.txt'
pid_file = _project_root / '.server_pid'
# Write PID so run.bat can print it
pid_file.write_text(str(os.getpid()), encoding='utf-8')
# Open browser
webbrowser.open(f'http://127.0.0.1:{port}/')
# Run the server. subprocess.call is blocking, so this script stays alive
# as long as the dev server does — the same effect as calling manage.py
# directly under start /B.
sys.exit(subprocess.call([sys.executable, str(manage_py), 'runserver', addr, '--noreload']))
# Run server with ALL output redirected to files
with open(stdout_log, 'a', encoding='utf-8') as out_fh, \
open(stderr_log, 'a', encoding='utf-8') as err_fh:
rc = subprocess.call(
[sys.executable, str(manage_py), 'runserver', addr, '--noreload'],
stdout=out_fh,
stderr=err_fh,
)
# Clean up PID file on exit
try:
pid_file.unlink(missing_ok=True)
except Exception:
pass
sys.exit(rc)
if __name__ == '__main__':
+178
View File
@@ -0,0 +1,178 @@
Restored 2 datasets from disk
[2026-07-23 13:06:51,899] INFO django: Restored 2 datasets from disk
[2026-07-23 13:06:52,038] INFO analysis.data_loader: [LOAD_FROM_DB_LAZY] table=_data_64 rows=9990 cols=57
[2026-07-23 13:06:52,043] INFO analysis.data_loader: [LOAD_FROM_DB_LAZY] table=_data_66 rows=2000 cols=9
[23/Jul/2026 13:21:24] "GET / HTTP/1.1" 200 10790
[23/Jul/2026 13:21:24,471] - Broken pipe from ('127.0.0.1', 64227)
[2026-07-23 13:27:21,369] INFO analysis.views: [UPLOAD] Received 1998 files
[23/Jul/2026 13:27:22] "POST /upload/csv/ HTTP/1.1" 200 178755
[23/Jul/2026 13:27:22] "POST /runs/5/finalize/ HTTP/1.1" 200 34
[23/Jul/2026 13:27:22] "GET /runs/5/status/ HTTP/1.1" 200 188
[2026-07-23 13:27:22,672] INFO analysis.views: [BACKGROUND] Forcing lat/lon column ':ips.latd' to Utf8 for safe parsing
[2026-07-23 13:27:22,672] INFO analysis.views: [BACKGROUND] Forcing lat/lon column ':ips.lond' to Utf8 for safe parsing
[2026-07-23 13:27:22,672] INFO analysis.views: [BACKGROUND] Forcing lat/lon column ':ipd.latd' to Utf8 for safe parsing
[2026-07-23 13:27:22,672] INFO analysis.views: [BACKGROUND] Forcing lat/lon column ':ipd.lond' to Utf8 for safe parsing
[2026-07-23 13:27:22,672] INFO analysis.views: [BACKGROUND] Found 1998 CSV files in C:\Users\25044\AppData\Roaming\TianXuan\data\uploads\20260723_052721
[2026-07-23 13:27:23,325] INFO analysis.data_loader: [SAVE_TO_DB] table=_data_68 rows=495 mode=append
[2026-07-23 13:27:23,609] INFO analysis.data_loader: [SAVE_TO_DB] table=_data_68 rows=495 mode=append
[2026-07-23 13:27:23,895] INFO analysis.data_loader: [SAVE_TO_DB] table=_data_68 rows=495 mode=append
[2026-07-23 13:27:24,170] INFO analysis.data_loader: [SAVE_TO_DB] table=_data_68 rows=495 mode=append
[2026-07-23 13:27:24,447] INFO analysis.data_loader: [SAVE_TO_DB] table=_data_68 rows=495 mode=append
[2026-07-23 13:27:24,733] INFO analysis.data_loader: [SAVE_TO_DB] table=_data_68 rows=495 mode=append
[2026-07-23 13:27:25,000] INFO analysis.data_loader: [SAVE_TO_DB] table=_data_68 rows=495 mode=append
[2026-07-23 13:27:25,268] INFO analysis.data_loader: [SAVE_TO_DB] table=_data_68 rows=495 mode=append
[2026-07-23 13:27:25,553] INFO analysis.data_loader: [SAVE_TO_DB] table=_data_68 rows=495 mode=append
[23/Jul/2026 13:27:25] "GET /runs/5/status/ HTTP/1.1" 200 242
[2026-07-23 13:27:25,838] INFO analysis.data_loader: [SAVE_TO_DB] table=_data_68 rows=495 mode=append
[2026-07-23 13:27:26,116] INFO analysis.data_loader: [SAVE_TO_DB] table=_data_68 rows=495 mode=append
[2026-07-23 13:27:26,392] INFO analysis.data_loader: [SAVE_TO_DB] table=_data_68 rows=495 mode=append
[2026-07-23 13:27:26,668] INFO analysis.data_loader: [SAVE_TO_DB] table=_data_68 rows=495 mode=append
[2026-07-23 13:27:26,946] INFO analysis.data_loader: [SAVE_TO_DB] table=_data_68 rows=495 mode=append
[2026-07-23 13:27:27,231] INFO analysis.data_loader: [SAVE_TO_DB] table=_data_68 rows=495 mode=append
[2026-07-23 13:27:27,497] INFO analysis.data_loader: [SAVE_TO_DB] table=_data_68 rows=495 mode=append
[2026-07-23 13:27:27,782] INFO analysis.data_loader: [SAVE_TO_DB] table=_data_68 rows=495 mode=append
[2026-07-23 13:27:28,071] INFO analysis.data_loader: [SAVE_TO_DB] table=_data_68 rows=495 mode=append
[2026-07-23 13:27:28,349] INFO analysis.data_loader: [SAVE_TO_DB] table=_data_68 rows=495 mode=append
[2026-07-23 13:27:28,621] INFO analysis.data_loader: [SAVE_TO_DB] table=_data_68 rows=495 mode=append
[23/Jul/2026 13:27:28] "GET /runs/5/status/ HTTP/1.1" 200 243
[2026-07-23 13:27:28,713] INFO analysis.data_loader: [SAVE_TO_DB] table=_data_68 rows=90 mode=append
E:\hjq\澶╃拠\analysis\views.py:584: PerformanceWarning: Determining the column names of a LazyFrame requires resolving its schema, which is a potentially expensive operation. Use `LazyFrame.collect_schema().names()` to get the column names without this warning.
if lat_lon_col in lf.columns:
[2026-07-23 13:27:29,071] INFO analysis.views: [BACKGROUND] Restored numeric types for 16 columns: ['1ipp', '2tmo', '4dbn', '4dur', '4srs', '8ack', '8byt', '8dbd', '8did', '8pak']
[2026-07-23 13:27:29,150] INFO analysis.views: [BACKGROUND] SVD: 15 components from 16 numeric columns (explained variance ratio sum=1.0000)
[23/Jul/2026 13:27:31] "GET /runs/5/status/ HTTP/1.1" 200 223
[23/Jul/2026 13:27:56] "POST /analyze/run/ HTTP/1.1" 200 45
[23/Jul/2026 13:27:56] "GET /runs/5/status/ HTTP/1.1" 200 223
[2026-07-23 13:27:57,344] INFO analysis.tool_registry: [CLUSTER_INPUT] rows=9990 cols=10 feature_cols=['1ipp', '2tmo', '4dbn', '4dur', '4srs', '8ack', '8byt', '8dbd', '8did', '8pak']
[2026-07-23 13:27:57,344] INFO analysis.tool_registry: [CLUSTER_SCALE] Starting StandardScaler...
[2026-07-23 13:27:57,344] INFO analysis.tool_registry: [CLUSTER_SCALE] Done. shape=(9990, 10)
E:\hjq\澶╃拠\runtime\python\Lib\site-packages\sklearn\cluster\_hdbscan\hdbscan.py:722: FutureWarning: The default value of `copy` will change from False to True in 1.10. Explicitly set a value for `copy` to silence this warning.
warn(
[23/Jul/2026 13:27:59] "GET /runs/5/status/ HTTP/1.1" 200 240
[23/Jul/2026 13:28:02] "GET /runs/5/status/ HTTP/1.1" 200 240
[23/Jul/2026 13:28:06] "GET /runs/5/status/ HTTP/1.1" 200 240
E:\hjq\澶╃拠\runtime\python\Lib\site-packages\umap\umap_.py:1952: UserWarning: n_jobs value 1 overridden to 1 by setting random_state. Use no seed for parallelism.
warn(
[23/Jul/2026 13:28:09] "GET /runs/5/status/ HTTP/1.1" 200 240
[23/Jul/2026 13:28:12] "GET /runs/5/status/ HTTP/1.1" 200 240
[23/Jul/2026 13:28:15] "GET /runs/5/status/ HTTP/1.1" 200 240
[23/Jul/2026 13:28:18] "GET /runs/5/status/ HTTP/1.1" 200 240
[23/Jul/2026 13:28:21] "GET /runs/5/status/ HTTP/1.1" 200 240
[23/Jul/2026 13:28:24] "GET /runs/5/status/ HTTP/1.1" 200 240
[23/Jul/2026 13:28:27] "GET /runs/5/status/ HTTP/1.1" 200 240
[23/Jul/2026 13:28:30] "GET /runs/5/status/ HTTP/1.1" 200 240
[23/Jul/2026 13:28:33] "GET /runs/5/status/ HTTP/1.1" 200 240
[23/Jul/2026 13:28:37] "GET /runs/5/status/ HTTP/1.1" 200 240
[2026-07-23 13:28:38,183] INFO analysis.geoip: [GEOIP] loaded 803 ranges from E:\hjq\澶╃拠\data\geoip_data.txt (0 skipped)
E:\hjq\澶╃拠\runtime\python\Lib\site-packages\umap\umap_.py:1952: UserWarning: n_jobs value 1 overridden to 1 by setting random_state. Use no seed for parallelism.
warn(
[23/Jul/2026 13:28:40] "GET /runs/5/status/ HTTP/1.1" 200 254
Restored 3 datasets from disk
[2026-07-23 21:16:22,571] INFO django: Restored 3 datasets from disk
[2026-07-23 21:16:22,802] INFO analysis.data_loader: [LOAD_FROM_DB_LAZY] table=_data_64 rows=9990 cols=57
[2026-07-23 21:16:22,804] INFO analysis.data_loader: [LOAD_FROM_DB_LAZY] table=_data_66 rows=2000 cols=9
[2026-07-23 21:16:22,992] INFO analysis.data_loader: [LOAD_FROM_DB_LAZY] table=_data_68 rows=9990 cols=57
[23/Jul/2026 21:16:44] "GET / HTTP/1.1" 200 12728
[23/Jul/2026 21:16:44] "GET /static/tianxuan/favicon.svg HTTP/1.1" 304 0
[23/Jul/2026 21:17:14] "HEAD / HTTP/1.1" 200 0
[23/Jul/2026 21:17:20] "HEAD / HTTP/1.1" 200 0
Restored 3 datasets from disk
[2026-07-23 21:22:58,596] INFO django: Restored 3 datasets from disk
[2026-07-23 21:22:58,783] INFO analysis.data_loader: [LOAD_FROM_DB_LAZY] table=_data_64 rows=9990 cols=57
[2026-07-23 21:22:58,801] INFO analysis.data_loader: [LOAD_FROM_DB_LAZY] table=_data_66 rows=2000 cols=9
[2026-07-23 21:22:59,009] INFO analysis.data_loader: [LOAD_FROM_DB_LAZY] table=_data_68 rows=9990 cols=57
[23/Jul/2026 21:22:59] "GET / HTTP/1.1" 200 12728
Restored 3 datasets from disk
[2026-07-23 21:37:07[23/Jul/2026 21:37:07] "GET / HTTP/1.1" 200 12728 [[23/Jul/2026 21:37:38] "HEAD / HTTP/1.1" 200 0
[23/Jul/2026 21:37:49] "HEAD / HTTP/1.1" 200 0
[23/Jul/2026 21:38:08] "HEAD / HTTP/1.1" 200 0
[23/Jul/2026 21:38:38] "HEAD / HTTP/1.1" 200 0
[23/Jul/2026 21:39:08] "HEAD / HTTP/1.1" 200 0
[23/Jul/2026 21:39:38] "HEAD / HTTP/1.1" 200 0
[23/Jul/2026 21:39:43] "HEAD / HTTP/1.1" 200 0
[23/Jul/2026 21:39:45] "HEAD / HTTP/1.1" 200 0
[23/Jul/2026 21:39:49] "HEAD / HTTP/1.1" 200 0
[23/Jul/2026 21:40:08] "HEAD / HTTP/1.1" 200 0
[23/Jul/2026 21:40:38] "HEAD / HTTP/1.1" 200 0
[23/Jul/2026 21:41:35] "HEAD / HTTP/1.1" 200 0
Restored 3 datasets from disk
[2026-07-23 21:42:01,918] INFO django: Restored 3 datasets from disk
[2026-07-23 21:42:02,145] INFO analysis.data_loader: [LOAD_FROM_DB_LAZY] table=_data_64 rows=9990 cols=57
[2026-07-23 21:42:02,153] INFO analysis.data_loader: [LOAD_FROM_DB_LAZY] table=_data_66 rows=2000 cols=9
[2026-07-23 21:42:02,295] INFO analysis.data_loader: [LOAD_FROM_DB_LAZY] table=_data_68 rows=9990 cols=57
esponse = wrapped_callback(request, *callback_args, **callback_kwargs)
^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
File "E:\hjq\天璇\simple_analysis\views.py", line 796, in index
File "E:\hjq\天璇\runtime\python\Lib\site-packages\django\shortcuts.py", line 24, in render
content = loader.render_to_string(template_name, context, request, using=using)
^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
File "E:\hjq\天璇\runtime\python\Lib\site-packages\django\template\loader.py", line 61, in render_to_string
template = get_template(template_name, using=using)
^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
File "E:\hjq\天璇\runtime\python\Lib\site-packages\django\template\loader.py", line 19, in get_template
raise TemplateDoesNotExist(template_name, chain=chain)
django.template.exceptions.TemplateDoesNotExist: simple_analysis/simple_analysis.html
[2026-07-23 21:41:56,536] ERROR django.request: Internal Server Error: /simple/
Traceback (most recent call last):
File "E:\hjq\天璇\runtime\python\Lib\site-packages\django\core\handlers\exception.py", line 55, in inner
response = get_response(request)
^^^^^^^^^^^^^^^^^^^^^
File "E:\hjq\天璇\runtime\python\Lib\site-packages\django\core\handlers\base.py", line 197, in _get_response
response = wrapped_callback(request, *callback_args, **callback_kwargs)
^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
File "E:\hjq\天璇\simple_analysis\views.py", line 796, in index
File "E:\hjq\天璇\runtime\python\Lib\site-packages\django\shortcuts.py", line 24, in render
content = loader.render_to_string(template_name, context, request, using=using)
^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
File "E:\hjq\天璇\runtime\python\Lib\site-packages\django\template\loader.py", line 61, in render_to_string
template = get_template(template_name, using=using)
^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
File "E:\hjq\天璇\runtime\python\Lib\site-packages\django\template\loader.py", line 19, in get_template
raise TemplateDoesNotExist(template_name, chain=chain)
django.template.exceptions.TemplateDoesNotExist: simple_analysis/simple_analysis.html
[23/Jul/2026 21:41:56] "GET /simple/ HTTP/1.1" 500 80559
Not Found: /favicon.ico
[2026-07-23 21:41:56,814] WARNING django.request: Not Found: /favicon.ico
[23/Jul/2026 21:41:56] "GET /favicon.ico HTTP/1.1" 404 6872
Restored 3 datasets from disk
[2026-07-23 21:45:23,039] INFO django: Restored 3 datasets from disk
[2026-07-23 21:45:23,182] INFO analysis.data_loader: [LOAD_FROM_DB_LAZY] table=_data_64 rows=9990 cols=57
[2026-07-23 21:45:23,190] INFO analysis.data_loader: [LOAD_FROM_DB_LAZY] table=_data_66 rows=2000 cols=9
[2026-07-23 21:45:23,314] INFO analysis.data_loader: [LOAD_FROM_DB_LAZY] table=_data_68 rows=9990 cols=57
[23/Jul/2026 21:45:28] "GET / HTTP/1.1" 200 12645
[23/Jul/2026 21:45:31] "GET /upload/ HTTP/1.1" 200 19078
[23/Jul/2026 21:45:44] "POST /runs/5/delete/ HTTP/1.1" 200 17
[23/Jul/2026 21:45:44] "GET /upload/ HTTP/1.1" 200 18534
[23/Jul/2026 21:45:46] "POST /runs/4/delete/ HTTP/1.1" 200 17
[23/Jul/2026 21:45:46] "GET /upload/ HTTP/1.1" 200 17939
[23/Jul/2026 21:45:48] "POST /runs/3/delete/ HTTP/1.1" 200 17
[23/Jul/2026 21:45:48] "GET /upload/ HTTP/1.1" 200 17395
[23/Jul/2026 21:45:51] "POST /runs/2/delete/ HTTP/1.1" 200 17
[23/Jul/2026 21:45:51] "GET /upload/ HTTP/1.1" 200 16853
[23/Jul/2026 21:45:52] "POST /runs/1/delete/ HTTP/1.1" 200 17
[23/Jul/2026 21:45:52] "GET /upload/ HTTP/1.1" 200 16136
[23/Jul/2026 21:46:23] "HEAD / HTTP/1.1" 200 0
[23/Jul/2026 21:46:53] "HEAD / HTTP/1.1" 200 0
[23/Jul/2026 21:47:23] "HEAD / HTTP/1.1" 200 0
[23/Jul/2026 21:47:53] "HEAD / HTTP/1.1" 200 0
[23/Jul/2026 21:48:23] "HEAD / HTTP/1.1" 200 0
[23/Jul/2026 21:48:53] "HEAD / HTTP/1.1" 200 0
[23/Jul/2026 21:49:23] "HEAD / HTTP/1.1" 200 0
[23/Jul/2026 21:49:53] "HEAD / HTTP/1.1" 200 0
[23/Jul/2026 21:50:23] "HEAD / HTTP/1.1" 200 0
Restored 3 datasets from disk
[2026-07-23 21:50:50,464] INFO django: Restored 3 datasets from disk
[23/Jul/2026 21:50:54] "GET /analyze/manual/ HTTP/1.1" 200 65847
[23/Jul/2026 21:50:54] "GET /tools/plan/ HTTP/1.1" 200 13
[23/Jul/2026 21:50:57] "GET /analyze/auto/ HTTP/1.1" 200 39486
[23/Jul/2026 21:51:09] "GET /runs/ HTTP/1.1" 200 9674
[2026-07-23 21:51:10,324] INFO analysis.geoip: [GEOIP] loaded 0 ranges (0 skipped)
[2026-07-23 21:51:10,325] INFO analysis.geoip: [GEOIP] loaded 12 cached entries
[23/Jul/2026 21:51:10] "GET /globe/ HTTP/1.1" 200 18520
[23/Jul/2026 21:51:10] "GET /static/tianxuan/three.min.js HTTP/1.1" 304 0
[23/Jul/2026 21:51:10] "GET /static/tianxuan/world_borders.js HTTP/1.1" 304 0
[23/Jul/2026 21:51:10] "GET /static/tianxuan/three.min.js HTTP/1.1" 304 0
[23/Jul/2026 21:51:10] "GET /static/tianxuan/world_borders.js HTTP/1.1" 304 0
[23/Jul/2026 21:51:10] "GET /static/tianxuan/earth_atmos_2048.jpg HTTP/1.1" 304 0
[23/Jul/2026 21:51:27] "GET /config/ HTTP/1.1" 200 14372
[23/Jul/2026 21:51:31] "GET /analyze/auto/ HTTP/1.1" 200 39486
+65
View File
@@ -0,0 +1,65 @@
Performing system checks...
System check identified no issues (0 silenced).
July 23, 2026 - 13:06:51
Django version 4.2.30, using settings 'tianxuan.settings'
Starting development server at http://127.0.0.1:18766/
Quit the server with CTRL-BREAK.
Performing system checks...
System check identified no issues (0 silenced).
Performing system checks...
System check identified no issues (0 silenced).
Performing system checks...
System check identified no issues (0 silenced).
July 23, 2026 - 21:16:22
Django version 4.2.30, using settings 'tianxuan.settings'
Starting development server at http://0.0.0.0:8000/
Quit the server with CTRL-BREAK.
Performing system checks...
System check identified no issues (0 silenced).
Performing system checks...
System check identified no issues (0 silenced).
July 23, 2026 - 21:22:58
Django version 4.2.30, using settings 'tianxuan.settings'
Starting development server at http://0.0.0.0:8000/
Quit the server with CTRL-BREAK.
Performing system checks...
System check identified no issues (0 silenced).
July 23, 2026 - 21:37:07
Django version 4.2.30, using settings 'tianxuan.settings'
Starting development server at http://0.0.0.0:8000/
Quit the server with CTRL-BREAK.
Performing system checks...
System check identified no issues (0 silenced).
July 23, 2026 - 21:42:01
Django version 4.2.30, using settings 'tianxuan.settings'
Starting development server at http://0.0.0.0:8000/
Quit the server with CTRL-BREAK.
Performing system checks...
System check identified no issues (0 silenced).
July 23, 2026 - 21:45:23
Django version 4.2.30, using settings 'tianxuan.settings'
Starting development server at http://0.0.0.0:8000/
Quit the server with CTRL-BREAK.
Performing system checks...
System check identified no issues (0 silenced).
July 23, 2026 - 21:50:50
Django version 4.2.30, using settings 'tianxuan.settings'
Starting development server at http://0.0.0.0:8000/
Quit the server with CTRL-BREAK.
View File
-7
View File
@@ -1,7 +0,0 @@
from django.apps import AppConfig
class SimpleAnalysisConfig(AppConfig):
default_auto_field = 'django.db.models.BigAutoField'
name = 'simple_analysis'
label = 'simple_analysis'
@@ -1,8 +0,0 @@
/*
* 主要逻辑内嵌在 simple_analysis.html
* 此文件供未来扩展或拆分使用
*/
(function() {
'use strict';
console.log('简单分析模块已加载');
})();
File diff suppressed because one or more lines are too long
Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.2 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 696 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.4 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.4 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 618 B

@@ -1,661 +0,0 @@
/* required styles */
.leaflet-pane,
.leaflet-tile,
.leaflet-marker-icon,
.leaflet-marker-shadow,
.leaflet-tile-container,
.leaflet-pane > svg,
.leaflet-pane > canvas,
.leaflet-zoom-box,
.leaflet-image-layer,
.leaflet-layer {
position: absolute;
left: 0;
top: 0;
}
.leaflet-container {
overflow: hidden;
}
.leaflet-tile,
.leaflet-marker-icon,
.leaflet-marker-shadow {
-webkit-user-select: none;
-moz-user-select: none;
user-select: none;
-webkit-user-drag: none;
}
/* Prevents IE11 from highlighting tiles in blue */
.leaflet-tile::selection {
background: transparent;
}
/* Safari renders non-retina tile on retina better with this, but Chrome is worse */
.leaflet-safari .leaflet-tile {
image-rendering: -webkit-optimize-contrast;
}
/* hack that prevents hw layers "stretching" when loading new tiles */
.leaflet-safari .leaflet-tile-container {
width: 1600px;
height: 1600px;
-webkit-transform-origin: 0 0;
}
.leaflet-marker-icon,
.leaflet-marker-shadow {
display: block;
}
/* .leaflet-container svg: reset svg max-width decleration shipped in Joomla! (joomla.org) 3.x */
/* .leaflet-container img: map is broken in FF if you have max-width: 100% on tiles */
.leaflet-container .leaflet-overlay-pane svg {
max-width: none !important;
max-height: none !important;
}
.leaflet-container .leaflet-marker-pane img,
.leaflet-container .leaflet-shadow-pane img,
.leaflet-container .leaflet-tile-pane img,
.leaflet-container img.leaflet-image-layer,
.leaflet-container .leaflet-tile {
max-width: none !important;
max-height: none !important;
width: auto;
padding: 0;
}
.leaflet-container img.leaflet-tile {
/* See: https://bugs.chromium.org/p/chromium/issues/detail?id=600120 */
mix-blend-mode: plus-lighter;
}
.leaflet-container.leaflet-touch-zoom {
-ms-touch-action: pan-x pan-y;
touch-action: pan-x pan-y;
}
.leaflet-container.leaflet-touch-drag {
-ms-touch-action: pinch-zoom;
/* Fallback for FF which doesn't support pinch-zoom */
touch-action: none;
touch-action: pinch-zoom;
}
.leaflet-container.leaflet-touch-drag.leaflet-touch-zoom {
-ms-touch-action: none;
touch-action: none;
}
.leaflet-container {
-webkit-tap-highlight-color: transparent;
}
.leaflet-container a {
-webkit-tap-highlight-color: rgba(51, 181, 229, 0.4);
}
.leaflet-tile {
filter: inherit;
visibility: hidden;
}
.leaflet-tile-loaded {
visibility: inherit;
}
.leaflet-zoom-box {
width: 0;
height: 0;
-moz-box-sizing: border-box;
box-sizing: border-box;
z-index: 800;
}
/* workaround for https://bugzilla.mozilla.org/show_bug.cgi?id=888319 */
.leaflet-overlay-pane svg {
-moz-user-select: none;
}
.leaflet-pane { z-index: 400; }
.leaflet-tile-pane { z-index: 200; }
.leaflet-overlay-pane { z-index: 400; }
.leaflet-shadow-pane { z-index: 500; }
.leaflet-marker-pane { z-index: 600; }
.leaflet-tooltip-pane { z-index: 650; }
.leaflet-popup-pane { z-index: 700; }
.leaflet-map-pane canvas { z-index: 100; }
.leaflet-map-pane svg { z-index: 200; }
.leaflet-vml-shape {
width: 1px;
height: 1px;
}
.lvml {
behavior: url(#default#VML);
display: inline-block;
position: absolute;
}
/* control positioning */
.leaflet-control {
position: relative;
z-index: 800;
pointer-events: visiblePainted; /* IE 9-10 doesn't have auto */
pointer-events: auto;
}
.leaflet-top,
.leaflet-bottom {
position: absolute;
z-index: 1000;
pointer-events: none;
}
.leaflet-top {
top: 0;
}
.leaflet-right {
right: 0;
}
.leaflet-bottom {
bottom: 0;
}
.leaflet-left {
left: 0;
}
.leaflet-control {
float: left;
clear: both;
}
.leaflet-right .leaflet-control {
float: right;
}
.leaflet-top .leaflet-control {
margin-top: 10px;
}
.leaflet-bottom .leaflet-control {
margin-bottom: 10px;
}
.leaflet-left .leaflet-control {
margin-left: 10px;
}
.leaflet-right .leaflet-control {
margin-right: 10px;
}
/* zoom and fade animations */
.leaflet-fade-anim .leaflet-popup {
opacity: 0;
-webkit-transition: opacity 0.2s linear;
-moz-transition: opacity 0.2s linear;
transition: opacity 0.2s linear;
}
.leaflet-fade-anim .leaflet-map-pane .leaflet-popup {
opacity: 1;
}
.leaflet-zoom-animated {
-webkit-transform-origin: 0 0;
-ms-transform-origin: 0 0;
transform-origin: 0 0;
}
svg.leaflet-zoom-animated {
will-change: transform;
}
.leaflet-zoom-anim .leaflet-zoom-animated {
-webkit-transition: -webkit-transform 0.25s cubic-bezier(0,0,0.25,1);
-moz-transition: -moz-transform 0.25s cubic-bezier(0,0,0.25,1);
transition: transform 0.25s cubic-bezier(0,0,0.25,1);
}
.leaflet-zoom-anim .leaflet-tile,
.leaflet-pan-anim .leaflet-tile {
-webkit-transition: none;
-moz-transition: none;
transition: none;
}
.leaflet-zoom-anim .leaflet-zoom-hide {
visibility: hidden;
}
/* cursors */
.leaflet-interactive {
cursor: pointer;
}
.leaflet-grab {
cursor: -webkit-grab;
cursor: -moz-grab;
cursor: grab;
}
.leaflet-crosshair,
.leaflet-crosshair .leaflet-interactive {
cursor: crosshair;
}
.leaflet-popup-pane,
.leaflet-control {
cursor: auto;
}
.leaflet-dragging .leaflet-grab,
.leaflet-dragging .leaflet-grab .leaflet-interactive,
.leaflet-dragging .leaflet-marker-draggable {
cursor: move;
cursor: -webkit-grabbing;
cursor: -moz-grabbing;
cursor: grabbing;
}
/* marker & overlays interactivity */
.leaflet-marker-icon,
.leaflet-marker-shadow,
.leaflet-image-layer,
.leaflet-pane > svg path,
.leaflet-tile-container {
pointer-events: none;
}
.leaflet-marker-icon.leaflet-interactive,
.leaflet-image-layer.leaflet-interactive,
.leaflet-pane > svg path.leaflet-interactive,
svg.leaflet-image-layer.leaflet-interactive path {
pointer-events: visiblePainted; /* IE 9-10 doesn't have auto */
pointer-events: auto;
}
/* visual tweaks */
.leaflet-container {
background: #ddd;
outline-offset: 1px;
}
.leaflet-container a {
color: #0078A8;
}
.leaflet-zoom-box {
border: 2px dotted #38f;
background: rgba(255,255,255,0.5);
}
/* general typography */
.leaflet-container {
font-family: "Helvetica Neue", Arial, Helvetica, sans-serif;
font-size: 12px;
font-size: 0.75rem;
line-height: 1.5;
}
/* general toolbar styles */
.leaflet-bar {
box-shadow: 0 1px 5px rgba(0,0,0,0.65);
border-radius: 4px;
}
.leaflet-bar a {
background-color: #fff;
border-bottom: 1px solid #ccc;
width: 26px;
height: 26px;
line-height: 26px;
display: block;
text-align: center;
text-decoration: none;
color: black;
}
.leaflet-bar a,
.leaflet-control-layers-toggle {
background-position: 50% 50%;
background-repeat: no-repeat;
display: block;
}
.leaflet-bar a:hover,
.leaflet-bar a:focus {
background-color: #f4f4f4;
}
.leaflet-bar a:first-child {
border-top-left-radius: 4px;
border-top-right-radius: 4px;
}
.leaflet-bar a:last-child {
border-bottom-left-radius: 4px;
border-bottom-right-radius: 4px;
border-bottom: none;
}
.leaflet-bar a.leaflet-disabled {
cursor: default;
background-color: #f4f4f4;
color: #bbb;
}
.leaflet-touch .leaflet-bar a {
width: 30px;
height: 30px;
line-height: 30px;
}
.leaflet-touch .leaflet-bar a:first-child {
border-top-left-radius: 2px;
border-top-right-radius: 2px;
}
.leaflet-touch .leaflet-bar a:last-child {
border-bottom-left-radius: 2px;
border-bottom-right-radius: 2px;
}
/* zoom control */
.leaflet-control-zoom-in,
.leaflet-control-zoom-out {
font: bold 18px 'Lucida Console', Monaco, monospace;
text-indent: 1px;
}
.leaflet-touch .leaflet-control-zoom-in, .leaflet-touch .leaflet-control-zoom-out {
font-size: 22px;
}
/* layers control */
.leaflet-control-layers {
box-shadow: 0 1px 5px rgba(0,0,0,0.4);
background: #fff;
border-radius: 5px;
}
.leaflet-control-layers-toggle {
background-image: url(images/layers.png);
width: 36px;
height: 36px;
}
.leaflet-retina .leaflet-control-layers-toggle {
background-image: url(images/layers-2x.png);
background-size: 26px 26px;
}
.leaflet-touch .leaflet-control-layers-toggle {
width: 44px;
height: 44px;
}
.leaflet-control-layers .leaflet-control-layers-list,
.leaflet-control-layers-expanded .leaflet-control-layers-toggle {
display: none;
}
.leaflet-control-layers-expanded .leaflet-control-layers-list {
display: block;
position: relative;
}
.leaflet-control-layers-expanded {
padding: 6px 10px 6px 6px;
color: #333;
background: #fff;
}
.leaflet-control-layers-scrollbar {
overflow-y: scroll;
overflow-x: hidden;
padding-right: 5px;
}
.leaflet-control-layers-selector {
margin-top: 2px;
position: relative;
top: 1px;
}
.leaflet-control-layers label {
display: block;
font-size: 13px;
font-size: 1.08333em;
}
.leaflet-control-layers-separator {
height: 0;
border-top: 1px solid #ddd;
margin: 5px -10px 5px -6px;
}
/* Default icon URLs */
.leaflet-default-icon-path { /* used only in path-guessing heuristic, see L.Icon.Default */
background-image: url(images/marker-icon.png);
}
/* attribution and scale controls */
.leaflet-container .leaflet-control-attribution {
background: #fff;
background: rgba(255, 255, 255, 0.8);
margin: 0;
}
.leaflet-control-attribution,
.leaflet-control-scale-line {
padding: 0 5px;
color: #333;
line-height: 1.4;
}
.leaflet-control-attribution a {
text-decoration: none;
}
.leaflet-control-attribution a:hover,
.leaflet-control-attribution a:focus {
text-decoration: underline;
}
.leaflet-attribution-flag {
display: inline !important;
vertical-align: baseline !important;
width: 1em;
height: 0.6669em;
}
.leaflet-left .leaflet-control-scale {
margin-left: 5px;
}
.leaflet-bottom .leaflet-control-scale {
margin-bottom: 5px;
}
.leaflet-control-scale-line {
border: 2px solid #777;
border-top: none;
line-height: 1.1;
padding: 2px 5px 1px;
white-space: nowrap;
-moz-box-sizing: border-box;
box-sizing: border-box;
background: rgba(255, 255, 255, 0.8);
text-shadow: 1px 1px #fff;
}
.leaflet-control-scale-line:not(:first-child) {
border-top: 2px solid #777;
border-bottom: none;
margin-top: -2px;
}
.leaflet-control-scale-line:not(:first-child):not(:last-child) {
border-bottom: 2px solid #777;
}
.leaflet-touch .leaflet-control-attribution,
.leaflet-touch .leaflet-control-layers,
.leaflet-touch .leaflet-bar {
box-shadow: none;
}
.leaflet-touch .leaflet-control-layers,
.leaflet-touch .leaflet-bar {
border: 2px solid rgba(0,0,0,0.2);
background-clip: padding-box;
}
/* popup */
.leaflet-popup {
position: absolute;
text-align: center;
margin-bottom: 20px;
}
.leaflet-popup-content-wrapper {
padding: 1px;
text-align: left;
border-radius: 12px;
}
.leaflet-popup-content {
margin: 13px 24px 13px 20px;
line-height: 1.3;
font-size: 13px;
font-size: 1.08333em;
min-height: 1px;
}
.leaflet-popup-content p {
margin: 17px 0;
margin: 1.3em 0;
}
.leaflet-popup-tip-container {
width: 40px;
height: 20px;
position: absolute;
left: 50%;
margin-top: -1px;
margin-left: -20px;
overflow: hidden;
pointer-events: none;
}
.leaflet-popup-tip {
width: 17px;
height: 17px;
padding: 1px;
margin: -10px auto 0;
pointer-events: auto;
-webkit-transform: rotate(45deg);
-moz-transform: rotate(45deg);
-ms-transform: rotate(45deg);
transform: rotate(45deg);
}
.leaflet-popup-content-wrapper,
.leaflet-popup-tip {
background: white;
color: #333;
box-shadow: 0 3px 14px rgba(0,0,0,0.4);
}
.leaflet-container a.leaflet-popup-close-button {
position: absolute;
top: 0;
right: 0;
border: none;
text-align: center;
width: 24px;
height: 24px;
font: 16px/24px Tahoma, Verdana, sans-serif;
color: #757575;
text-decoration: none;
background: transparent;
}
.leaflet-container a.leaflet-popup-close-button:hover,
.leaflet-container a.leaflet-popup-close-button:focus {
color: #585858;
}
.leaflet-popup-scrolled {
overflow: auto;
}
.leaflet-oldie .leaflet-popup-content-wrapper {
-ms-zoom: 1;
}
.leaflet-oldie .leaflet-popup-tip {
width: 24px;
margin: 0 auto;
-ms-filter: "progid:DXImageTransform.Microsoft.Matrix(M11=0.70710678, M12=0.70710678, M21=-0.70710678, M22=0.70710678)";
filter: progid:DXImageTransform.Microsoft.Matrix(M11=0.70710678, M12=0.70710678, M21=-0.70710678, M22=0.70710678);
}
.leaflet-oldie .leaflet-control-zoom,
.leaflet-oldie .leaflet-control-layers,
.leaflet-oldie .leaflet-popup-content-wrapper,
.leaflet-oldie .leaflet-popup-tip {
border: 1px solid #999;
}
/* div icon */
.leaflet-div-icon {
background: #fff;
border: 1px solid #666;
}
/* Tooltip */
/* Base styles for the element that has a tooltip */
.leaflet-tooltip {
position: absolute;
padding: 6px;
background-color: #fff;
border: 1px solid #fff;
border-radius: 3px;
color: #222;
white-space: nowrap;
-webkit-user-select: none;
-moz-user-select: none;
-ms-user-select: none;
user-select: none;
pointer-events: none;
box-shadow: 0 1px 3px rgba(0,0,0,0.4);
}
.leaflet-tooltip.leaflet-interactive {
cursor: pointer;
pointer-events: auto;
}
.leaflet-tooltip-top:before,
.leaflet-tooltip-bottom:before,
.leaflet-tooltip-left:before,
.leaflet-tooltip-right:before {
position: absolute;
pointer-events: none;
border: 6px solid transparent;
background: transparent;
content: "";
}
/* Directions */
.leaflet-tooltip-bottom {
margin-top: 6px;
}
.leaflet-tooltip-top {
margin-top: -6px;
}
.leaflet-tooltip-bottom:before,
.leaflet-tooltip-top:before {
left: 50%;
margin-left: -6px;
}
.leaflet-tooltip-top:before {
bottom: 0;
margin-bottom: -12px;
border-top-color: #fff;
}
.leaflet-tooltip-bottom:before {
top: 0;
margin-top: -12px;
margin-left: -6px;
border-bottom-color: #fff;
}
.leaflet-tooltip-left {
margin-left: -6px;
}
.leaflet-tooltip-right {
margin-left: 6px;
}
.leaflet-tooltip-left:before,
.leaflet-tooltip-right:before {
top: 50%;
margin-top: -6px;
}
.leaflet-tooltip-left:before {
right: 0;
margin-right: -12px;
border-left-color: #fff;
}
.leaflet-tooltip-right:before {
left: 0;
margin-left: -12px;
border-right-color: #fff;
}
/* Printing */
@media print {
/* Prevent printers from removing background-images of controls. */
.leaflet-control {
-webkit-print-color-adjust: exact;
print-color-adjust: exact;
}
}
File diff suppressed because one or more lines are too long
File diff suppressed because it is too large Load Diff
-11
View File
@@ -1,11 +0,0 @@
from django.urls import path
from . import views
app_name = 'simple_analysis'
urlpatterns = [
path('', views.index, name='index'),
path('upload/', views.upload_csv, name='upload'),
path('filter/', views.filter_data, name='filter'),
path('cluster/', views.run_clustering, name='cluster'),
path('cluster/<int:label>/', views.cluster_detail, name='cluster_detail'),
]
-796
View File
@@ -1,796 +0,0 @@
"""简单分析模块 — 独立的上传→筛选→聚类→地图可视化工作流。
不与天璇原有分析模块共享数据库或 session store所有中间数据
存储在模块级内存缓存SimpleAnalysisCache或临时目录中
"""
import json
import logging
import os
import re
import shutil
import tempfile
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
from sklearn.preprocessing import StandardScaler
from sklearn.cluster import DBSCAN
logger = logging.getLogger(__name__)
# ── In-memory cache ──────────────────────────────────────────────────────────
# Thread-safe for single-user desktop usage; each user session gets a unique key.
_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
# ── Column-name helpers ──────────────────────────────────────────────────────
def _norm(name: str) -> str:
"""Normalise column name: lower-case, strip whitespace, replace hyphens."""
return name.strip().lower().replace('-', '_').replace(' ', '_')
def _find_col(col_names: list[str], *patterns: str) -> str | None:
"""Find first column matching any pattern (exact or suffix after stripping)."""
normed = {c: _norm(c) for c in col_names}
for p in patterns:
np_norm = _norm(p)
for orig, n in normed.items():
if n == np_norm or n.endswith('_' + np_norm) or n.startswith(np_norm + '_'):
return orig
return None
# ── Step 1: Upload ───────────────────────────────────────────────────────────
@csrf_exempt
@require_POST
def upload_csv(request):
"""Upload one or more CSV files, merge, return column info + cnam frequency."""
files = request.FILES.getlist('files')
if not files:
return JsonResponse({'error': '未上传任何文件'}, status=400)
session_id = _new_id()
upload_dir = _TEMP_DIR / session_id
upload_dir.mkdir(parents=True, exist_ok=True)
saved_paths = []
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))
# Load & merge CSV files using Polars
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)
col_names = df.columns
total_rows = len(df)
# Detect key columns — expanded patterns to match actual CSV column names
cnam_col = _find_col(col_names, 'cnam', 'snam', 'class_name', 'category', 'target', 'name')
cnrs_col = _find_col(col_names, 'cnrs', 'conn_success', 'result', '4srs')
isrs_col = _find_col(col_names, 'isrs', 'is_resolved', 'resolved')
src_lat_col = _find_col(col_names, ':ips.latd', 'src_latitude', 'src_lat', 'source_lat')
src_lon_col = _find_col(col_names, ':ips.lond', 'src_longitude', 'src_lon', 'source_lon')
dst_lat_col = _find_col(col_names, ':ipd.latd', 'dst_latitude', 'dst_lat', 'dest_lat')
dst_lon_col = _find_col(col_names, ':ipd.lond', 'dst_longitude', 'dst_lon', 'dest_lon')
ips_col = _find_col(col_names, ':ips', 'src_ip', 'source_ip', 'ip_src')
ipd_col = _find_col(col_names, ':ipd', 'dst_ip', 'dest_ip', 'destination_ip', 'ip_dst')
# CNAM frequency
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)
# Store metadata in cache
meta = {
'session_id': session_id,
'upload_dir': str(upload_dir),
'total_rows': total_rows,
'col_names': col_names,
'cnam_col': cnam_col,
'cnrs_col': cnrs_col,
'isrs_col': isrs_col,
'src_lat_col': src_lat_col,
'src_lon_col': src_lon_col,
'dst_lat_col': dst_lat_col,
'dst_lon_col': dst_lon_col,
'ips_col': ips_col,
'ipd_col': ipd_col,
}
_store(session_id, {'meta': meta, 'df': df})
return JsonResponse({
'session_id': session_id,
'total_rows': total_rows,
'columns': col_names[:50],
'detected_columns': {
'cnam': cnam_col,
'cnrs': cnrs_col,
'isrs': isrs_col,
'src_lat': src_lat_col,
'src_lon': src_lon_col,
'dst_lat': dst_lat_col,
'dst_lon': dst_lon_col,
'ips': ips_col,
'ipd': ipd_col,
},
'cnam_frequency': cnam_freq[:30],
'top_cnam': top_cnam,
})
# ── Step 2: Filter ───────────────────────────────────────────────────────────
@csrf_exempt
@require_POST
def filter_data(request):
"""Apply cnam + cnrs + isrs filters; return preview rows."""
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: pl.DataFrame = entry['df']
meta = entry['meta']
cnam_col = meta['cnam_col']
cnrs_col = meta['cnrs_col']
isrs_col = meta['isrs_col']
# Validate required columns
missing = []
if not cnam_col:
missing.append('cnam/snam (目标类别)')
if missing:
return JsonResponse({
'error': f'缺少关键列: {", ".join(missing)}。请检查CSV文件格式。',
'missing_columns': missing,
}, status=400)
# Apply filter — cnrs and isrs are optional; only apply if column exists
try:
cond = (pl.col(cnam_col).cast(pl.Utf8) == selected_cnam)
if cnrs_col:
cond = cond & (pl.col(cnrs_col).cast(pl.Utf8).str.strip_chars() == '+')
if isrs_col:
cond = cond & (pl.col(isrs_col).cast(pl.Utf8).str.strip_chars() == '+')
filtered = df.filter(cond)
except Exception as e:
return JsonResponse({'error': f'筛选失败: {e}'}, status=400)
filtered_rows = len(filtered)
meta['filtered_cnam'] = selected_cnam
meta['filtered_rows'] = filtered_rows
entry['filtered_df'] = filtered
# Preview data (first 20 rows)
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: Cluster ──────────────────────────────────────────────────────────
@csrf_exempt
@require_POST
def run_clustering(request):
"""Run HDBSCAN geo-clustering or IP-subnet soft-clustering."""
body = json.loads(request.body)
session_id = body.get('session_id')
mode = body.get('mode', 'geo') # 'geo' or 'subnet'
min_cluster_size = int(body.get('min_cluster_size', 5))
cluster_selection_epsilon = float(body.get('cluster_selection_epsilon', 0.005))
min_records_per_subnet = int(body.get('min_records_per_subnet', 3))
max_cluster_size = int(body.get('max_cluster_size', 100))
entry = _cached(session_id)
if entry is None:
return JsonResponse({'error': '会话已过期,请重新上传'}, status=400)
filtered = entry.get('filtered_df')
if filtered is None:
return JsonResponse({'error': '请先执行筛选(步骤二)'}, status=400)
meta = entry['meta']
src_lat_col = meta['src_lat_col']
src_lon_col = meta['src_lon_col']
ips_col = meta['ips_col']
if mode == 'geo':
result = _cluster_geo(entry, filtered, meta, min_cluster_size, cluster_selection_epsilon)
else:
result = _cluster_subnet(entry, filtered, meta, min_records_per_subnet, max_cluster_size)
# Update cache with any modifications made by cluster functions
_store(session_id, entry)
return result
def _cluster_geo(entry: dict, df: pl.DataFrame, meta: dict,
min_cluster_size: int, cluster_selection_epsilon: float = 0.005):
"""HDBSCAN clustering on source IP latitude/longitude using Haversine distance."""
src_lat_col = meta['src_lat_col']
src_lon_col = meta['src_lon_col']
ips_col = meta['ips_col']
if not src_lat_col or not src_lon_col:
return JsonResponse({'error': '缺少源经纬度列(:ips.latd / :ips.lond),无法执行地理聚类'}, status=400)
# Drop rows with missing lat/lon
clean = df.filter(
pl.col(src_lat_col).is_not_null()
& pl.col(src_lon_col).is_not_null()
)
if len(clean) == 0:
return JsonResponse({'error': '所有记录均缺失经纬度,无法聚类'}, status=400)
# Clean lat/lon: cast to string, filter out '+'/'-' artifacts
try:
lat_raw = clean[src_lat_col].cast(pl.Utf8)
lon_raw = clean[src_lon_col].cast(pl.Utf8)
# Build validity mask: exclude '+', '-', empty, and None
valid_mask = (
lat_raw.is_not_null() & lon_raw.is_not_null()
& (lat_raw != '+') & (lat_raw != '-')
& (lon_raw != '+') & (lon_raw != '-')
& (lat_raw != '') & (lon_raw != '')
)
clean = clean.filter(valid_mask)
if len(clean) == 0:
return JsonResponse({'error': '无有效经纬度记录(排除符号值后)'}, status=400)
lat_vals = clean[src_lat_col].cast(pl.Float64, strict=False).to_numpy()
lon_vals = clean[src_lon_col].cast(pl.Float64, strict=False).to_numpy()
# Final NaN check
lat_vals = np.where(np.isnan(lat_vals), 0.0, lat_vals)
lon_vals = np.where(np.isnan(lon_vals), 0.0, lon_vals)
except Exception as e:
return JsonResponse({'error': f'经纬度列转换失败: {e}'}, status=400)
# Filter to valid lat/lon ranges
valid_range_mask = (
(lat_vals >= -90) & (lat_vals <= 90)
& (lon_vals >= -180) & (lon_vals <= 180)
& ~np.isnan(lat_vals) & ~np.isnan(lon_vals)
)
lat_vals = lat_vals[valid_range_mask]
lon_vals = lon_vals[valid_range_mask]
clean = clean.filter(
pl.Series(valid_range_mask)
)
if len(clean) < min_cluster_size:
return JsonResponse({
'error': f'有效经纬度记录数({len(clean)})少于最小聚类规模({min_cluster_size}',
}, status=400)
# ── HDBSCAN with Haversine distance ────────────────────────────────────
# Convert degrees to radians for haversine metric
lat_rad = np.radians(lat_vals)
lon_rad = np.radians(lon_vals)
coords = np.column_stack([lat_rad, lon_rad])
clusterer = HDBSCAN(
min_cluster_size=min_cluster_size,
min_samples=min(3, min_cluster_size),
metric='haversine',
cluster_selection_epsilon=cluster_selection_epsilon,
)
labels = clusterer.fit_predict(coords)
n_clusters = int(len(set(labels)) - (1 if -1 in labels else 0))
n_noise = int((labels == -1).sum())
noise_ratio = n_noise / len(labels) if len(labels) > 0 else 0
# Check for degenerate clustering (fallback suggestion)
fallback_suggested = False
if noise_ratio > 0.8 or n_clusters <= 1:
fallback_suggested = True
# Build cluster overview
cluster_map = {}
unique_labels = sorted(set(labels))
data_points_by_cluster = {}
for lbl in unique_labels:
mask = labels == lbl
idxs = np.where(mask)[0]
cluster_lats = lat_vals[idxs]
cluster_lons = lon_vals[idxs]
center_lat = float(np.median(cluster_lats))
center_lon = float(np.median(cluster_lons))
# Unique IPs
if ips_col:
ips_in_cluster = clean.filter(pl.Series(mask))[ips_col].cast(pl.Utf8).unique().to_list()
n_unique_ips = len(ips_in_cluster)
else:
ips_in_cluster = []
n_unique_ips = 0
cluster_map[int(lbl)] = {
'label': int(lbl),
'size': int(mask.sum()),
'center_lat': round(float(center_lat), 4),
'center_lon': round(float(center_lon), 4),
'n_unique_ips': int(n_unique_ips),
'is_noise': bool(lbl == -1),
}
# Collect individual data points for map rendering (lat, lon, index)
pts = []
for pt_idx, i in enumerate(idxs):
pts.append({
'lat': round(float(cluster_lats[pt_idx]), 4),
'lon': round(float(cluster_lons[pt_idx]), 4),
'idx': int(i),
})
data_points_by_cluster[int(lbl)] = pts
# Annotate original filtered DataFrame with cluster labels
label_series = np.full(len(df), -1, dtype=int)
# Map back: only the valid rows got labels
valid_indices = np.where(valid_range_mask)[0]
for i, idx in enumerate(valid_indices):
label_series[idx] = int(labels[i])
entry['cluster_labels'] = label_series.tolist()
entry['cluster_map'] = cluster_map
entry['cluster_mode'] = 'geo'
entry['n_clusters'] = n_clusters
entry['n_noise'] = n_noise
# Update filtered_df with cluster label column
df_with_labels = df.with_columns(pl.Series('_cluster_label', label_series))
entry['filtered_df'] = df_with_labels
return JsonResponse({
'n_clusters': n_clusters,
'n_noise': n_noise,
'n_valid_points': len(clean),
'clusters': cluster_map,
'labels': label_series.tolist(),
'data_points': data_points_by_cluster,
'fallback_suggested': fallback_suggested,
})
def _cluster_subnet(entry: dict, df: pl.DataFrame, meta: dict,
min_records_per_subnet: int = 3, max_cluster_size: int = 100):
"""Soft clustering by /24 subnet prefix.
Subnets with fewer than *min_records_per_subnet* records are marked noise (-1).
Subnets with more than *max_cluster_size* records are auto-split using geo
sub-clustering (HDBSCAN + haversine) on the available lat/lon within the subnet.
"""
ips_col = meta['ips_col']
src_lat_col = meta['src_lat_col']
src_lon_col = meta['src_lon_col']
if not ips_col:
return JsonResponse({'error': '缺少源IP列(:ips),无法执行子网聚类'}, status=400)
try:
ip_series = df[ips_col].cast(pl.Utf8)
except Exception as e:
return JsonResponse({'error': f'IP列转换失败: {e}'}, status=400)
# Extract /24 subnet
def _subnet24(ip: str) -> str | None:
if not ip or ip == 'None' or ip == '':
return None
parts = ip.split('.')
if len(parts) < 4:
return None
return '.'.join(parts[:3]) + '.0/24'
subnets = [None if v is None else _subnet24(str(v)) for v in ip_series.to_list()]
subnet_count = Counter(s for s in subnets if s is not None)
# Assign cluster labels based on subnet frequency
labels = []
cluster_map: dict[int, dict] = {}
next_label = 0
subnet_to_label: dict[str, int] = {}
# Track which records belong to large subnets for geo splitting
large_subnet_indices: dict[str, list[int]] = {}
for row_idx, s in enumerate(subnets):
if s is None:
labels.append(-1)
else:
cnt = subnet_count.get(s, 0)
if cnt < min_records_per_subnet:
labels.append(-1) # noise (too few records)
elif cnt > max_cluster_size:
# Large subnet → will apply geo sub-clustering later
if s not in subnet_to_label:
subnet_to_label[s] = next_label
cluster_map[next_label] = {
'label': next_label,
'size': 0,
'subnet': s,
'is_noise': False,
'_is_large': True,
'_large_subnet_name': s,
}
next_label += 1
large_subnet_indices[s] = []
lbl = subnet_to_label[s]
labels.append(lbl)
cluster_map[lbl]['size'] = cluster_map[lbl].get('size', 0) + 1
large_subnet_indices[s].append(row_idx)
else:
if s not in subnet_to_label:
subnet_to_label[s] = next_label
cluster_map[next_label] = {
'label': next_label,
'size': 0,
'subnet': s,
'is_noise': False,
}
next_label += 1
lbl = subnet_to_label[s]
labels.append(lbl)
cluster_map[lbl]['size'] = cluster_map[lbl].get('size', 0) + 1
# ── Geo sub-clustering for large subnets ────────────────────────────────
if large_subnet_indices and src_lat_col and src_lon_col:
for subnet_name, indices in large_subnet_indices.items():
old_label = subnet_to_label[subnet_name]
# Remove the temporary large-subnet cluster entry
cluster_map.pop(old_label, None)
# Get lat/lon for records in this subnet
subnet_df = df[pl.Series(indices)]
try:
sub_lats = subnet_df[src_lat_col].cast(pl.Float64).to_numpy()
sub_lons = subnet_df[src_lon_col].cast(pl.Float64).to_numpy()
valid = (
~np.isnan(sub_lats) & ~np.isnan(sub_lons)
& (sub_lats >= -90) & (sub_lats <= 90)
& (sub_lons >= -180) & (sub_lons <= 180)
)
sub_lats = sub_lats[valid]
sub_lons = sub_lons[valid]
if len(sub_lats) >= 3:
# Run HDBSCAN with haversine on this subnet's points
coords = np.column_stack([np.radians(sub_lats), np.radians(sub_lons)])
sub_cluster = HDBSCAN(
min_cluster_size=min(3, len(sub_lats) // 5),
min_samples=2,
metric='haversine',
cluster_selection_epsilon=0.005,
)
sub_labels = sub_cluster.fit_predict(coords)
# Create sub-clusters
sub_label_map: dict[int, int] = {}
valid_idx_iter = 0
for orig_rank, orig_idx in enumerate(indices):
if not valid[orig_rank - valid_idx_iter]:
# Was invalid, check if we skipped
pass
# Re-map: skip invalid points (mark noise)
valid_counter = 0
for orig_rank, orig_idx in enumerate(indices):
if not valid[orig_rank]:
labels[orig_idx] = -1 # no lat/lon → noise
else:
sl = int(sub_labels[valid_counter])
valid_counter += 1
if sl == -1:
labels[orig_idx] = -1
else:
sub_cluster_name = f'{subnet_name}/sub{sl}'
if sub_cluster_name not in subnet_to_label:
subnet_to_label[sub_cluster_name] = next_label
cluster_map[next_label] = {
'label': next_label,
'size': 0,
'subnet': sub_cluster_name,
'is_noise': False,
}
next_label += 1
new_lbl = subnet_to_label[sub_cluster_name]
labels[orig_idx] = new_lbl
cluster_map[new_lbl]['size'] = cluster_map[new_lbl].get('size', 0) + 1
else:
# Not enough geo data → keep as single cluster
cluster_map[old_label] = {
'label': old_label,
'size': len(indices),
'subnet': subnet_name,
'is_noise': False,
}
subnet_to_label[subnet_name] = old_label
except Exception as e:
logger.warning('Subnet geo-splitting failed for %s: %s', subnet_name, e)
# Keep the original cluster
cluster_map[old_label] = {
'label': old_label,
'size': len(indices),
'subnet': subnet_name,
'is_noise': False,
}
subnet_to_label[subnet_name] = old_label
# Update cluster maps with centers and unique IP counts
for lbl in list(cluster_map.keys()):
mask = np.array(labels) == lbl
ips_in_cluster = df.filter(pl.Series(mask))[ips_col].cast(pl.Utf8).unique().to_list()
cluster_map[lbl]['n_unique_ips'] = len(ips_in_cluster)
# Try to get geo center if lat/lon available
if src_lat_col and src_lon_col:
try:
clat = df.filter(pl.Series(mask))[src_lat_col].cast(pl.Float64).drop_nulls()
clon = df.filter(pl.Series(mask))[src_lon_col].cast(pl.Float64).drop_nulls()
if len(clat) > 0 and len(clon) > 0:
cluster_map[lbl]['center_lat'] = round(float(clat.median()), 4)
cluster_map[lbl]['center_lon'] = round(float(clon.median()), 4)
except Exception:
cluster_map[lbl]['center_lat'] = None
cluster_map[lbl]['center_lon'] = None
else:
cluster_map[lbl]['center_lat'] = None
cluster_map[lbl]['center_lon'] = None
n_noise = labels.count(-1)
n_clusters = len(cluster_map)
# Build data_points for map
data_points_by_cluster: dict[int, list] = {}
for lbl in list(cluster_map.keys()):
mask = np.array(labels) == lbl
pts = []
if src_lat_col and src_lon_col:
try:
sub = df.filter(pl.Series(mask))
lats = sub[src_lat_col].cast(pl.Float64).to_numpy()
lons = sub[src_lon_col].cast(pl.Float64).to_numpy()
for i in range(len(sub)):
if not np.isnan(lats[i]) and not np.isnan(lons[i]):
pts.append({'lat': round(float(lats[i]), 4),
'lon': round(float(lons[i]), 4),
'idx': int(i)})
except Exception:
pass
data_points_by_cluster[lbl] = pts
# Also collect noisy points' coordinates
noise_mask = np.array(labels) == -1
noise_points = []
if src_lat_col and src_lon_col:
try:
noise_sub = df.filter(pl.Series(noise_mask))
nlats = noise_sub[src_lat_col].cast(pl.Float64).to_numpy()
nlons = noise_sub[src_lon_col].cast(pl.Float64).to_numpy()
for i in range(len(noise_sub)):
if not np.isnan(nlats[i]) and not np.isnan(nlons[i]):
noise_points.append({'lat': round(float(nlats[i]), 4),
'lon': round(float(nlons[i]), 4),
'idx': int(i)})
except Exception:
pass
if noise_points:
data_points_by_cluster[-1] = noise_points
# Annotate DataFrame
df_with_labels = df.with_columns(pl.Series('_cluster_label', labels))
entry['cluster_labels'] = labels
entry['cluster_map'] = cluster_map
entry['cluster_mode'] = 'subnet'
entry['filtered_df'] = df_with_labels
entry['n_clusters'] = n_clusters
entry['n_noise'] = n_noise
return JsonResponse({
'n_clusters': n_clusters,
'n_noise': n_noise,
'n_valid_points': len(df),
'clusters': cluster_map,
'labels': labels,
'data_points': data_points_by_cluster,
})
# ── Step 4: Cluster detail ───────────────────────────────────────────────────
@require_GET
def cluster_detail(request, label: int):
"""Return detailed profile for a specific cluster."""
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)
df = entry.get('filtered_df')
meta = entry.get('meta', {})
cluster_map = entry.get('cluster_map', {})
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, {})
# Helper: get unique values for a column
def _top_values(col_name: str, top_n: int = 5) -> list:
if 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()
return [
{'value': v['counts'] if isinstance(v, dict) else str(row[0]),
'count': int(row[1]),
'pct': round(int(row[1]) / total * 100, 1)}
for row in vals.iter_rows()
]
except Exception:
return []
# Source IPs
ips_col = meta.get('ips_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
# Detect useful columns for profiling
col_names = df.columns
dst_ip_col = _find_col(col_names, ':ipd', 'dst_ip', 'dest_ip', 'destination_ip')
dst_port_col = _find_col(col_names, 'dst_port', 'dest_port', 'port', 'dport')
tls_ver_col = _find_col(col_names, '0ver', 'tls_version', 'version', 'tlsver')
cipher_col = _find_col(col_names, '0cph', 'cipher_suite', 'cipher', 'tls_cipher')
sni_col = _find_col(col_names, 'sni', 'server_name', 'tls_sni')
bytes_col = _find_col(col_names, '8byt', 'bytes_sent', 'bytes', 'src_bytes')
dur_col = _find_col(col_names, '4dur', 'duration', 'dur', 'flow_duration')
proto_col = _find_col(col_names, 'proto', 'protocol', 'l4_proto')
profile = {
'label': label,
'size': rows,
'cluster_info': cluster_info,
'unique_ips': unique_ips[:100],
'unique_ips_count': len(unique_ips),
'top_dst_ips': _top_values(dst_ip_col) if dst_ip_col else [],
'top_dst_ports': _top_values(dst_port_col) if dst_port_col else [],
'top_tls_versions': _top_values(tls_ver_col) if tls_ver_col else [],
'top_ciphers': _top_values(cipher_col) if cipher_col else [],
'top_sni': _top_values(sni_col) if sni_col else [],
'top_protocols': _top_values(proto_col) if proto_col else [],
# Traffic stats
'traffic': {},
}
# Traffic stats
if bytes_col and bytes_col in cluster_df.columns:
try:
total_bytes = cluster_df[bytes_col].cast(pl.Float64).sum()
profile['traffic']['total_bytes'] = round(total_bytes, 2) if total_bytes else 0
except Exception:
pass
if dur_col and dur_col in cluster_df.columns:
try:
total_dur = cluster_df[dur_col].cast(pl.Float64).sum()
avg_dur = cluster_df[dur_col].cast(pl.Float64).mean()
profile['traffic']['total_duration'] = round(total_dur, 2) if total_dur else 0
profile['traffic']['avg_duration'] = round(avg_dur, 2) if avg_dur else 0
except Exception:
pass
# Geo bounding box (if lat/lon available)
src_lat_col = meta.get('src_lat_col')
src_lon_col = meta.get('src_lon_col')
if src_lat_col and src_lon_col and src_lat_col in cluster_df.columns:
try:
lats = cluster_df[src_lat_col].cast(pl.Float64).drop_nulls()
lons = cluster_df[src_lon_col].cast(pl.Float64).drop_nulls()
if len(lats) > 0 and len(lons) > 0:
profile['geo_bounds'] = {
'lat_min': round(float(lats.min()), 4),
'lat_max': round(float(lats.max()), 4),
'lon_min': round(float(lons.min()), 4),
'lon_max': round(float(lons.max()), 4),
}
except Exception:
pass
return JsonResponse(profile)
# ── Main page ────────────────────────────────────────────────────────────────
def index(request):
"""Render the simple analysis workbench page."""
return render(request, 'simple_analysis/simple_analysis.html')
+112 -54
View File
@@ -1,62 +1,120 @@
{% extends 'base.html' %}
{% block title %}Cluster #{{ cluster.cluster_label }} - Run #{{ run.display_id }}{% endblock %}
{% load static %}
{% block title %}Cluster #{{ cluster.cluster_label }} — Run #{{ run.display_id }}{% endblock %}
{% block content %}
<style>
.entity-card { border:1px solid #e0e0e0; border-radius:6px; padding:0.6rem; margin-bottom:0.4rem; background:#fafafa; }
.entity-card .val { font-weight:600; font-size:0.85rem; }
.entity-card .feat { font-size:0.75rem; color:#666; display:inline-block; margin-right:0.5rem; }
.feat-positive { color:#2e7d32; }
.feat-negative { color:#c62828; }
.feat-table { width:100%; border-collapse:collapse; font-size:0.8rem; }
.feat-table th { background:#f5f5f5; padding:0.3rem 0.5rem; text-align:left; font-weight:600; border-bottom:2px solid #ddd; }
.feat-table td { padding:0.25rem 0.5rem; border-bottom:1px solid #eee; }
</style>
<div class="card">
<a href="{% url 'analysis:cluster_overview' run.display_id %}" style="color:#4361ee;text-decoration:none;font-size:0.9rem;">Back to overview</a>
<h2 style="margin-top:0.5rem;">Cluster #{{ cluster.cluster_label }}</h2>
<p>{{ cluster.size }} entities ({{ cluster.proportion|floatformat:2 }}% of total)</p>
<p>Silhouette Score: {{ cluster.silhouette_score|floatformat:4|default:"-" }}</p>
<a href="{% url 'analysis:cluster_overview' run.display_id %}" style="color:#4361ee;text-decoration:none;font-size:0.9rem;">Cluster Overview</a>
<div style="display:flex;justify-content:space-between;align-items:center;flex-wrap:wrap;gap:0.5rem;margin-top:0.5rem;">
<div>
<h2 style="margin:0;">Cluster #{{ cluster.cluster_label }}
{% if cluster.cluster_label == -1 %}<span class="badge badge-warning">Noise</span>{% endif %}
</h2>
<p style="margin:0.25rem 0 0;color:#666;font-size:0.9rem;">
{{ cluster.size }} entities
{% if cluster.proportion %} — {{ cluster.proportion|floatformat:1 }}% of total{% endif %}
{% if cluster.silhouette_score is not None %} — Silhouette: {{ cluster.silhouette_score|floatformat:4 }}{% endif %}
</p>
</div>
</div>
{% if nl_summary %}
<div style="background:#f8f9fa;border-left:3px solid #4361ee;padding:0.6rem;margin:0.75rem 0;border-radius:0 4px 4px 0;font-size:0.85rem;line-height:1.6;color:#333;">{{ nl_summary }}</div>
{% endif %}
</div>
<div class="grid-2">
<div class="card">
<h2>Feature Summary (top 50)</h2>
<table>
<thead>
<tr>
<th>Feature</th>
<th>Mean</th>
<th>Std</th>
<th>Median</th>
<th>Dist. Score</th>
</tr>
</thead>
<tbody>
{% for f in features %}
<tr>
<td><code>{{ f.feature_name }}</code></td>
<td>{{ f.mean|floatformat:3|default:"-" }}</td>
<td>{{ f.std|floatformat:3|default:"-" }}</td>
<td>{{ f.median|floatformat:3|default:"-" }}</td>
<td>{{ f.distinguishing_score|floatformat:3|default:"-" }}</td>
</tr>
{% empty %}
<tr><td colspan="5">No features</td></tr>
{% endfor %}
</tbody>
</table>
</div>
<!-- ── Feature Analysis ── -->
<div class="card">
<h3>📊 Feature Analysis <span style="font-weight:400;font-size:0.8rem;color:#888;">{{ features|length }} features</span></h3>
{% if features %}
<table class="feat-table">
<thead>
<tr>
<th>#</th>
<th>Feature</th>
<th>Dist. Score</th>
<th>Mean</th>
<th>Std</th>
<th>Median</th>
<th>P25</th>
<th>P75</th>
</tr>
</thead>
<tbody>
{% for f in features %}
<tr>
<td>{{ forloop.counter }}</td>
<td><code>{{ f.feature_name }}</code></td>
<td><span class="{% if f.distinguishing_score > 0 %}feat-positive{% else %}feat-negative{% endif %}">{{ f.distinguishing_score|floatformat:3 }}</span></td>
<td>{{ f.mean|floatformat:3|default:"-" }}</td>
<td>{{ f.std|floatformat:3|default:"-" }}</td>
<td>{{ f.median|floatformat:3|default:"-" }}</td>
<td>{{ f.p25|floatformat:3|default:"-" }}</td>
<td>{{ f.p75|floatformat:3|default:"-" }}</td>
</tr>
{% endfor %}
</tbody>
</table>
{% else %}
<div class="empty-state"><p>No feature data for this cluster.</p></div>
{% endif %}
</div>
<div class="card">
<h2>Entities (top 50)</h2>
<table>
<thead>
<tr>
<th>Entity</th>
<th></th>
</tr>
</thead>
<tbody>
{% for e in entities %}
<tr>
<td><code>{{ e.entity_value }}</code></td>
<td><a href="{% url 'analysis:entity_profile' e.id %}" class="btn btn-primary">Profile</a></td>
</tr>
{% empty %}
<tr><td colspan="2">No entities</td></tr>
{% endfor %}
</tbody>
</table>
<!-- ── SVD Singular Values ── -->
{% if cluster.cluster_label != -1 %}
{# SVD-extracted features (from _run_clustering_pipeline cluster_svd_extract) have higher distinguishing scores #}
{% with svd_features=features|slice:":5" %}
{% if svd_features %}
<div class="card">
<h3>🔬 SVD Key Features <span style="font-weight:400;font-size:0.8rem;color:#888;">Top 5 by distinguishing score</span></h3>
<div style="display:grid;grid-template-columns:1fr 1fr;gap:0.5rem;">
{% for f in svd_features %}
<div style="background:#f8f9fa;border-radius:6px;padding:0.5rem;border:1px solid #e0e0e0;">
<div style="font-weight:600;font-size:0.8rem;">{{ f.feature_name }}</div>
<div style="font-size:0.75rem;color:#666;margin-top:0.2rem;">
Score: <span class="{% if f.distinguishing_score > 0 %}feat-positive{% else %}feat-negative{% endif %}">{{ f.distinguishing_score|floatformat:2 }}</span>
| Mean: {{ f.mean|floatformat:2 }}
| Std: {{ f.std|floatformat:2 }}
</div>
</div>
{% endfor %}
</div>
</div>
{% endblock %}
{% endif %}
{% endwith %}
{% endif %}
<!-- ── Entity List ── -->
<div class="card">
<h3>👤 Entities <span style="font-weight:400;font-size:0.8rem;color:#888;">{{ entities|length }} shown</span></h3>
<div id="entityList">
{% for e in entities %}
<div class="entity-card">
<div style="display:flex;justify-content:space-between;align-items:center;">
<span class="val">{{ e.entity_value }}</span>
<a href="{% url 'analysis:entity_profile' e.id %}" class="btn btn-sm" style="font-size:0.75rem;padding:0.2rem 0.6rem;background:#4361ee;color:#fff;text-decoration:none;border-radius:4px;">Profile</a>
</div>
{% if e.feature_json %}
<div style="margin-top:0.3rem;">
{% for k, v in e.feature_json.items|slice:":8" %}
<span class="feat"><strong>{{ k }}</strong>: {% if v is None %}-{% else %}{{ v|floatformat:2 }}{% endif %}</span>
{% endfor %}
{% if e.feature_json.items|length > 8 %}<span class="feat" style="color:#999;">+{{ e.feature_json.items|length|add:"-8" }} more</span>{% endif %}
</div>
{% endif %}
</div>
{% empty %}
<div class="empty-state"><p>No entities in this cluster.</p></div>
{% endfor %}
</div>
</div>
{% endblock %}
File diff suppressed because it is too large Load Diff
+30 -1
View File
@@ -81,7 +81,6 @@
<a href="{% url 'analysis:run_list' %}">运行记录</a>
<a href="{% url 'analysis:globe' %}">态势</a>
<a href="{% url 'analysis:config' %}">配置</a>
<a href="{% url 'simple_analysis:index' %}" style="color:#f4a261;font-weight:600;">简单分析</a>
</nav>
<div class="container">
{% block content %}{% endblock %}
@@ -123,6 +122,36 @@
const msg = event.reason && event.reason.message ? event.reason.message : String(event.reason || 'Unknown error');
showError('未处理的错误: ' + msg);
});
// ── Server heartbeat: detect when Django crashes ──
(function() {
var heartbeatBanner = null;
function checkServer() {
fetch('/', { method: 'HEAD', cache: 'no-store' })
.then(function() {
if (heartbeatBanner) {
heartbeatBanner.remove();
heartbeatBanner = null;
}
})
.catch(function() {
if (!heartbeatBanner) {
heartbeatBanner = document.createElement('div');
heartbeatBanner.style.cssText = 'position:fixed;top:0;left:0;right:0;z-index:10000;background:#dc3545;color:#fff;text-align:center;padding:0.5rem;font-size:0.85rem;font-weight:600;';
heartbeatBanner.textContent = '⚠️ 服务器连接断开 — Django 进程可能已崩溃。请重启应用。';
document.body.prepend(heartbeatBanner);
}
});
}
// Check every 30 seconds
setInterval(checkServer, 30000);
// Also check when the page becomes visible again
document.addEventListener('visibilitychange', function() {
if (!document.hidden) checkServer();
});
})();
</script>
</body>
</html>
+351 -40
View File
@@ -148,6 +148,39 @@
<button class="btn btn-primary" style="margin-top:1rem;" id="startAutoBtn" onclick="startAuto()" {% if not llm_configured %}disabled{% endif %}>开始自动分析</button>
</div>
<!-- Filter builder card -->
<div class="card" id="filterCard" style="background:#f0f4ff;border-color:#c8d6e5;">
<h3>🔍 数据筛选(可选)</h3>
<div id="filterNoDataset" style="text-align:center;color:#999;padding:1rem;font-size:0.9rem;">选择数据集后可用</div>
<div id="filterRows" style="display:none;">
<div class="filter-row" data-idx="0" style="display:flex;gap:0.5rem;align-items:center;margin-bottom:0.4rem;">
<select class="filter-col" style="flex:1;padding:0.35rem;border:1px solid #ccc;border-radius:4px;"><option value="">-- 列 --</option></select>
<select class="filter-op" style="width:130px;padding:0.35rem;border:1px solid #ccc;border-radius:4px;">
<option value="eq">==</option><option value="neq">!=</option>
<option value="gt">&gt;</option><option value="lt">&lt;</option>
<option value="gte">&gt;=</option><option value="lte">&lt;=</option>
<option value="contains">contains</option>
<option value="is_null">is_null</option>
<option value="not_null">not_null</option>
<option value="in">in</option>
</select>
<input class="filter-val" placeholder="值" style="flex:1;padding:0.35rem;border:1px solid #ccc;border-radius:4px;">
<button class="btn" onclick="removeFilterRow(this)" style="background:#dc3545;color:#fff;font-size:0.8rem;padding:0.2rem 0.5rem;display:none;"></button>
</div>
</div>
<div id="filterControls" style="display:none;margin:0.5rem 0;gap:0.5rem;align-items:center;">
<label style="font-size:0.85rem;white-space:nowrap;">逻辑:</label>
<select id="filterLogic" style="padding:0.35rem;border:1px solid #ccc;border-radius:4px;">
<option value="and">AND</option><option value="or">OR</option>
</select>
<button class="btn" onclick="addFilterRow()" style="font-size:0.8rem;background:#e8f0fe;color:#4361ee;">+ 添加条件</button>
<button class="btn" onclick="resetFilter()" style="font-size:0.8rem;background:#fce4ec;color:#c62828;">重置</button>
<button class="btn btn-primary" onclick="applyFilter()" style="margin-left:auto;">应用筛选</button>
</div>
<div id="filterResult" style="display:none;margin-top:0.5rem;padding:0.5rem;background:#e8f5e9;border-radius:4px;font-size:0.85rem;"></div>
<div id="activeFilterInfo" style="display:none;margin-top:0.5rem;padding:0.35rem 0.5rem;background:#fff3cd;border-radius:4px;font-size:0.8rem;color:#856404;"></div>
</div>
<div id="autoProgress" style="display:none;margin-top:1rem;">
<p>LLM 编排中... <span id="autoStatus"></span></p>
<div style="background:#eee;height:8px;border-radius:4px;overflow:hidden;">
@@ -171,16 +204,27 @@
const DATASETS_WITH_STATUS = {{ datasets_with_status_json|safe }};
let activeFilter = 'all';
let selectedRunId = null;
let activeFilteredDatasetId = null;
let currentFilters = [];
let currentFilterLogic = 'and';
// ── Build column map from session datasets ──
const DATASET_COLUMNS = {};
DATASETS_WITH_STATUS.forEach(ds => {
if (ds.columns && ds.columns.length > 0) {
DATASET_COLUMNS[ds.dataset_id] = ds.columns;
}
});
// ── Dataset table rendering ──
function buildDatasetTable(filter) {
const container = document.getElementById('datasetTable');
let rows = DATASETS_WITH_STATUS.filter(ds => {
if (filter === 'all') return true;
if (filter === 'ready') return ds.run_status === 'ready' || (!ds.run_status || ds.run_status === 'ready');
if (filter === 'analyzing') return ['pending', 'loading', 'profiling', 'aggregating', 'clustering', 'extracting'].includes(ds.run_status);
if (filter === 'completed') return ds.run_status === 'completed';
if (filter === 'failed') return ds.run_status === 'failed';
if (filter === 'ready') return ds.badge_class === 'ready';
if (filter === 'analyzing') return ds.badge_class === 'analyzing';
if (filter === 'completed') return ds.badge_class === 'completed';
if (filter === 'failed') return ds.badge_class === 'failed';
return true;
});
@@ -192,9 +236,219 @@ function buildDatasetTable(filter) {
let html = '<table class="ds-table"><thead><tr><th></th><th>ID</th><th>状态</th><th>行数</th><th>文件数</th><th>来源</th></tr></thead><tbody>';
rows.forEach(ds => {
const label = ds.status_label || '待分析';
const badgeClass = (ds.run_status === 'completed') ? 'completed' :
(ds.run_status === 'failed') ? 'failed' :
(ds.run_status && ds.run_status !== 'ready') ? 'analyzing' : 'ready';
const badgeClass = ds.badge_class || 'ready';
const dispId = ds.display_id || ds.dataset_id;
const rowCount = ds.row_count != null ? ds.row_count.toLocaleString() : '?';
const fileCount = ds.file_count != null ? ds.file_count : '?';
const source = ds.sqlite_table ? ('SQLite: ' + ds.sqlite_table) : (ds.csv_glob || '?');
const checked = (selectedRunId === String(ds.dataset_id)) ? 'checked' : '';
const reloadIndicator = ds.needs_reload ? ' <span style="color:#e65100;font-size:0.7rem;">(需加载)</span>' : '';
html += '<tr data-ds-id="' + ds.dataset_id + '" onclick="selectRow(\'' + ds.dataset_id + '\')" class="' + (selectedRunId === String(ds.dataset_id) ? 'selected' : '') + '">' +
'<td><input type="radio" name="auto_run_id" value="' + ds.dataset_id + '" ' + checked + ' onclick="event.stopPropagation();selectRow(\'' + ds.dataset_id + '\')"></td>' +
'<td style="font-weight:600;">#' + dispId + '</td>' +
'<td><span class="status-badge ' + badgeClass + '">' + label + reloadIndicator + '</span></td>' +
'<td>' + rowCount + '</td>' +
'<td>' + fileCount + '</td>' +
'<td style="max-width:180px;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;">' + escapeHtml(source) + '</td>' +
'</tr>';
});
html += '</tbody></table>';
container.innerHTML = html;
}
function selectRow(dsId) {
selectedRunId = String(dsId);
// Update radio buttons and row highlight
document.querySelectorAll('#datasetTable input[name="auto_run_id"]').forEach(r => {
r.checked = (r.value === String(dsId));
});
document.querySelectorAll('#datasetTable tr').forEach(tr => tr.classList.remove('selected'));
const row = document.querySelector('#datasetTable tr[data-ds-id="' + dsId + '"]');
if (row) row.classList.add('selected');
// Check if data needs reload
const dsInfo = DATASETS_WITH_STATUS.find(ds => ds.dataset_id === dsId);
if (dsInfo && dsInfo.needs_reload) {
// Auto-reload data
if (row) row.style.opacity = '0.6';
fetch('/tools/reload-run/', {
method: 'POST',
headers: { 'Content-Type':'application/json', 'X-CSRFToken':'{{ csrf_token }}' },
body: JSON.stringify({ display_id: dsInfo.display_id || dsInfo.dataset_id }),
})
.then(r => r.json())
.then(data => {
if (data.error) {
showError('加载数据集失败: ' + data.error);
if (row) row.style.opacity = '1';
return;
}
dsInfo.needs_reload = false;
if (data.columns) {
dsInfo.columns = data.columns;
DATASET_COLUMNS[dsId] = data.columns;
}
if (row) row.style.opacity = '1';
// Rebuild table to remove reload indicator
buildDatasetTable(activeFilter);
selectRow(dsId);
showSuccess('数据集已加载: ' + dsId);
})
.catch(err => {
showError('加载数据集失败: ' + err.message);
if (row) row.style.opacity = '1';
});
return;
}
// Populate filter column dropdowns
onSelectDataset(dsId);
}
function onSelectDataset(dsId) {
const noDatasetMsg = document.getElementById('filterNoDataset');
const filterRows = document.getElementById('filterRows');
const filterControls = document.getElementById('filterControls');
const filterResult = document.getElementById('filterResult');
if (!dsId || !DATASET_COLUMNS[dsId] || DATASET_COLUMNS[dsId].length === 0) {
noDatasetMsg.style.display = 'block';
filterRows.style.display = 'none';
filterControls.style.display = 'none';
filterResult.style.display = 'none';
return;
}
noDatasetMsg.style.display = 'none';
filterRows.style.display = 'block';
filterControls.style.display = 'flex';
const cols = DATASET_COLUMNS[dsId] || [];
document.querySelectorAll('.filter-col').forEach(sel => {
const cur = sel.value;
sel.innerHTML = '<option value="">-- 列 --</option>';
cols.forEach(c => { sel.innerHTML += '<option value="' + c + '" ' + (c===cur?'selected':'') + '>' + c + '</option>'; });
});
}
function setDatasetFilter(filter) {
activeFilter = filter;
document.querySelectorAll('#statusFilter .filter-btn').forEach(btn => {
btn.classList.toggle('active', btn.dataset.filter === filter);
});
buildDatasetTable(filter);
}
// Init filter tabs
document.querySelectorAll('#statusFilter .filter-btn').forEach(btn => {
btn.addEventListener('click', function() {
setDatasetFilter(this.dataset.filter);
});
});
buildDatasetTable('all');
// ── Filter builder functions ──
function addFilterRow() {
const rows = document.querySelectorAll('#filterRows .filter-row');
if (rows.length >= 5) return;
const template = rows[0].cloneNode(true);
template.querySelector('.filter-val').value = '';
template.querySelector('.filter-col').selectedIndex = 0;
template.querySelector('.filter-op').selectedIndex = 0;
template.querySelector('button').style.display = 'inline-block';
document.getElementById('filterRows').appendChild(template);
if (selectedRunId) onSelectDataset(selectedRunId);
}
function removeFilterRow(btn) {
const rows = document.querySelectorAll('#filterRows .filter-row');
if (rows.length <= 1) return;
btn.closest('.filter-row').remove();
}
function resetFilter() {
const rows = document.querySelectorAll('#filterRows .filter-row');
for (let i = rows.length - 1; i >= 1; i--) rows[i].remove();
const firstRow = rows[0];
firstRow.querySelector('.filter-val').value = '';
firstRow.querySelector('.filter-col').selectedIndex = 0;
firstRow.querySelector('.filter-op').selectedIndex = 0;
firstRow.querySelector('button').style.display = 'none';
document.getElementById('filterResult').style.display = 'none';
document.getElementById('activeFilterInfo').style.display = 'none';
currentFilters = [];
activeFilteredDatasetId = null;
}
function collectFilters() {
const filters = [];
document.querySelectorAll('#filterRows .filter-row').forEach(row => {
const col = row.querySelector('.filter-col').value;
const op = row.querySelector('.filter-op').value;
let val = row.querySelector('.filter-val').value;
if (!col || !op) return;
if ((op === 'in') && val) {
val = val.split(',').map(s => s.trim()).filter(s => s);
}
filters.push({ column: col, op: op, value: val });
});
return filters;
}
async function applyFilter() {
if (!selectedRunId) { showError('请先选择数据集'); return; }
const filters = collectFilters();
if (filters.length === 0) { showError('请添加至少一个筛选条件'); return; }
const logic = document.getElementById('filterLogic').value;
try {
const resp = await fetch('/tools/filter/', {
method: 'POST',
headers: { 'Content-Type':'application/json', 'X-CSRFToken':'{{ csrf_token }}' },
body: JSON.stringify({ dataset_id: selectedRunId, filters, logic }),
});
const data = await resp.json();
if (data.error) { showError('筛选失败: ' + data.error); return; }
const resultDiv = document.getElementById('filterResult');
resultDiv.style.display = 'block';
resultDiv.innerHTML = '<strong>✅ 筛选完成</strong>: 数据集 <code>' + data.dataset_id + '</code>,共 <strong>' + data.row_count + '</strong> 行';
showSuccess('筛选完成: ' + data.row_count + ' 行');
currentFilters = filters;
currentFilterLogic = logic;
activeFilteredDatasetId = data.dataset_id;
const infoDiv = document.getElementById('activeFilterInfo');
infoDiv.style.display = 'block';
infoDiv.textContent = '已应用筛选 (' + filters.length + ' 条件) 到 ' + data.dataset_id;
} catch(e) {
showError('筛选请求失败: ' + e.message);
}
}
// ── Dataset table rendering ──
function buildDatasetTable(filter) {
const container = document.getElementById('datasetTable');
let rows = DATASETS_WITH_STATUS.filter(ds => {
if (filter === 'all') return true;
if (filter === 'ready') return ds.badge_class === 'ready';
if (filter === 'analyzing') return ds.badge_class === 'analyzing';
if (filter === 'completed') return ds.badge_class === 'completed';
if (filter === 'failed') return ds.badge_class === 'failed';
return true;
});
if (!rows.length) {
container.innerHTML = '<p style="text-align:center;color:#999;padding:1rem;">无匹配的数据集</p>';
return;
}
let html = '<table class="ds-table"><thead><tr><th></th><th>ID</th><th>状态</th><th>行数</th><th>文件数</th><th>来源</th></tr></thead><tbody>';
rows.forEach(ds => {
const label = ds.status_label || '待分析';
const badgeClass = ds.badge_class || 'ready';
const dispId = ds.display_id || ds.dataset_id;
const rowCount = ds.row_count != null ? ds.row_count.toLocaleString() : '?';
const fileCount = ds.file_count != null ? ds.file_count : '?';
@@ -260,33 +514,64 @@ function toggleStep(el) {
}
function buildTimeline(llmThinking, toolCalls) {
// Build tool entries from tool_calls_json (no separate thought entries)
const entries = (toolCalls || []).map(tc => ({
step: tc.step,
type: 'tool',
name: tc.name,
input: tc.input,
output: tc.output,
}));
// Match llm_thinking text to tool entries by step number
// Parse llm_thinking into per-step segments
const thinkingByStep = {};
const thinkOrder = []; // track step order for interleaving
if (llmThinking) {
const thinkingByStep = {};
const parts = llmThinking.split(/\n(?=\[\d+\])/);
for (const part of parts) {
const m = part.match(/^\[(\d+)\]\s*(.*)/s);
if (m) {
thinkingByStep[parseInt(m[1])] = m[2].trim();
}
}
for (const entry of entries) {
if (thinkingByStep[entry.step] !== undefined) {
entry.thinking = thinkingByStep[entry.step];
const step = parseInt(m[1]);
if (!(step in thinkingByStep)) {
thinkOrder.push(step);
}
thinkingByStep[step] = (thinkingByStep[step] || '') + (thinkingByStep[step] ? '\n' : '') + m[2].trim();
}
}
}
// Build entries: interleave thinking + tool per step
const toolMap = {}; // step -> tool entries
(toolCalls || []).forEach(tc => {
const s = tc.step;
if (!toolMap[s]) toolMap[s] = [];
toolMap[s].push({
type: 'tool',
name: tc.name,
input: tc.input,
output: tc.output,
});
});
const entries = [];
// Collect all step numbers that appear in either thinking or tool_calls
const allSteps = new Set([...thinkOrder, ...Object.keys(toolMap).map(Number)]);
const sortedSteps = Array.from(allSteps).sort((a, b) => a - b);
for (const step of sortedSteps) {
// Thinking first (独立条目)
if (thinkingByStep[step] !== undefined) {
// Last step with only thinking and no tool call = 回答
const isLastStep = step === sortedSteps[sortedSteps.length - 1];
const hasTool = toolMap[step] && toolMap[step].length > 0;
if (isLastStep && !hasTool) {
entries.push({ step, type: 'answer', text: thinkingByStep[step] });
} else {
entries.push({ step, type: 'thinking', text: thinkingByStep[step] });
}
}
// Then tool entries for this step
if (toolMap[step]) {
for (const te of toolMap[step]) {
// Attach thinking to first tool entry only if not already shown separately
// (No, we already show thinking separately above)
entries.push({ step, ...te, thinking: '' });
}
}
}
entries.sort((a, b) => a.step - b.step);
return entries;
}
@@ -308,25 +593,33 @@ function renderTimeline(entries) {
lastStep = entry.step;
}
if (entry.type === 'tool') {
if (entry.type === 'thinking') {
// 思考条目 — 浅蓝背景文本块
const div = document.createElement('div');
div.style.cssText = 'background:#eef2ff;padding:0.6rem;border-radius:6px;font-size:0.78rem;line-height:1.6;color:#333;max-height:200px;overflow:auto;margin-bottom:0.3rem;white-space:pre-wrap;border:1px solid #d0d8f0;';
div.innerHTML =
'<span style="font-weight:600;color:#4361ee;font-size:0.75rem;display:block;margin-bottom:0.3rem;">💭 思考</span>' +
escapeHtml(entry.text);
container.appendChild(div);
} else if (entry.type === 'answer') {
// 回答条目 — 绿色背景
const div = document.createElement('div');
div.style.cssText = 'background:#e8f5e9;padding:0.6rem;border-radius:6px;font-size:0.78rem;line-height:1.6;color:#333;max-height:300px;overflow:auto;margin-bottom:0.3rem;white-space:pre-wrap;border:1px solid #c8e6c9;';
div.innerHTML =
'<span style="font-weight:600;color:#2e7d32;font-size:0.75rem;display:block;margin-bottom:0.3rem;">✅ 回答</span>' +
escapeHtml(entry.text);
container.appendChild(div);
} else if (entry.type === 'tool') {
const card = document.createElement('div');
card.style.cssText = 'border:1px solid #e0e0e0;border-radius:6px;margin-bottom:0.3rem;overflow:hidden;background:#fff;';
const inputStr = prettyJson(entry.input);
const hasThinking = entry.thinking && (entry.output == null || entry.output === '' || entry.output === 'null' || entry.output === 'None');
const outputStr = prettyJson(entry.output);
let bodyInner = '';
bodyInner +=
'<div style="font-weight:600;font-size:0.72rem;color:#555;margin-bottom:0.2rem;">输入 (INPUT):</div>' +
'<pre style="background:#1a1a2e;color:#e0e0e0;padding:0.5rem;border-radius:4px;font-size:0.7rem;max-height:200px;overflow:auto;margin:0 0 0.3rem 0;"><code>' + escapeHtml(inputStr) + '</code></pre>';
if (hasThinking) {
bodyInner +=
'<div style="font-weight:600;font-size:0.72rem;color:#555;margin-bottom:0.2rem;">思考 (THINKING):</div>' +
'<div style="background:#eef2ff;padding:0.6rem;border-radius:4px;font-size:0.78rem;line-height:1.6;color:#333;max-height:200px;overflow:auto;margin:0;white-space:pre-wrap;">' + escapeHtml(entry.thinking) + '</div>';
} else {
bodyInner +=
'<div style="font-weight:600;font-size:0.72rem;color:#555;margin-bottom:0.2rem;">输出 (OUTPUT):</div>' +
'<pre style="background:#1a1a2e;color:#e0e0e0;padding:0.5rem;border-radius:4px;font-size:0.7rem;max-height:200px;overflow:auto;margin:0;"><code>' + escapeHtml(outputStr) + '</code></pre>';
}
'<pre style="background:#1a1a2e;color:#e0e0e0;padding:0.5rem;border-radius:4px;font-size:0.7rem;max-height:200px;overflow:auto;margin:0 0 0.3rem 0;"><code>' + escapeHtml(inputStr) + '</code></pre>' +
'<div style="font-weight:600;font-size:0.72rem;color:#555;margin-bottom:0.2rem;">输出 (OUTPUT):</div>' +
'<pre style="background:#1a1a2e;color:#e0e0e0;padding:0.5rem;border-radius:4px;font-size:0.7rem;max-height:200px;overflow:auto;margin:0;"><code>' + escapeHtml(outputStr) + '</code></pre>';
card.innerHTML =
'<div class="tool-call-header" onclick="toggleStep(this)" style="padding:0.4rem 0.8rem;">' +
'<span><span style="background:#43aa8b;color:#fff;border-radius:3px;padding:0 5px;font-size:0.7rem;font-weight:600;margin-right:6px;">🔧</span>' +
@@ -358,6 +651,16 @@ async function startAuto() {
return;
}
// Extract numeric display_id from dataset key (upload_XX or run_XX)
let displayId;
const uploadMatch = runId.match(/^(?:upload_|run_)(\d+)$/);
if (uploadMatch) {
displayId = parseInt(uploadMatch[1]);
} else {
displayId = parseInt(runId);
}
if (isNaN(displayId)) { showError('无效的数据集ID: ' + runId); return; }
const timelineContainer = document.getElementById('timeline');
const timelinePanel = document.getElementById('timelinePanel');
@@ -369,12 +672,20 @@ async function startAuto() {
timelinePanel.style.display = 'none';
timelineContainer.innerHTML = '';
// Build request body
const body = { run_id: displayId };
if (activeFilteredDatasetId) {
// Use filtered dataset: re-apply filter on the backend
body.filters = currentFilters;
body.logic = currentFilterLogic;
}
let resp;
try {
resp = await fetch('/analyze/llm/', {
method: 'POST',
headers: { 'Content-Type': 'application/json', 'X-CSRFToken': '{{ csrf_token }}' },
body: JSON.stringify({ run_id: parseInt(runId) }),
body: JSON.stringify(body),
});
} catch (e) { showError('启动 LLM 分析失败: ' + e.message); return; }
const data = await resp.json();
@@ -383,7 +694,7 @@ async function startAuto() {
statusPoll = setInterval(async () => {
try {
const r = await fetch('/runs/' + runId + '/status/');
const r = await fetch('/runs/' + displayId + '/status/');
const s = await r.json();
const pct = s.progress_pct || 0;
const msg = s.progress_msg || s.status;
@@ -403,7 +714,7 @@ async function startAuto() {
document.getElementById('autoBar').style.width = '100%';
document.getElementById('cancelBtn').style.display = 'none';
document.getElementById('autoStatus').textContent = '分析完成!正在跳转...';
setTimeout(() => { window.location.href = '/clusters/' + runId + '/'; }, 1500);
setTimeout(() => { window.location.href = '/clusters/' + displayId + '/'; }, 1500);
}
if (s.status === 'failed') {
clearInterval(statusPoll);
+70 -12
View File
@@ -183,9 +183,9 @@ function buildDatasetTable(filter) {
const container = document.getElementById('datasetTable');
let rows = DATASETS_WITH_STATUS.filter(ds => {
if (filter === 'all') return true;
if (filter === 'ready') return ds.run_status === 'ready' || (!ds.run_status || ds.run_status === 'ready');
if (filter === 'completed') return ds.run_status === 'completed';
if (filter === 'failed') return ds.run_status === 'failed';
if (filter === 'ready') return ds.badge_class === 'ready';
if (filter === 'completed') return ds.badge_class === 'completed';
if (filter === 'failed') return ds.badge_class === 'failed';
return true;
});
@@ -197,9 +197,7 @@ function buildDatasetTable(filter) {
let html = '<table class="ds-table"><thead><tr><th>ID</th><th>状态</th><th>行数</th><th>文件数</th><th>SVD</th><th></th></tr></thead><tbody>';
rows.forEach(ds => {
const label = ds.status_label || '待分析';
const badgeClass = (ds.run_status === 'completed') ? 'completed' :
(ds.run_status === 'failed') ? 'failed' :
(ds.run_status && ds.run_status !== 'ready') ? 'analyzing' : 'ready';
const badgeClass = ds.badge_class || 'ready';
const dispId = ds.display_id || ds.dataset_id;
const rowCount = ds.row_count != null ? ds.row_count.toLocaleString() : '?';
const fileCount = ds.file_count != null ? ds.file_count : '?';
@@ -218,6 +216,58 @@ function buildDatasetTable(filter) {
}
function selectDataset(dsId) {
// Check if this dataset needs data reload
const dsInfo = DATASETS_WITH_STATUS.find(ds => ds.dataset_id === dsId);
if (dsInfo && dsInfo.needs_reload) {
// Trigger background reload
const row = document.querySelector(`#datasetTable tr[data-ds-id="${dsId}"]`);
if (row) {
row.style.opacity = '0.6';
const statusCell = row.querySelector('.status-badge');
if (statusCell) statusCell.textContent = '加载中...';
}
fetch('/tools/reload-run/', {
method: 'POST',
headers: { 'Content-Type':'application/json', 'X-CSRFToken':'{{ csrf_token }}' },
body: JSON.stringify({ display_id: dsInfo.display_id }),
})
.then(r => r.json())
.then(data => {
if (data.error) {
showError('加载数据集失败: ' + data.error);
if (row) row.style.opacity = '1';
return;
}
// Mark as no longer needing reload
dsInfo.needs_reload = false;
// Add to dropdown if not present
const sel = document.getElementById('datasetSelect');
if (!Array.from(sel.options).some(o => o.value === dsId)) {
const opt = document.createElement('option');
opt.value = dsId;
opt.text = dsId + ' (' + data.row_count + ' rows)';
sel.add(opt);
}
sel.value = dsId;
onDatasetChange();
// Update status badge
if (row) {
row.style.opacity = '1';
const badge = row.querySelector('.status-badge');
if (badge) {
badge.textContent = '待分析';
badge.className = 'status-badge ready';
}
}
showSuccess('数据集已加载: ' + dsId);
})
.catch(err => {
showError('加载数据集失败: ' + err.message);
if (row) row.style.opacity = '1';
});
return;
}
const sel = document.getElementById('datasetSelect');
let opt = Array.from(sel.options).find(o => o.value === dsId);
if (!opt) {
@@ -394,9 +444,13 @@ async function executeStep(idx) {
// Raw row-level clustering: skip entity aggregation entirely
if (step.tool.name === 'build_entity_profiles') {
step.status = 'done';
resultDiv.textContent = '[已跳过] 逐行直接聚类模式,无需实体聚合';
resultDiv.style.display = 'block';
step._resultText = '[已跳过] 逐行直接聚类模式,无需实体聚合';
renderSteps();
const skipResultDiv = document.getElementById(`result-${idx}`);
if (skipResultDiv) {
skipResultDiv.textContent = step._resultText;
skipResultDiv.style.display = 'block';
}
return;
}
// Always cluster raw rows directly
@@ -411,8 +465,7 @@ async function executeStep(idx) {
body: JSON.stringify({ tool: step.tool.name, params }),
});
const data = await resp.json();
resultDiv.textContent = JSON.stringify(data, null, 2);
resultDiv.style.display = 'block';
step._resultText = JSON.stringify(data, null, 2);
step.status = data.error ? 'failed' : 'done';
// update dataset select from result
if (data && data.result && data.result.dataset_id) {
@@ -425,12 +478,17 @@ async function executeStep(idx) {
}
}
} catch(err) {
resultDiv.textContent = 'Error: ' + err.message;
resultDiv.style.display = 'block';
step._resultText = 'Error: ' + err.message;
step.status = 'failed';
showError('步骤 ' + step.tool.name + ' 执行失败: ' + err.message);
}
renderSteps();
// Re-populate result content after renderSteps() recreates the DOM
const newResultDiv = document.getElementById(`result-${idx}`);
if (newResultDiv && step._resultText) {
newResultDiv.textContent = step._resultText;
newResultDiv.style.display = 'block';
}
}
// ── Run All (sequential) ──
+13 -6
View File
@@ -22,8 +22,8 @@ TOOLS = [
"description": "使用自适应权重学习在实体数据上计算自适应代理/异常/威胁评分。在build_entity_profiles之后调用。添加proxy_score/risk_level/threat_score列。",
"parameters": {"type": "object", "properties": {"dataset_id": {"type": "string"}}, "required": ["dataset_id"]}}},
{"type": "function", "function": {"name": "run_clustering",
"description": "将实体数据聚类为行为组。需要实体数据集ID。探索性分析选择hdbscan自动检测聚类数);固定簇数选择kmeans。",
"parameters": {"type": "object", "properties": {"dataset_id": {"type": "string"}, "cluster_columns": {"type": "array", "items": {"type": "string"}}, "algorithm": {"type": "string", "enum": ["hdbscan", "kmeans"]}}, "required": ["dataset_id", "cluster_columns"]}}},
"description": "将实体数据聚类为行为组。需要实体数据集ID。agglomerative(默认/凝聚层次聚类)适合预知簇数;hdbscan自动检测聚类数kmeans固定簇数",
"parameters": {"type": "object", "properties": {"dataset_id": {"type": "string"}, "cluster_columns": {"type": "array", "items": {"type": "string"}}, "algorithm": {"type": "string", "enum": ["agglomerative", "hdbscan", "kmeans"]}}, "required": ["dataset_id", "cluster_columns"]}}},
{"type": "function", "function": {"name": "extract_features",
"description": "识别每个聚类的区分特征(z-score或anova)。在run_clustering之后调用。",
"parameters": {"type": "object", "properties": {"dataset_id": {"type": "string"}, "cluster_result_id": {"type": "string"}, "method": {"type": "string", "enum": ["zscore", "anova"]}}, "required": ["dataset_id", "cluster_result_id"]}}},
@@ -363,13 +363,20 @@ def run_llm_pipeline(dataset_id: str, entity_col: str = "",
called_tools.add(tool_key)
logger.info(f'[TOOL] step={step} name={name} args={json.dumps(args)[:200]}')
if callback:
callback(step, name, None, meta={"args": args})
try:
result = asyncio.run(handle_call(name, args))
async def _call_with_timeout():
return await asyncio.wait_for(
handle_call(name, args),
timeout=120,
)
result = asyncio.run(_call_with_timeout())
except asyncio.TimeoutError:
result = {'error': f'Tool {name} timed out after 120s'}
logger.warning(f'[TOOL] {name} timed out')
except Exception as e:
raise SystemExit(f"MCP tool error: {e}") from e
result = {'error': f'MCP tool error: {e}'}
logger.error(f'[TOOL] {name} error: {e}')
if callback:
callback(step, name, result, meta={"args": args})
-1
View File
@@ -32,7 +32,6 @@ INSTALLED_APPS = [
"django.contrib.messages",
"django.contrib.staticfiles",
"analysis.apps.AnalysisConfig",
"simple_analysis.apps.SimpleAnalysisConfig",
]
MIDDLEWARE = [
+1 -1
View File
@@ -4,5 +4,5 @@ from django.urls import path, include
urlpatterns = [
path('admin/', admin.site.urls),
path('', include('analysis.urls')),
path('simple/', include('simple_analysis.urls')),
]