feat: add global and per-entity dynamic traffic flow animation

- backend: traffic_timeline_view + entity_traffic_timeline_view
  time-window aggregation (10s/30s/1min/5min/10min)
  Beijing time (UTC+8), per-edge count/bytes, node activity
- frontend: global traffic panel with play/pause/speed controls
  canvas overlay for curved edge flow particles + node dimming
  entity traffic view with 24h distribution + in/out chart.js
- routes: /simple/traffic-timeline/ + /simple/traffic-timeline/entity/

Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
TianXuan Developer
2026-07-24 22:35:46 +08:00
parent eb5758b77b
commit 82e3cd26ee
3 changed files with 645 additions and 1 deletions
@@ -258,6 +258,22 @@
letter-spacing: 1px;
}
/* ── Traffic flow animation ────────────────────────── */
.sa-traffic-panel { display:none; position:fixed; bottom:0; left:0; right:0;
background:rgba(15,18,25,0.95); color:#e0e0e0; z-index:2000;
border-top:2px solid #4361ee; padding:0.5rem 1rem; max-height:280px; overflow-y:auto; }
.sa-traffic-panel.open { display:block; }
.sa-traffic-controls { display:flex; align-items:center; gap:1rem; flex-wrap:wrap; }
.sa-traffic-controls button { background:#2a2d3a; color:#ccc; border:1px solid #444;
border-radius:4px; padding:0.3rem 0.7rem; cursor:pointer; font-size:0.8rem; }
.sa-traffic-controls button:hover { background:#4361ee; color:#fff; }
.sa-traffic-controls button.active { background:#4361ee; color:#fff; }
.sa-traffic-slider { flex:1; min-width:200px; }
.sa-traffic-slider input { width:100%; }
.sa-traffic-time { font-size:0.8rem; color:#888; min-width:60px; text-align:center; }
.sa-traffic-info { font-size:0.75rem; color:#999; margin-top:0.3rem; }
.sa-traffic-canvas { position:absolute; top:0; left:0; pointer-events:none; z-index:1000; }
/* ── Responsive ──────────────────────────────────── */
@media (max-width: 900px) {
.sa-map-row { flex-direction: column; }
@@ -486,6 +502,7 @@
<div style="margin:0.75rem 0;text-align:center;">
<button class="btn" onclick="exportClusterCSV()">📥 导出点表 (CSV)</button>
<button class="btn" style="margin-left:0.5rem;" onclick="exportEdgesCSV()">📤 导出边表 (CSV)</button>
<button class="btn btn-primary" style="margin-left:0.5rem;" onclick="toggleTrafficPanel()">🌊 动态流通</button>
<button class="btn" style="margin-left:0.5rem;" onclick="resetAll()">🔄 重新开始</button>
</div>
@@ -1725,6 +1742,11 @@ function showNodeDetailPanel(data) {
edgeHtml;
setText('saDetailTitle', `节点: ${n.ip}`);
// Add traffic view button
const titleEl = document.getElementById('saDetailTitle');
if (titleEl) {
titleEl.innerHTML += ` <button class=\"btn\" style=\"font-size:0.7rem;padding:0.15rem 0.5rem;margin-left:0.5rem;\" onclick=\"showEntityTrafficView('${escapeHtml(n.ip)}')\">📡 动态流量</button>`;
}
panel.classList.add('open');
// Store edges for map highlighting
@@ -1907,7 +1929,10 @@ function loadEdgeDetail(e) {
if (SA._edgeTimeChart) { SA._edgeTimeChart.destroy(); SA._edgeTimeChart = null; }
setText('saDetailTitle', `边详情`);
// Convert timestamps to Beijing time if possible
const titleEl2 = document.getElementById('saDetailTitle');
if (titleEl2) {
titleEl2.innerHTML += ` <button class=\"btn\" style=\"font-size:0.7rem;padding:0.15rem 0.5rem;margin-left:0.5rem;\" onclick=\"showEntityTrafficView('${escapeHtml(e.source)}')\">📡 源IP流量</button>`;
}
const toBeijing = (ts) => {
if (!ts) return '—';
try {
@@ -2540,10 +2565,309 @@ function generateColors(n) {
return Array.from({length: n}, (_, i) => palette[i % palette.length]);
}
/* ── Traffic Flow Animation ─────────────────────────── */
let _trafficData = null;
let _trafficAnimId = null;
let _trafficPlaying = false;
let _trafficSpeed = 1;
let _trafficWindowIdx = 0;
let _trafficParticles = [];
let _trafficCanvasCtx = null;
function toggleTrafficPanel() {
const panel = document.getElementById('saTrafficPanel');
if (!panel) return;
if (panel.classList.contains('open')) {
trafficStop();
panel.classList.remove('open');
// Remove canvas layer
const c = document.getElementById('saTrafficCanvas');
if (c) c.style.display = 'none';
if (_trafficCanvasCtx) { _trafficCanvasCtx.clearRect(0,0,c.width,c.height); }
} else {
panel.classList.add('open');
loadTrafficData();
}
}
function loadTrafficData() {
if (!SA.sessionId) { showError('请先完成聚类分析'); return; }
const ws = parseInt(document.getElementById('saTrafficWindow')?.value || 60);
showLoading('加载流通数据...');
fetch('/simple/traffic-timeline/', {
method: 'POST',
headers: {'Content-Type': 'application/json', 'X-CSRFToken': getCSRF()},
body: JSON.stringify({session_id: SA.sessionId, window_seconds: ws, max_edges: 300}),
})
.then(r => r.json())
.then(d => {
hideLoading();
if (d.error) { showError(d.error); return; }
_trafficData = d;
_trafficWindowIdx = 0;
const slider = document.getElementById('saTrafficSlider');
slider.max = Math.max(0, d.n_windows - 1);
slider.value = 0;
document.getElementById('saTrafficInfo').textContent =
`${d.n_windows} 个窗口 · ${ws}s/窗口 · ${Object.keys(d.nodes_coords||{}).length} 节点有坐标`;
trafficSeek(0);
trafficStart();
})
.catch(err => { hideLoading(); showError('加载失败: ' + err.message); });
}
function reloadTrafficData() {
trafficStop();
loadTrafficData();
}
function trafficSeek(idx) {
idx = parseInt(idx);
if (!_trafficData) return;
_trafficWindowIdx = Math.max(0, Math.min(idx, _trafficData.n_windows - 1));
document.getElementById('saTrafficSlider').value = _trafficWindowIdx;
const w = _trafficData.windows[_trafficWindowIdx];
if (w) {
document.getElementById('saTrafficTime').textContent = w.t_label;
renderTrafficFrame(w);
}
}
function trafficStepForward() {
if (_trafficData) trafficSeek(_trafficWindowIdx + 1);
}
function trafficStepBack() {
if (_trafficData) trafficSeek(_trafficWindowIdx - 1);
}
function trafficTogglePlay() {
if (_trafficPlaying) trafficPause(); else trafficStart();
}
function trafficStart() {
if (!_trafficData || _trafficPlaying) return;
_trafficPlaying = true;
document.getElementById('saTrafficPlayBtn').textContent = '⏸ 暂停';
trafficAnimate();
}
function trafficPause() {
_trafficPlaying = false;
document.getElementById('saTrafficPlayBtn').textContent = '▶ 播放';
if (_trafficAnimId) { cancelAnimationFrame(_trafficAnimId); _trafficAnimId = null; }
}
function trafficStop() {
trafficPause();
_trafficData = null;
_trafficWindowIdx = 0;
const c = document.getElementById('saTrafficCanvas');
if (c && _trafficCanvasCtx) _trafficCanvasCtx.clearRect(0, 0, c.width, c.height);
document.getElementById('saTrafficTime').textContent = '--:--:--';
document.getElementById('saTrafficInfo').textContent = '';
}
function trafficSetSpeed(speed, btn) {
_trafficSpeed = speed;
document.querySelectorAll('#saTrafficPanel button[data-speed]').forEach(b => b.classList.remove('active'));
if (btn) btn.classList.add('active');
}
function trafficAnimate() {
if (!_trafficPlaying || !_trafficData) return;
renderTrafficFrame(_trafficData.windows[_trafficWindowIdx]);
const delay = _trafficData.window_seconds * 1000 / _trafficSpeed;
_trafficAnimId = setTimeout(() => {
_trafficWindowIdx++;
if (_trafficWindowIdx >= _trafficData.n_windows) _trafficWindowIdx = 0;
document.getElementById('saTrafficSlider').value = _trafficWindowIdx;
trafficAnimate();
}, Math.max(100, Math.min(delay, 2000)));
}
function renderTrafficFrame(w) {
if (!w || !SA.map) return;
document.getElementById('saTrafficTime').textContent = w.t_label;
document.getElementById('saTrafficInfo').textContent =
`${w.edges.length} 条活跃边 · ${w.active_nodes.length} 活跃节点`;
// Setup canvas
const container = document.getElementById('saMap');
const c = document.getElementById('saTrafficCanvas');
if (!c || !container) return;
c.style.display = 'block';
const rect = container.getBoundingClientRect();
c.width = rect.width;
c.height = rect.height;
c.style.width = rect.width + 'px';
c.style.height = rect.height + 'px';
c.style.left = rect.left + 'px';
c.style.top = rect.top + 'px';
_trafficCanvasCtx = c.getContext('2d');
_trafficCanvasCtx.clearRect(0, 0, c.width, c.height);
const coords = _trafficData.nodes_coords || {};
const maxCount = Math.max(1, ...w.edges.map(e => e.count));
w.edges.forEach(e => {
const srcC = coords[e.source];
const dstC = coords[e.target];
if (!srcC || !dstC) return;
const p1 = SA.map.latLngToContainerPoint([srcC[0], srcC[1]]);
const p2 = SA.map.latLngToContainerPoint([dstC[0], dstC[1]]);
if (!p1 || !p2) return;
const alpha = 0.2 + 0.6 * (e.count / maxCount);
const width = 1 + 3 * (e.count / maxCount);
// Draw curved edge
const midX = (p1.x + p2.x) / 2;
const midY = (p1.y + p2.y) / 2 - Math.abs(p2.x - p1.x) * 0.15;
_trafficCanvasCtx.beginPath();
_trafficCanvasCtx.moveTo(p1.x, p1.y);
_trafficCanvasCtx.quadraticCurveTo(midX, midY, p2.x, p2.y);
_trafficCanvasCtx.strokeStyle = `rgba(67,97,238,${alpha.toFixed(2)})`;
_trafficCanvasCtx.lineWidth = width;
_trafficCanvasCtx.stroke();
// Draw flow particle
const phase = (_trafficWindowIdx % 10) / 10;
const t = 0.15 + phase * 0.7;
const px = (1-t)*(1-t)*p1.x + 2*(1-t)*t*midX + t*t*p2.x;
const py = (1-t)*(1-t)*p1.y + 2*(1-t)*t*midY + t*t*p2.y;
_trafficCanvasCtx.beginPath();
_trafficCanvasCtx.arc(px, py, 2 + 2*(e.count/maxCount), 0, Math.PI*2);
_trafficCanvasCtx.fillStyle = '#e63946';
_trafficCanvasCtx.fill();
});
// Highlight active nodes
const activeSet = new Set(w.active_nodes || []);
if (SA.pointLayer && !SA._clusterFocus) {
SA.pointLayer.getLayers().forEach(l => {
const ip = l._tooltip?.getContent?.()?.match(/\b(\d+\.\d+\.\d+\.\d+)\b/)?.[1];
if (ip && l.setStyle) {
if (activeSet.has(ip)) {
l.setStyle({fillOpacity: 0.95, opacity: 1, weight: 3, color: '#fff'});
} else {
l.setStyle({fillOpacity: 0.25, opacity: 0.35, weight: 1, color: '#ddd'});
}
}
});
}
}
/* ── Entity traffic view in detail panel ────────────── */
function showEntityTrafficView(ip) {
const panel = document.getElementById('saDetailPanel');
const body = document.getElementById('saDetailBody');
if (!panel || !body) return;
setText('saDetailTitle', `📡 动态流量: ${ip}`);
showLoading('加载实体流量数据...');
fetch('/simple/traffic-timeline/entity/', {
method: 'POST',
headers: {'Content-Type': 'application/json', 'X-CSRFToken': getCSRF()},
body: JSON.stringify({session_id: SA.sessionId, entity_ip: ip, window_seconds: 60}),
})
.then(r => r.json())
.then(d => {
hideLoading();
if (d.error) { showError(d.error); return; }
renderEntityTrafficPanel(ip, d);
})
.catch(err => { hideLoading(); showError(err.message); });
}
function renderEntityTrafficPanel(ip, data) {
const body = document.getElementById('saDetailBody');
if (!body) return;
if (SA._edgeTimeChart) { SA._edgeTimeChart.destroy(); SA._edgeTimeChart = null; }
const distHtml = (data.hourly_distribution || []).map(h =>
`<div style="display:inline-block;width:${Math.max(1,h.count/Math.max(...data.hourly_distribution.map(x=>x.count))*100)}%;height:12px;background:#4361ee;margin-right:1px;" title="${h.hour}时: ${h.count}次"></div>`
).join('');
body.innerHTML =
`<div class="stat-row">
<div class="stat-card"><div class="num">${data.n_peers||0}</div><div class="lbl">关联对端</div></div>
<div class="stat-card"><div class="num">${data.total_count||0}</div><div class="lbl">总通信次数</div></div>
<div class="stat-card"><div class="num">${(data.total_bytes/1024).toFixed(1)} KB</div><div class="lbl">总流量</div></div>
</div>
<div class="section-title">📊 24小时活跃分布 (北京时间)</div>
<div style="display:flex;align-items:flex-end;height:30px;gap:1px;">${distHtml}</div>
<div style="display:flex;justify-content:space-between;font-size:0.6rem;color:#999;margin-top:0.2rem;">
<span>0时</span><span>6时</span><span>12时</span><span>18时</span><span>23时</span></div>
<div class="section-title">🕐 逐窗口流入/流出</div>
<div style="max-height:180px;overflow-y:auto;font-size:0.75rem;">
<canvas id="saEntityTrafficChart" height="160"></canvas>
</div>
<div class="section-title">🌐 对端IP (${data.n_peers||0})</div>
<div>${(data.peers||[]).slice(0,30).map(p => `<span class="sa-tag" style="cursor:pointer;color:#4361ee;margin:0.1rem;" onclick="loadNodeDetail('${escapeHtml(p)}')">${escapeHtml(p)}</span>`).join(' ')}</div>`;
document.getElementById('saDetailPanel').classList.add('open');
// Render chart
const ctx = document.getElementById('saEntityTrafficChart');
if (!ctx) return;
const wins = data.windows || [];
const labels = wins.map(w => w.t_label);
const outData = wins.map(w => w.out_bytes / 1024);
const inData = wins.map(w => w.in_bytes / 1024);
SA._edgeTimeChart = new Chart(ctx.getContext('2d'), {
type: 'bar',
data: {
labels,
datasets: [
{label: '流出 KB', data: outData, backgroundColor: '#e63946', barPercentage: 0.8},
{label: '流入 KB', data: inData, backgroundColor: '#4361ee', barPercentage: 0.8},
]
},
options: {
responsive: true, maintainAspectRatio: false,
plugins: { legend: { labels: { font: {size: 10}, boxWidth: 10 } } },
scales: {
x: { ticks: { font: {size: 8}, maxTicksLimit: 15, maxRotation: 45 } },
y: { beginAtZero: true, ticks: { font: {size: 9} } }
}
}
});
}
function escapeHtml(str) {
if (str == null) return '';
return String(str).replace(/&/g,'&amp;').replace(/</g,'&lt;').replace(/>/g,'&gt;').replace(/"/g,'&quot;');
}
<!-- Traffic Flow Panel -->
<div class="sa-traffic-panel" id="saTrafficPanel">
<div style="display:flex;justify-content:space-between;align-items:center;margin-bottom:0.5rem;">
<b style="font-size:0.9rem;">🌊 动态流通 (北京时间)</b>
<div style="display:flex;gap:0.5rem;align-items:center;">
<span style="font-size:0.75rem;">窗口:</span>
<select id="saTrafficWindow" onchange="reloadTrafficData()" style="background:#2a2d3a;color:#ccc;border:1px solid #444;border-radius:3px;padding:0.15rem 0.3rem;font-size:0.75rem;">
<option value="10">10s</option><option value="30">30s</option><option value="60" selected>1min</option><option value="300">5min</option><option value="600">10min</option>
</select>
<button onclick="toggleTrafficPanel()" style="background:transparent;color:#888;border:none;font-size:1.2rem;cursor:pointer;"></button>
</div>
</div>
<div class="sa-traffic-controls">
<button onclick="trafficStepBack()" title="后退"></button>
<button id="saTrafficPlayBtn" onclick="trafficTogglePlay()"> 播放</button>
<button onclick="trafficStepForward()" title="前进"></button>
<span style="font-size:0.75rem;">速度:</span>
<button data-speed="0.5" onclick="trafficSetSpeed(0.5,this)">0.5x</button>
<button data-speed="1" onclick="trafficSetSpeed(1,this)" class="active">1x</button>
<button data-speed="2" onclick="trafficSetSpeed(2,this)">2x</button>
<button data-speed="4" onclick="trafficSetSpeed(4,this)">4x</button>
<button data-speed="8" onclick="trafficSetSpeed(8,this)">8x</button>
<div class="sa-traffic-slider">
<input type="range" id="saTrafficSlider" min="0" max="0" value="0" oninput="trafficSeek(this.value)">
</div>
<span class="sa-traffic-time" id="saTrafficTime">--:--:--</span>
</div>
<div class="sa-traffic-info" id="saTrafficInfo"></div>
<canvas id="saTrafficCanvas" class="sa-traffic-canvas" style="display:none;"></canvas>
</div>
</script>
{% endblock %}
+2
View File
@@ -16,4 +16,6 @@ urlpatterns = [
path('edges/', views.edge_relationships, name='edges'),
path('edge-time-series/', views.edge_time_series, name='edge_time_series'),
path('cluster-nodes-edges/', views.cluster_nodes_edges, name='cluster_nodes_edges'),
path('traffic-timeline/', views.traffic_timeline_view, name='traffic_timeline'),
path('traffic-timeline/entity/', views.entity_traffic_timeline_view, name='entity_traffic_timeline'),
]
+318
View File
@@ -2097,6 +2097,324 @@ def geoip_status(request):
})
# ═══════════════════════════════════════════════════════════════════════════════
# Traffic timeline — dynamic flow animation
# ═══════════════════════════════════════════════════════════════════════════════
@csrf_exempt
@require_POST
def traffic_timeline_view(request):
"""Return time-windowed traffic data for global flow animation.
POST body: {session_id, window_seconds (10|30|60|300|600), max_edges (default 500)}
Returns per-window edge activity and node activity.
"""
body = json.loads(request.body)
session_id = body.get('session_id')
window_sec = int(body.get('window_seconds', 60))
max_edges = int(body.get('max_edges', 500))
if not session_id:
return JsonResponse({'error': '缺少 session_id'}, status=400)
if window_sec not in (10, 30, 60, 300, 600):
window_sec = 60
entry = _cached(session_id)
if entry is None:
return JsonResponse({'error': '会话已过期'}, status=400)
df = entry.get('filtered_df') if entry.get('filtered_df') is not None else entry.get('df')
if df is None:
return JsonResponse({'error': '数据未就绪'}, status=400)
meta = entry.get('meta', {})
ips_col = meta.get('ips_col')
ipd_col = meta.get('ipd_col')
time_col = meta.get('time_col')
bytes_col = meta.get('bytes_col')
if not ips_col or not ipd_col or not time_col:
return JsonResponse({'error': '缺少IP或时间列'}, status=400)
try:
# Extract columns
src_list = df[ips_col].cast(pl.Utf8).to_list()
dst_list = df[ipd_col].cast(pl.Utf8).to_list()
time_list = df[time_col].cast(pl.Utf8).to_list()
bytes_raw = None
if bytes_col and bytes_col in df.columns:
try:
bytes_raw = df[bytes_col].cast(pl.Float64).to_list()
except Exception:
pass
# Parse timestamps → Beijing time Unix seconds
import calendar as cal_mod
bj_timestamps = []
for t_str in time_list:
try:
t_clean = str(t_str).strip() if t_str else ''
if not t_clean or t_clean in ('None', '', 'nan'):
continue
# Try ISO format or 'YYYY-MM-DD HH:MM:SS' format
for fmt in ['%Y-%m-%dT%H:%M:%S', '%Y-%m-%d %H:%M:%S',
'%Y-%m-%dT%H:%M:%S.%f', '%Y-%m-%d %H:%M:%S.%f']:
try:
parsed = time.strptime(t_clean[:26], fmt)
bj_ts = int(cal_mod.timegm(parsed)) + 8 * 3600
bj_timestamps.append(bj_ts)
break
except ValueError:
continue
except Exception:
continue
if not bj_timestamps:
return JsonResponse({'error': '无法解析时间戳'}, status=400)
min_ts = min(bj_timestamps)
max_ts = max(bj_timestamps)
# Build time windows
windows = []
t = min_ts
while t <= max_ts:
windows.append(t)
t += window_sec
# Aggregate per window
window_data = []
for wi, w_start in enumerate(windows):
w_end = w_start + window_sec
# Find rows in this window
edge_map = {} # (src,dst) → {count, bytes}
active_nodes = set()
row_count = 0
for idx, ts in enumerate(bj_timestamps):
if wi < len(windows) - 1 and ts >= w_end:
continue
if wi > 0 and ts < w_start:
continue
# In-window row
s = str(src_list[idx]) if idx < len(src_list) and src_list[idx] not in (None, '', 'None') else '?'
d = str(dst_list[idx]) if idx < len(dst_list) and dst_list[idx] not in (None, '', 'None') else '?'
key = (s, d)
if key not in edge_map:
edge_map[key] = {'count': 0, 'bytes': 0.0}
edge_map[key]['count'] += 1
if bytes_raw and idx < len(bytes_raw):
try:
edge_map[key]['bytes'] += float(bytes_raw[idx] or 0)
except Exception:
pass
active_nodes.add(s)
active_nodes.add(d)
row_count += 1
# Sort edges by activity
edges = []
for (src, dst), v in sorted(edge_map.items(), key=lambda x: -x[1]['count'])[:max_edges]:
edges.append({
'source': src,
'target': dst,
'count': v['count'],
'bytes': round(v['bytes'], 2),
})
# Node activity
node_activity = {}
nodes_data = entry.get('nodes', [])
for n in nodes_data:
ip = n.get('ip', '')
if ip in active_nodes:
node_activity[ip] = {'active': True}
if edges or active_nodes:
window_data.append({
'ts': w_start,
'ts_end': w_end,
't_label': time.strftime('%H:%M:%S', time.gmtime(w_start)),
'edges': edges,
'active_nodes': list(active_nodes)[:1000],
'row_count': row_count,
})
# Node coordinates for map
nodes_coords = {}
nodes_data = entry.get('nodes', [])
for n in nodes_data:
if n.get('lat') is not None and n.get('lon') is not None:
nodes_coords[n['ip']] = [n['lat'], n['lon']]
return JsonResponse({
'windows': window_data,
'window_seconds': window_sec,
'min_ts': min_ts,
'max_ts': max_ts,
'n_windows': len(window_data),
'nodes_coords': nodes_coords,
})
except Exception as e:
return JsonResponse({'error': f'时间线构建失败: {e}'}, status=400)
@csrf_exempt
@require_POST
def entity_traffic_timeline_view(request):
"""Return per-entity traffic timeline for local flow animation.
POST body: {session_id, entity_ip, window_seconds}
Returns timeline focused on one IP's related edges.
"""
body = json.loads(request.body)
session_id = body.get('session_id')
entity_ip = body.get('entity_ip', '')
window_sec = int(body.get('window_seconds', 60))
if not session_id or not entity_ip:
return JsonResponse({'error': '缺少 session_id / entity_ip'}, status=400)
if window_sec not in (10, 30, 60, 300, 600):
window_sec = 60
entry = _cached(session_id)
if entry is None:
return JsonResponse({'error': '会话已过期'}, status=400)
df = entry.get('filtered_df') if entry.get('filtered_df') is not None else entry.get('df')
if df is None:
return JsonResponse({'error': '数据未就绪'}, status=400)
meta = entry.get('meta', {})
ips_col = meta.get('ips_col')
ipd_col = meta.get('ipd_col')
time_col = meta.get('time_col')
bytes_col = meta.get('bytes_col')
if not ips_col or not ipd_col or not time_col:
return JsonResponse({'error': '缺少IP或时间列'}, status=400)
try:
# Filter rows where entity_ip is src OR dst
src_list = df[ips_col].cast(pl.Utf8).to_list()
dst_list = df[ipd_col].cast(pl.Utf8).to_list()
time_list = df[time_col].cast(pl.Utf8).to_list()
bytes_raw = None
if bytes_col and bytes_col in df.columns:
try:
bytes_raw = df[bytes_col].cast(pl.Float64).to_list()
except Exception:
pass
# Parse timestamps for filtered rows
import calendar as cal_mod
filtered_data = [] # [(ts_bj, src, dst, bytes_val)]
for idx in range(len(df)):
s = str(src_list[idx]) if src_list[idx] not in (None, '', 'None') else ''
d = str(dst_list[idx]) if dst_list[idx] not in (None, '', 'None') else ''
if entity_ip not in (s, d):
continue
t_str = str(time_list[idx]) if time_list[idx] else ''
if not t_str or t_str in ('None', '', 'nan'):
continue
bj_ts = None
for fmt in ['%Y-%m-%dT%H:%M:%S', '%Y-%m-%d %H:%M:%S',
'%Y-%m-%dT%H:%M:%S.%f', '%Y-%m-%d %H:%M:%S.%f']:
try:
parsed = time.strptime(t_str[:26], fmt)
bj_ts = int(cal_mod.timegm(parsed)) + 8 * 3600
break
except ValueError:
continue
if bj_ts is None:
continue
bv = 0.0
if bytes_raw and idx < len(bytes_raw):
try:
bv = float(bytes_raw[idx] or 0)
except Exception:
pass
filtered_data.append((bj_ts, s, d, bv))
if not filtered_data:
return JsonResponse({'error': f'IP {entity_ip} 无时序数据'}, status=400)
min_ts = min(d[0] for d in filtered_data)
max_ts = max(d[0] for d in filtered_data)
# Build windows
windows = []
t = min_ts
while t <= max_ts:
windows.append(t)
t += window_sec
window_data = []
peer_set = set()
total_bytes = 0.0
total_count = 0
for wi, w_start in enumerate(windows):
w_end = w_start + window_sec
edge_map = {}
out_bytes = 0.0
in_bytes = 0.0
for (ts, s, d, bv) in filtered_data:
if wi < len(windows) - 1 and ts >= w_end:
continue
if wi > 0 and ts < w_start:
continue
key = (s, d)
if key not in edge_map:
edge_map[key] = {'count': 0, 'bytes': 0.0}
edge_map[key]['count'] += 1
edge_map[key]['bytes'] += bv
if s == entity_ip:
out_bytes += bv
if d == entity_ip:
in_bytes += bv
peer = d if s == entity_ip else s
peer_set.add(peer)
total_bytes += bv
total_count += 1
edges = []
for (src, dst), v in sorted(edge_map.items(), key=lambda x: -x[1]['count'])[:50]:
edges.append({
'source': src, 'target': dst,
'count': v['count'], 'bytes': round(v['bytes'], 2),
})
if edges:
window_data.append({
'ts': w_start,
't_label': time.strftime('%H:%M:%S', time.gmtime(w_start)),
'edges': edges,
'out_bytes': round(out_bytes, 2),
'in_bytes': round(in_bytes, 2),
})
# Aggregate stats
hour_counts = [0] * 24
for (ts, s, d, bv) in filtered_data:
tm = time.gmtime(ts)
hour_counts[tm.tm_hour] += 1
return JsonResponse({
'entity_ip': entity_ip,
'windows': window_data,
'window_seconds': window_sec,
'min_ts': min_ts,
'max_ts': max_ts,
'n_windows': len(window_data),
'n_peers': len(peer_set),
'total_bytes': round(total_bytes, 2),
'total_count': total_count,
'peers': sorted(peer_set)[:50],
'hourly_distribution': [{'hour': h, 'count': c} for h, c in enumerate(hour_counts)],
})
except Exception as e:
return JsonResponse({'error': f'实体时间线构建失败: {e}'}, status=400)
# ── Main page ────────────────────────────────────────────────────────────────
def index(request):