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:
parent
0a309ea4f7
commit
532ecf7e3d
@ -2,69 +2,262 @@
|
|||||||
{% block title %}Carte flux{% endblock %}
|
{% block title %}Carte flux{% endblock %}
|
||||||
{% block content %}
|
{% block content %}
|
||||||
<a href="/audit-full" class="text-xs text-gray-500 hover:text-gray-300">< Retour</a>
|
<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>
|
<div class="flex justify-between items-center mb-4">
|
||||||
|
<h2 class="text-xl font-bold text-cyber-accent">Carte des flux reseau</h2>
|
||||||
{% if flows %}
|
<div class="flex gap-2 items-center">
|
||||||
<div class="card overflow-x-auto mb-4">
|
<select id="filter-domain" class="text-xs py-1 px-2" onchange="filterGraph()">
|
||||||
<div class="p-2 border-b border-cyber-border">
|
<option value="">Tous</option>
|
||||||
<span class="text-xs font-bold text-cyber-accent">{{ flows|length }} flux inter-serveurs</span>
|
{% 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>
|
||||||
|
</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">
|
<table class="w-full table-cyber text-xs">
|
||||||
<thead><tr>
|
<thead><tr>
|
||||||
<th class="p-2">Dir</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="text-left p-2">Source</th>
|
<th class="p-2">Port</th><th class="p-2">Process</th><th class="p-2">State</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>
|
|
||||||
</tr></thead>
|
</tr></thead>
|
||||||
<tbody>
|
<tbody>
|
||||||
{% for f in flows %}
|
{% for f in flows %}
|
||||||
<tr class="{% if f.state == 'CLOSE-WAIT' %}bg-red-900/10{% endif %}">
|
<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 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-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 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 '-' }}</td>
|
|
||||||
<td class="p-2 font-mono text-gray-500 text-center">{{ f.dest_ip }}</td>
|
|
||||||
<td class="p-2 text-center font-bold">{{ f.dest_port }}</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">{{ 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"><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>
|
</tr>
|
||||||
{% endfor %}
|
{% endfor %}
|
||||||
</tbody>
|
</tbody>
|
||||||
</table>
|
</table>
|
||||||
</div>
|
</div>
|
||||||
{% else %}
|
</details>
|
||||||
<div class="card p-8 text-center text-gray-500">Aucun flux. Importez des rapports JSON d'abord.</div>
|
|
||||||
{% endif %}
|
{% endif %}
|
||||||
|
|
||||||
{% if app_map %}
|
{% if app_map %}
|
||||||
<h3 class="text-lg font-bold text-cyber-accent mb-3">Carte applicative</h3>
|
<details class="card mt-4">
|
||||||
<div class="grid grid-cols-2 gap-3">
|
<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() %}
|
{% for app_name, app in app_map.items() %}
|
||||||
<div class="card p-3">
|
<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="text-sm font-bold text-cyber-yellow">{{ app_name }}</span>
|
<span class="badge badge-blue ml-2">{{ app.servers|length }}</span>
|
||||||
<span class="badge badge-blue">{{ app.servers|length }} serveur(s)</span>
|
{% if app.ports %}<span class="text-xs text-gray-500 ml-2">ports: {{ app.ports|join(', ') }}</span>{% endif %}
|
||||||
</div>
|
<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>
|
||||||
{% 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>
|
|
||||||
</div>
|
</div>
|
||||||
{% endfor %}
|
{% endfor %}
|
||||||
</div>
|
</div>
|
||||||
|
</details>
|
||||||
{% endif %}
|
{% 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> → <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 %}
|
{% endblock %}
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user