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:
@@ -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,'&').replace(/</g,'<').replace(/>/g,'>').replace(/"/g,'"');
|
||||
}
|
||||
|
||||
<!-- 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 %}
|
||||
|
||||
@@ -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'),
|
||||
]
|
||||
|
||||
@@ -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):
|
||||
|
||||
Reference in New Issue
Block a user