Flow map: filtre par domaine/zone, recherche serveur avec autocompletion

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
Khalid MOUTAOUAKIL 2026-04-06 16:48:29 +02:00
parent 532ecf7e3d
commit f6571c2b10
2 changed files with 114 additions and 21 deletions

View File

@ -157,12 +157,86 @@ async def audit_full_flow_map(request: Request, db=Depends(get_db)):
if not user:
return RedirectResponse(url="/login")
domain_filter = request.query_params.get("domain", "")
server_filter = request.query_params.get("server", "").strip()
# Domaines + zones pour le dropdown
all_domains = db.execute(text(
"SELECT code, name, 'domain' as type FROM domains ORDER BY name"
)).fetchall()
all_zones = db.execute(text(
"SELECT name as code, name, 'zone' as type FROM zones ORDER BY name"
)).fetchall()
# Serveurs audites pour l'autocompletion
audited_servers = db.execute(text("""
SELECT DISTINCT hostname FROM server_audit_full WHERE status = 'ok' ORDER BY hostname
""")).fetchall()
if server_filter:
# Flux pour un serveur specifique (IN + OUT)
flows = db.execute(text("""
SELECT source_hostname, source_ip, dest_ip, dest_port,
dest_hostname, process_name, direction, state,
COUNT(*) as cnt
FROM network_flow_map nfm
JOIN server_audit_full saf ON nfm.audit_id = saf.id
WHERE saf.id IN (
SELECT DISTINCT ON (hostname) id FROM server_audit_full
WHERE status = 'ok' ORDER BY hostname, audit_date DESC
)
AND (nfm.source_hostname = :srv OR nfm.dest_hostname = :srv)
AND nfm.source_hostname != nfm.dest_hostname
AND nfm.dest_hostname IS NOT NULL
GROUP BY source_hostname, source_ip, dest_ip, dest_port,
dest_hostname, process_name, direction, state
ORDER BY source_hostname
"""), {"srv": server_filter}).fetchall()
elif domain_filter:
# Flux pour un domaine ou une zone
# D'abord chercher comme zone
hostnames = [r.hostname for r in db.execute(text("""
SELECT s.hostname FROM servers s
JOIN zones z ON s.zone_id = z.id WHERE z.name = :name
"""), {"name": domain_filter}).fetchall()]
if not hostnames:
# Sinon comme domaine
hostnames = [r.hostname for r in db.execute(text("""
SELECT s.hostname FROM servers s
JOIN domain_environments de ON s.domain_env_id = de.id
JOIN domains d ON de.domain_id = d.id WHERE d.code = :dc
"""), {"dc": domain_filter}).fetchall()]
if hostnames:
flows = db.execute(text("""
SELECT source_hostname, source_ip, dest_ip, dest_port,
dest_hostname, process_name, direction, state,
COUNT(*) as cnt
FROM network_flow_map nfm
JOIN server_audit_full saf ON nfm.audit_id = saf.id
WHERE saf.id IN (
SELECT DISTINCT ON (hostname) id FROM server_audit_full
WHERE status = 'ok' ORDER BY hostname, audit_date DESC
)
AND (nfm.source_hostname = ANY(:hosts) OR nfm.dest_hostname = ANY(:hosts))
AND nfm.source_hostname != COALESCE(nfm.dest_hostname, '')
AND nfm.dest_hostname IS NOT NULL
GROUP BY source_hostname, source_ip, dest_ip, dest_port,
dest_hostname, process_name, direction, state
ORDER BY source_hostname
"""), {"hosts": hostnames}).fetchall()
else:
flows = []
else:
flows = get_flow_map(db)
app_map = get_app_map(db)
ctx = base_context(request, db, user)
ctx.update({
"app_name": APP_NAME, "flows": flows, "app_map": app_map,
"all_domains": all_domains, "all_zones": all_zones,
"audited_servers": audited_servers,
"domain_filter": domain_filter, "server_filter": server_filter,
})
return templates.TemplateResponse("audit_full_flowmap.html", ctx)

View File

@ -4,14 +4,35 @@
<a href="/audit-full" class="text-xs text-gray-500 hover:text-gray-300">< Retour</a>
<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>
<!-- Filtres -->
<div class="card p-3 mb-4 flex gap-3 items-center flex-wrap">
<form method="GET" action="/audit-full/flow-map" class="flex gap-2 items-center flex-1">
<select name="domain" class="text-xs py-1 px-2" onchange="this.form.submit()">
<option value="">Tous</option>
<optgroup label="Zones">
{% for z in all_zones %}<option value="{{ z.code }}" {% if domain_filter == z.code %}selected{% endif %}>{{ z.name }}</option>{% endfor %}
</optgroup>
<optgroup label="Domaines">
{% for d in all_domains %}<option value="{{ d.code }}" {% if domain_filter == d.code %}selected{% endif %}>{{ d.name }}</option>{% endfor %}
</optgroup>
</select>
<div style="position:relative" class="flex-1">
<input type="text" name="server" value="{{ server_filter }}" placeholder="Serveur (ex: vptrabkme1)..." class="text-xs py-1 px-2 w-full font-mono" id="server-input" autocomplete="off" list="server-list">
<datalist id="server-list">
{% for s in audited_servers %}<option value="{{ s.hostname }}">{% endfor %}
</datalist>
</div>
<button type="submit" class="btn-primary px-3 py-1 text-xs">Generer</button>
{% if domain_filter or server_filter %}<a href="/audit-full/flow-map" class="text-xs text-gray-400 hover:text-cyber-accent">Reset</a>{% endif %}
</form>
<span class="text-xs text-gray-500">
{% if server_filter %}Serveur: <span class="text-cyber-accent font-mono">{{ server_filter }}</span>
{% elif domain_filter %}Domaine/Zone: <span class="text-cyber-accent">{{ domain_filter }}</span>
{% else %}Vue globale{% endif %}
</span>
</div>
<!-- Map SVG -->
@ -190,10 +211,11 @@ function render() {
g.setAttribute("transform", "translate(" + n.x + "," + n.y + ")");
g.style.cursor = "pointer";
var isTarget = (serverFilter && n.id === serverFilter);
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("r", isTarget ? "10" : "6");
circle.setAttribute("fill", isTarget ? "#facc15" : "#22c55e");
circle.setAttribute("stroke", isTarget ? "#facc15" : "#0a0e17");
circle.setAttribute("stroke-width", "2");
g.appendChild(circle);
@ -247,17 +269,14 @@ function resetZoom() {
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";
// Highlight du serveur filtre
var serverFilter = "{{ server_filter }}";
if (serverFilter && nodeIdx[serverFilter] !== undefined) {
var targetNode = nodes[nodeIdx[serverFilter]];
// Centrer la vue sur ce noeud
viewBox.x = targetNode.x - W / 2;
viewBox.y = targetNode.y - H / 2;
svg.setAttribute("viewBox", viewBox.x + " " + viewBox.y + " " + viewBox.w + " " + viewBox.h);
}
</script>
{% endblock %}