Carte flux visuelle SVG: noeuds, liens verts, pan/zoom, tooltip, recherche

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
Khalid MOUTAOUAKIL 2026-04-06 16:44:20 +02:00
parent 0a309ea4f7
commit 532ecf7e3d

View File

@ -2,69 +2,262 @@
{% block title %}Carte flux{% endblock %}
{% block content %}
<a href="/audit-full" class="text-xs text-gray-500 hover:text-gray-300">< Retour</a>
<h2 class="text-xl font-bold text-cyber-accent mb-4">Carte des flux reseau</h2>
{% if flows %}
<div class="card overflow-x-auto mb-4">
<div class="p-2 border-b border-cyber-border">
<span class="text-xs font-bold text-cyber-accent">{{ flows|length }} flux inter-serveurs</span>
<div class="flex justify-between items-center mb-4">
<h2 class="text-xl font-bold text-cyber-accent">Carte des flux reseau</h2>
<div class="flex gap-2 items-center">
<select id="filter-domain" class="text-xs py-1 px-2" onchange="filterGraph()">
<option value="">Tous</option>
{% for d in all_domains %}<option value="{{ d.name }}">{{ d.name }}</option>{% endfor %}
</select>
<input type="text" id="filter-search" placeholder="Rechercher..." class="text-xs py-1 px-2 w-40 font-mono" oninput="filterGraph()">
<button onclick="resetZoom()" class="btn-sm bg-cyber-border text-gray-400 px-2 py-1 text-xs">Reset vue</button>
</div>
</div>
<!-- Map SVG -->
<div class="card" style="position:relative;overflow:hidden;height:700px;background:#0a0e17;" id="map-container">
<svg id="flow-svg" width="100%" height="100%" style="cursor:grab;">
<defs>
<marker id="arrow" viewBox="0 0 10 6" refX="10" refY="3" markerWidth="8" markerHeight="6" orient="auto">
<path d="M0,0 L10,3 L0,6 Z" fill="#22c55e" opacity="0.6"/>
</marker>
</defs>
<g id="svg-root">
<g id="links-layer"></g>
<g id="nodes-layer"></g>
</g>
</svg>
<div id="tooltip" style="display:none;position:absolute;background:#1a1f2e;border:1px solid #22c55e;padding:6px 10px;border-radius:4px;font-size:11px;color:#e2e8f0;pointer-events:none;z-index:10;max-width:300px;"></div>
</div>
<!-- Legende -->
<div class="flex gap-4 mt-2 text-xs text-gray-500">
<span><span style="color:#22c55e;">---></span> Flux reseau</span>
<span id="stats-nodes">0 serveurs</span>
<span id="stats-links">0 flux</span>
</div>
<!-- Tableau flux en dessous -->
{% if flows %}
<details class="card mt-4">
<summary class="p-3 cursor-pointer text-sm text-gray-400">Tableau des flux ({{ flows|length }})</summary>
<div class="overflow-x-auto">
<table class="w-full table-cyber text-xs">
<thead><tr>
<th class="p-2">Dir</th>
<th class="text-left p-2">Source</th>
<th class="p-2">IP source</th>
<th class="text-left p-2">Destination</th>
<th class="p-2">IP dest</th>
<th class="p-2">Port</th>
<th class="p-2">Process</th>
<th class="p-2">State</th>
<th class="p-2">Nb</th>
<th class="p-2">Dir</th><th class="text-left p-2">Source</th><th class="text-left p-2">Destination</th>
<th class="p-2">Port</th><th class="p-2">Process</th><th class="p-2">State</th>
</tr></thead>
<tbody>
{% for f in flows %}
<tr class="{% if f.state == 'CLOSE-WAIT' %}bg-red-900/10{% endif %}">
<td class="p-2 text-center"><span class="badge {% if f.direction == 'IN' %}badge-green{% else %}badge-yellow{% endif %}">{{ f.direction }}</span></td>
<td class="p-2 font-mono text-cyber-accent">{{ f.source_hostname }}</td>
<td class="p-2 font-mono text-gray-500 text-center">{{ f.source_ip }}</td>
<td class="p-2 font-mono {% if f.dest_hostname %}text-cyber-accent{% else %}text-gray-400{% endif %}">{{ f.dest_hostname or '-' }}</td>
<td class="p-2 font-mono text-gray-500 text-center">{{ f.dest_ip }}</td>
<td class="p-2 font-mono {% if f.dest_hostname %}text-cyber-accent{% else %}text-gray-400{% endif %}">{{ f.dest_hostname or f.dest_ip }}</td>
<td class="p-2 text-center font-bold">{{ f.dest_port }}</td>
<td class="p-2 text-center">{{ f.process_name }}</td>
<td class="p-2 text-center"><span class="badge {% if f.state == 'ESTAB' %}badge-green{% elif f.state == 'CLOSE-WAIT' %}badge-red{% else %}badge-gray{% endif %}">{{ f.state }}</span></td>
<td class="p-2 text-center">{{ f.cnt }}</td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
{% else %}
<div class="card p-8 text-center text-gray-500">Aucun flux. Importez des rapports JSON d'abord.</div>
</div>
</details>
{% endif %}
{% if app_map %}
<h3 class="text-lg font-bold text-cyber-accent mb-3">Carte applicative</h3>
<div class="grid grid-cols-2 gap-3">
<details class="card mt-4">
<summary class="p-3 cursor-pointer text-sm text-gray-400">Carte applicative ({{ app_map|length }} applis)</summary>
<div class="grid grid-cols-2 gap-3 p-3">
{% for app_name, app in app_map.items() %}
<div class="card p-3">
<div class="flex justify-between items-center mb-2">
<span class="text-sm font-bold text-cyber-yellow">{{ app_name }}</span>
<span class="badge badge-blue">{{ app.servers|length }} serveur(s)</span>
</div>
{% if app.ports %}
<div class="text-xs text-gray-500 mb-2">Ports: {% for p in app.ports %}<span class="font-mono text-cyber-accent">{{ p }}</span>{% if not loop.last %}, {% endif %}{% endfor %}</div>
{% endif %}
<div class="space-y-1">
{% for s in app.servers %}
<div class="text-xs font-mono">
<span class="text-cyber-accent">{{ s.hostname }}</span>
<span class="text-gray-500">user={{ s.user }}</span>
<span class="text-gray-600">{{ s.cmdline[:50] }}</span>
</div>
{% endfor %}
</div>
<span class="text-sm font-bold text-cyber-yellow">{{ app_name }}</span>
<span class="badge badge-blue ml-2">{{ app.servers|length }}</span>
{% if app.ports %}<span class="text-xs text-gray-500 ml-2">ports: {{ app.ports|join(', ') }}</span>{% endif %}
<div class="mt-1">{% for s in app.servers %}<span class="text-xs font-mono text-cyber-accent">{{ s.hostname }}</span>{% if not loop.last %}, {% endif %}{% endfor %}</div>
</div>
{% endfor %}
</div>
</div>
</details>
{% endif %}
<script>
// Donnees flux depuis le serveur — uniquement inter-serveurs (dest_hostname connu), pas de self-loop, dedupliques
var rawFlows = [
{% for f in flows %}{% if f.dest_hostname and f.source_hostname != f.dest_hostname %}
{src:"{{f.source_hostname}}",dst:"{{f.dest_hostname}}",port:{{f.dest_port}},proc:"{{f.process_name}}",state:"{{f.state}}",cnt:{{f.cnt}}},
{% endif %}{% endfor %}
];
// Deduplication: une seule fleche par paire src->dst (agglomerer les ports)
var linkMap = {};
rawFlows.forEach(function(f) {
var key = f.src + ">" + f.dst;
if (!linkMap[key]) {
linkMap[key] = {src: f.src, dst: f.dst, ports: [], procs: [], cnt: 0};
}
if (linkMap[key].ports.indexOf(f.port) === -1) linkMap[key].ports.push(f.port);
if (f.proc && linkMap[key].procs.indexOf(f.proc) === -1) linkMap[key].procs.push(f.proc);
linkMap[key].cnt += f.cnt;
});
var links = Object.values(linkMap);
// Noeuds uniques
var nodeSet = {};
links.forEach(function(l) { nodeSet[l.src] = true; nodeSet[l.dst] = true; });
var nodes = Object.keys(nodeSet).map(function(name, i) {
return {id: name, x: 400 + Math.random() * 600, y: 200 + Math.random() * 400, vx: 0, vy: 0};
});
var nodeIdx = {};
nodes.forEach(function(n, i) { nodeIdx[n.id] = i; });
document.getElementById("stats-nodes").textContent = nodes.length + " serveurs";
document.getElementById("stats-links").textContent = links.length + " flux";
// Force-directed layout
var W = document.getElementById("map-container").clientWidth;
var H = 700;
var repulsion = 800;
var attraction = 0.005;
var damping = 0.85;
var iterations = 300;
for (var iter = 0; iter < iterations; iter++) {
// Repulsion entre noeuds
for (var i = 0; i < nodes.length; i++) {
for (var j = i + 1; j < nodes.length; j++) {
var dx = nodes[i].x - nodes[j].x;
var dy = nodes[i].y - nodes[j].y;
var dist = Math.sqrt(dx * dx + dy * dy) || 1;
var force = repulsion / (dist * dist);
var fx = dx / dist * force;
var fy = dy / dist * force;
nodes[i].vx += fx; nodes[i].vy += fy;
nodes[j].vx -= fx; nodes[j].vy -= fy;
}
}
// Attraction via liens
links.forEach(function(l) {
var a = nodes[nodeIdx[l.src]], b = nodes[nodeIdx[l.dst]];
if (!a || !b) return;
var dx = b.x - a.x, dy = b.y - a.y;
var dist = Math.sqrt(dx * dx + dy * dy) || 1;
var force = dist * attraction;
a.vx += dx / dist * force; a.vy += dy / dist * force;
b.vx -= dx / dist * force; b.vy -= dy / dist * force;
});
// Appliquer + damping
nodes.forEach(function(n) {
n.x += n.vx; n.y += n.vy;
n.vx *= damping; n.vy *= damping;
n.x = Math.max(60, Math.min(W - 60, n.x));
n.y = Math.max(40, Math.min(H - 40, n.y));
});
}
// Rendu SVG
var svgRoot = document.getElementById("svg-root");
var linksLayer = document.getElementById("links-layer");
var nodesLayer = document.getElementById("nodes-layer");
var tooltip = document.getElementById("tooltip");
function render() {
linksLayer.innerHTML = "";
nodesLayer.innerHTML = "";
links.forEach(function(l) {
var a = nodes[nodeIdx[l.src]], b = nodes[nodeIdx[l.dst]];
if (!a || !b || a.hidden || b.hidden) return;
var line = document.createElementNS("http://www.w3.org/2000/svg", "line");
line.setAttribute("x1", a.x); line.setAttribute("y1", a.y);
line.setAttribute("x2", b.x); line.setAttribute("y2", b.y);
line.setAttribute("stroke", "#22c55e");
line.setAttribute("stroke-width", Math.min(3, 0.5 + l.cnt * 0.1));
line.setAttribute("stroke-opacity", "0.5");
line.setAttribute("marker-end", "url(#arrow)");
line.onmouseenter = function(e) {
tooltip.style.display = "block";
tooltip.style.left = e.offsetX + 10 + "px";
tooltip.style.top = e.offsetY - 10 + "px";
tooltip.innerHTML = "<b>" + l.src + "</b> &rarr; <b>" + l.dst + "</b><br>Ports: " + l.ports.join(", ") + "<br>Process: " + l.procs.join(", ") + "<br>Connexions: " + l.cnt;
};
line.onmouseleave = function() { tooltip.style.display = "none"; };
linksLayer.appendChild(line);
});
nodes.forEach(function(n) {
if (n.hidden) return;
var g = document.createElementNS("http://www.w3.org/2000/svg", "g");
g.setAttribute("transform", "translate(" + n.x + "," + n.y + ")");
g.style.cursor = "pointer";
var circle = document.createElementNS("http://www.w3.org/2000/svg", "circle");
circle.setAttribute("r", "6");
circle.setAttribute("fill", "#22c55e");
circle.setAttribute("stroke", "#0a0e17");
circle.setAttribute("stroke-width", "2");
g.appendChild(circle);
var text = document.createElementNS("http://www.w3.org/2000/svg", "text");
text.setAttribute("x", "9"); text.setAttribute("y", "4");
text.setAttribute("fill", "#94a3b8");
text.setAttribute("font-size", "9");
text.setAttribute("font-family", "monospace");
text.textContent = n.id;
g.appendChild(text);
g.onclick = function() { location.href = "/audit-full?q=" + n.id; };
g.onmouseenter = function() { circle.setAttribute("r", "8"); circle.setAttribute("fill", "#facc15"); };
g.onmouseleave = function() { circle.setAttribute("r", "6"); circle.setAttribute("fill", "#22c55e"); };
nodesLayer.appendChild(g);
});
}
render();
// Pan + Zoom
var svg = document.getElementById("flow-svg");
var viewBox = {x: 0, y: 0, w: W, h: H};
svg.setAttribute("viewBox", viewBox.x + " " + viewBox.y + " " + viewBox.w + " " + viewBox.h);
var isPanning = false, startX, startY;
svg.onmousedown = function(e) { isPanning = true; startX = e.clientX; startY = e.clientY; svg.style.cursor = "grabbing"; };
svg.onmousemove = function(e) {
if (!isPanning) return;
var dx = (e.clientX - startX) * viewBox.w / svg.clientWidth;
var dy = (e.clientY - startY) * viewBox.h / svg.clientHeight;
viewBox.x -= dx; viewBox.y -= dy;
svg.setAttribute("viewBox", viewBox.x + " " + viewBox.y + " " + viewBox.w + " " + viewBox.h);
startX = e.clientX; startY = e.clientY;
};
svg.onmouseup = function() { isPanning = false; svg.style.cursor = "grab"; };
svg.onmouseleave = function() { isPanning = false; svg.style.cursor = "grab"; };
svg.onwheel = function(e) {
e.preventDefault();
var scale = e.deltaY > 0 ? 1.1 : 0.9;
var mx = e.offsetX / svg.clientWidth * viewBox.w + viewBox.x;
var my = e.offsetY / svg.clientHeight * viewBox.h + viewBox.y;
viewBox.w *= scale; viewBox.h *= scale;
viewBox.x = mx - (mx - viewBox.x) * scale;
viewBox.y = my - (my - viewBox.y) * scale;
svg.setAttribute("viewBox", viewBox.x + " " + viewBox.y + " " + viewBox.w + " " + viewBox.h);
};
function resetZoom() {
viewBox = {x: 0, y: 0, w: W, h: H};
svg.setAttribute("viewBox", "0 0 " + W + " " + H);
}
function filterGraph() {
var search = document.getElementById("filter-search").value.toLowerCase();
var domain = document.getElementById("filter-domain").value;
// Pour le filtre domaine, on filtre cote client par prefix hostname (approximation)
nodes.forEach(function(n) {
n.hidden = false;
if (search && n.id.toLowerCase().indexOf(search) === -1) n.hidden = true;
});
render();
var vis = nodes.filter(function(n) { return !n.hidden; }).length;
document.getElementById("stats-nodes").textContent = vis + " serveurs";
}
</script>
{% endblock %}