Dashboard patching 2026: KPIs, barre progression, graphe semaines, domaine/env/zone

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
Khalid MOUTAOUAKIL 2026-04-06 17:54:00 +02:00
parent 2b2fac7c13
commit ed23cc3fb6
2 changed files with 239 additions and 59 deletions

View File

@ -15,7 +15,7 @@ async def dashboard(request: Request, db=Depends(get_db)):
if not user: if not user:
return RedirectResponse(url="/login") return RedirectResponse(url="/login")
# Stats # Stats generales
stats = {} stats = {}
stats["total_servers"] = db.execute(text("SELECT COUNT(*) FROM servers")).scalar() stats["total_servers"] = db.execute(text("SELECT COUNT(*) FROM servers")).scalar()
stats["patchable"] = db.execute(text("SELECT COUNT(*) FROM servers WHERE patch_os_owner='secops' AND etat='en_production'")).scalar() stats["patchable"] = db.execute(text("SELECT COUNT(*) FROM servers WHERE patch_os_owner='secops' AND etat='en_production'")).scalar()
@ -42,7 +42,80 @@ async def dashboard(request: Request, db=Depends(get_db)):
# Par tier # Par tier
tiers = db.execute(text("SELECT tier, COUNT(*) FROM servers GROUP BY tier ORDER BY tier")).fetchall() tiers = db.execute(text("SELECT tier, COUNT(*) FROM servers GROUP BY tier ORDER BY tier")).fetchall()
# ── Stats patching 2026 ──
patch_stats = db.execute(text("""
SELECT
COUNT(*) as audited,
COUNT(*) FILTER (WHERE last_patch_year = 2026) as patched_2026,
COUNT(*) FILTER (WHERE last_patch_year = 2025) as patched_2025_only,
COUNT(*) FILTER (WHERE last_patch_year IS NULL OR last_patch_week IS NULL) as never_patched,
COUNT(*) FILTER (WHERE patch_count_2026 >= 1) as patched_once,
COUNT(*) FILTER (WHERE patch_count_2026 >= 2) as patched_twice,
COUNT(*) FILTER (WHERE patch_count_2026 >= 3) as patched_thrice,
COUNT(*) FILTER (WHERE reboot_required = true) as needs_reboot
FROM server_audit_full
WHERE status = 'ok'
AND id IN (SELECT DISTINCT ON (hostname) id FROM server_audit_full WHERE status = 'ok' ORDER BY hostname, audit_date DESC)
""")).fetchone()
# Frequence patching par semaine 2026
patch_weekly = db.execute(text("""
SELECT last_patch_week as week, COUNT(*) as cnt
FROM server_audit_full
WHERE status = 'ok' AND last_patch_year = 2026 AND last_patch_week IS NOT NULL
AND id IN (SELECT DISTINCT ON (hostname) id FROM server_audit_full WHERE status = 'ok' ORDER BY hostname, audit_date DESC)
GROUP BY last_patch_week ORDER BY last_patch_week
""")).fetchall()
# Patching par domaine
patch_by_domain = db.execute(text("""
SELECT d.name as domain, d.code,
COUNT(DISTINCT saf.hostname) as total,
COUNT(DISTINCT saf.hostname) FILTER (WHERE saf.last_patch_year = 2026) as patched,
COUNT(DISTINCT saf.hostname) FILTER (WHERE saf.patch_count_2026 >= 2) as patched_twice,
COUNT(DISTINCT saf.hostname) FILTER (WHERE saf.last_patch_year IS NULL) as never
FROM server_audit_full saf
JOIN servers s ON saf.server_id = s.id
JOIN domain_environments de ON s.domain_env_id = de.id
JOIN domains d ON de.domain_id = d.id
WHERE saf.status = 'ok'
AND saf.id IN (SELECT DISTINCT ON (hostname) id FROM server_audit_full WHERE status = 'ok' ORDER BY hostname, audit_date DESC)
GROUP BY d.name, d.code, d.display_order
ORDER BY d.display_order
""")).fetchall()
# Patching par environnement
patch_by_env = db.execute(text("""
SELECT e.name as env,
COUNT(DISTINCT saf.hostname) as total,
COUNT(DISTINCT saf.hostname) FILTER (WHERE saf.last_patch_year = 2026) as patched
FROM server_audit_full saf
JOIN servers s ON saf.server_id = s.id
JOIN domain_environments de ON s.domain_env_id = de.id
JOIN environments e ON de.environment_id = e.id
WHERE saf.status = 'ok'
AND saf.id IN (SELECT DISTINCT ON (hostname) id FROM server_audit_full WHERE status = 'ok' ORDER BY hostname, audit_date DESC)
GROUP BY e.name ORDER BY e.name
""")).fetchall()
# Patching par zone (DMZ, LAN, EMV)
patch_by_zone = db.execute(text("""
SELECT z.name as zone,
COUNT(DISTINCT saf.hostname) as total,
COUNT(DISTINCT saf.hostname) FILTER (WHERE saf.last_patch_year = 2026) as patched,
COUNT(DISTINCT saf.hostname) FILTER (WHERE saf.last_patch_year IS NULL) as never
FROM server_audit_full saf
JOIN servers s ON saf.server_id = s.id
JOIN zones z ON s.zone_id = z.id
WHERE saf.status = 'ok'
AND saf.id IN (SELECT DISTINCT ON (hostname) id FROM server_audit_full WHERE status = 'ok' ORDER BY hostname, audit_date DESC)
GROUP BY z.name ORDER BY z.name
""")).fetchall()
return templates.TemplateResponse("dashboard.html", { return templates.TemplateResponse("dashboard.html", {
"request": request, "user": user, "app_name": APP_NAME, "request": request, "user": user, "app_name": APP_NAME,
"stats": stats, "domains": domains, "tiers": tiers "stats": stats, "domains": domains, "tiers": tiers,
"patch_stats": patch_stats, "patch_weekly": patch_weekly,
"patch_by_domain": patch_by_domain, "patch_by_env": patch_by_env,
"patch_by_zone": patch_by_zone,
}) })

View File

@ -3,41 +3,153 @@
{% block content %} {% block content %}
<h2 class="text-xl font-bold text-cyber-accent mb-4">Dashboard</h2> <h2 class="text-xl font-bold text-cyber-accent mb-4">Dashboard</h2>
<!-- KPIs --> <!-- KPIs generaux -->
<div class="grid grid-cols-5 gap-4 mb-6"> <div style="display:flex;flex-wrap:nowrap;gap:8px;margin-bottom:16px;">
<div class="card p-4 text-center"> <div class="card p-3 text-center" style="flex:1;min-width:0"><div class="text-2xl font-bold text-cyber-accent">{{ stats.total_servers }}</div><div class="text-xs text-gray-500">Serveurs</div></div>
<div class="text-3xl font-bold text-cyber-accent">{{ stats.total_servers }}</div> <div class="card p-3 text-center" style="flex:1;min-width:0"><div class="text-2xl font-bold text-cyber-green">{{ stats.patchable }}</div><div class="text-xs text-gray-500">Patchables SecOps</div></div>
<div class="text-xs text-gray-500">Serveurs</div> <div class="card p-3 text-center" style="flex:1;min-width:0"><div class="text-2xl font-bold text-white">{{ stats.linux }} / {{ stats.windows }}</div><div class="text-xs text-gray-500">Linux / Windows</div></div>
</div> <div class="card p-3 text-center" style="flex:1;min-width:0"><div class="text-2xl font-bold text-cyber-yellow">{{ stats.qualys_tags }}</div><div class="text-xs text-gray-500">Tags Qualys</div></div>
<div class="card p-4 text-center"> <div class="card p-3 text-center" style="flex:1;min-width:0"><div class="text-2xl font-bold text-cyber-red">{{ stats.eol }}</div><div class="text-xs text-gray-500">EOL</div></div>
<div class="text-3xl font-bold text-cyber-green">{{ stats.patchable }}</div>
<div class="text-xs text-gray-500">Patchables SecOps</div>
</div>
<div class="card p-4 text-center">
<div class="text-3xl font-bold text-white">{{ stats.linux }} / {{ stats.windows }}</div>
<div class="text-xs text-gray-500">Linux / Windows</div>
</div>
<div class="card p-4 text-center">
<div class="text-3xl font-bold text-cyber-yellow">{{ stats.qualys_tags }}</div>
<div class="text-xs text-gray-500">Tags Qualys</div>
</div>
<div class="card p-4 text-center">
<div class="text-3xl font-bold text-cyber-red">{{ stats.eol }}</div>
<div class="text-xs text-gray-500">EOL</div>
</div>
</div> </div>
<!-- Par domaine --> <!-- ═══════ PATCHING 2026 ═══════ -->
<div class="card p-4 mb-6"> {% if patch_stats %}
<h3 class="text-sm font-bold text-cyber-accent mb-3">Par domaine</h3> <div class="card p-4 mb-4">
<table class="w-full table-cyber"> <h3 class="text-sm font-bold text-cyber-accent mb-3">Patching 2026</h3>
<thead><tr>
<th class="text-left p-2">Domaine</th> <!-- KPIs patching -->
<th class="p-2">Total</th> <div style="display:flex;flex-wrap:nowrap;gap:6px;margin-bottom:12px;">
<th class="p-2">Actifs</th> <a href="/audit-full" class="card p-2 text-center hover:bg-cyber-hover" style="flex:1;min-width:0;background:#111827;">
<th class="p-2">Linux</th> <div class="text-xl font-bold text-cyber-accent">{{ patch_stats.audited }}</div>
<th class="p-2">Windows</th> <div style="font-size:10px;" class="text-gray-500">Audites</div>
</tr></thead> </a>
<a href="/audit-full?filter=app_patch2026" class="card p-2 text-center hover:bg-cyber-hover" style="flex:1;min-width:0;background:#111827;">
<div class="text-xl font-bold text-cyber-green">{{ patch_stats.patched_2026 }}</div>
<div style="font-size:10px;" class="text-gray-500">Patches 2026</div>
</a>
<div class="card p-2 text-center" style="flex:1;min-width:0;background:#111827;">
<div class="text-xl font-bold text-cyber-green">{{ patch_stats.patched_once }}</div>
<div style="font-size:10px;" class="text-gray-500">1+ fois</div>
</div>
<div class="card p-2 text-center" style="flex:1;min-width:0;background:#111827;">
<div class="text-xl font-bold text-blue-400">{{ patch_stats.patched_twice }}</div>
<div style="font-size:10px;" class="text-gray-500">2+ fois</div>
</div>
<div class="card p-2 text-center" style="flex:1;min-width:0;background:#111827;">
<div class="text-xl font-bold text-purple-400">{{ patch_stats.patched_thrice }}</div>
<div style="font-size:10px;" class="text-gray-500">3+ fois</div>
</div>
<div class="card p-2 text-center" style="flex:1;min-width:0;background:#111827;">
<div class="text-xl font-bold text-cyber-yellow">{{ patch_stats.patched_2025_only }}</div>
<div style="font-size:10px;" class="text-gray-500">2025 seul</div>
</div>
<div class="card p-2 text-center" style="flex:1;min-width:0;background:#111827;">
<div class="text-xl font-bold text-cyber-red">{{ patch_stats.never_patched }}</div>
<div style="font-size:10px;" class="text-gray-500">Jamais</div>
</div>
<div class="card p-2 text-center" style="flex:1;min-width:0;background:#111827;">
<div class="text-xl font-bold {% if patch_stats.needs_reboot > 0 %}text-cyber-red{% else %}text-cyber-green{% endif %}">{{ patch_stats.needs_reboot }}</div>
<div style="font-size:10px;" class="text-gray-500">Reboot</div>
</div>
</div>
<!-- Barre de progression globale -->
{% set pct = (patch_stats.patched_2026 / patch_stats.audited * 100)|int if patch_stats.audited > 0 else 0 %}
<div class="mb-4">
<div class="flex justify-between text-xs mb-1">
<span class="text-gray-400">Couverture patching 2026</span>
<span class="font-bold {% if pct >= 80 %}text-cyber-green{% elif pct >= 50 %}text-cyber-yellow{% else %}text-cyber-red{% endif %}">{{ pct }}%</span>
</div>
<div style="height:8px;background:#1f2937;border-radius:4px;overflow:hidden;">
<div style="height:100%;width:{{ pct }}%;background:{% if pct >= 80 %}#22c55e{% elif pct >= 50 %}#eab308{% else %}#ef4444{% endif %};border-radius:4px;transition:width 0.5s;"></div>
</div>
</div>
<!-- Graphe frequence par semaine -->
{% if patch_weekly %}
<div class="mb-4">
<div class="text-xs text-gray-500 mb-2">Serveurs patches par semaine</div>
<div style="display:flex;align-items:flex-end;gap:2px;height:120px;">
{% set max_cnt = patch_weekly|map(attribute='cnt')|max %}
{% for w in patch_weekly %}
<div style="flex:1;display:flex;flex-direction:column;align-items:center;justify-content:flex-end;height:100%;" title="{{ w.week }}: {{ w.cnt }} serveurs">
<div style="font-size:9px;color:#94a3b8;margin-bottom:2px;">{{ w.cnt }}</div>
<div style="width:100%;background:#22c55e;border-radius:2px 2px 0 0;min-height:2px;height:{{ (w.cnt / max_cnt * 100)|int }}%;opacity:0.8;"></div>
<div style="font-size:8px;color:#6b7280;margin-top:2px;transform:rotate(-45deg);white-space:nowrap;">{{ w.week }}</div>
</div>
{% endfor %}
</div>
</div>
{% endif %}
<!-- Par domaine -->
<div class="grid grid-cols-2 gap-4">
<div>
<div class="text-xs text-gray-500 mb-2">Par domaine</div>
<table class="w-full table-cyber text-xs">
<thead><tr><th class="text-left p-1">Domaine</th><th class="p-1">Total</th><th class="p-1">Patche</th><th class="p-1">2x</th><th class="p-1">%</th></tr></thead>
<tbody>
{% for d in patch_by_domain %}
{% set pct_d = (d.patched / d.total * 100)|int if d.total > 0 else 0 %}
<tr>
<td class="p-1">{{ d.domain }}</td>
<td class="p-1 text-center">{{ d.total }}</td>
<td class="p-1 text-center text-cyber-green">{{ d.patched }}</td>
<td class="p-1 text-center text-blue-400">{{ d.patched_twice }}</td>
<td class="p-1 text-center">
<span class="{% if pct_d >= 80 %}text-cyber-green{% elif pct_d >= 50 %}text-cyber-yellow{% else %}text-cyber-red{% endif %} font-bold">{{ pct_d }}%</span>
</td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
<div>
<!-- Par environnement -->
<div class="text-xs text-gray-500 mb-2">Par environnement</div>
<table class="w-full table-cyber text-xs mb-4">
<thead><tr><th class="text-left p-1">Env</th><th class="p-1">Total</th><th class="p-1">Patche</th><th class="p-1">%</th></tr></thead>
<tbody>
{% for e in patch_by_env %}
{% set pct_e = (e.patched / e.total * 100)|int if e.total > 0 else 0 %}
<tr>
<td class="p-1">{{ e.env }}</td>
<td class="p-1 text-center">{{ e.total }}</td>
<td class="p-1 text-center text-cyber-green">{{ e.patched }}</td>
<td class="p-1 text-center"><span class="{% if pct_e >= 80 %}text-cyber-green{% elif pct_e >= 50 %}text-cyber-yellow{% else %}text-cyber-red{% endif %} font-bold">{{ pct_e }}%</span></td>
</tr>
{% endfor %}
</tbody>
</table>
<!-- Par zone -->
<div class="text-xs text-gray-500 mb-2">Par zone reseau</div>
<table class="w-full table-cyber text-xs">
<thead><tr><th class="text-left p-1">Zone</th><th class="p-1">Total</th><th class="p-1">Patche</th><th class="p-1">Jamais</th><th class="p-1">%</th></tr></thead>
<tbody>
{% for z in patch_by_zone %}
{% set pct_z = (z.patched / z.total * 100)|int if z.total > 0 else 0 %}
<tr>
<td class="p-1">{{ z.zone }}</td>
<td class="p-1 text-center">{{ z.total }}</td>
<td class="p-1 text-center text-cyber-green">{{ z.patched }}</td>
<td class="p-1 text-center text-cyber-red">{{ z.never }}</td>
<td class="p-1 text-center"><span class="{% if pct_z >= 80 %}text-cyber-green{% elif pct_z >= 50 %}text-cyber-yellow{% else %}text-cyber-red{% endif %} font-bold">{{ pct_z }}%</span></td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
</div>
</div>
{% endif %}
<!-- Par domaine (inventaire) -->
<div class="card p-4 mb-4">
<h3 class="text-sm font-bold text-cyber-accent mb-3">Inventaire par domaine</h3>
<table class="w-full table-cyber text-xs">
<thead><tr><th class="text-left p-2">Domaine</th><th class="p-2">Total</th><th class="p-2">Actifs</th><th class="p-2">Linux</th><th class="p-2">Windows</th></tr></thead>
<tbody> <tbody>
{% for d in domains %} {% for d in domains %}
<tr> <tr>
@ -52,32 +164,27 @@
</table> </table>
</div> </div>
<!-- Par tier --> <!-- Par tier + quick stats -->
<div class="card p-4 mb-6"> <div class="grid grid-cols-2 gap-4">
<h3 class="text-sm font-bold text-cyber-accent mb-3">Par tier</h3> <div class="card p-4">
<div class="flex gap-4"> <h3 class="text-sm font-bold text-cyber-accent mb-3">Par tier</h3>
{% for t in tiers %} <div class="flex gap-3">
<div class="flex-1 text-center p-3 rounded" style="background: {% if t[0] == 'tier0' %}#ff336622{% elif t[0] == 'tier1' %}#ff880022{% elif t[0] == 'tier2' %}#ffcc0022{% else %}#00ff8822{% endif %}"> {% for t in tiers %}
<div class="text-lg font-bold">{{ t[1] }}</div> <div class="flex-1 text-center p-3 rounded" style="background: {% if t[0] == 'tier0' %}#ff336622{% elif t[0] == 'tier1' %}#ff880022{% elif t[0] == 'tier2' %}#ffcc0022{% else %}#00ff8822{% endif %}">
<div class="text-xs text-gray-400">{{ t[0] }}</div> <div class="text-lg font-bold">{{ t[1] }}</div>
<div class="text-xs text-gray-400">{{ t[0] }}</div>
</div>
{% endfor %}
</div> </div>
{% endfor %}
</div> </div>
</div> <div class="card p-4">
<h3 class="text-sm font-bold text-cyber-accent mb-3">Quick stats</h3>
<!-- Quick stats --> <div class="space-y-2 text-xs">
<div class="grid grid-cols-3 gap-4"> <div class="flex justify-between"><span class="text-gray-500">Decommissionnes</span><span class="text-cyber-red font-bold">{{ stats.decom }}</span></div>
<div class="card p-3"> <div class="flex justify-between"><span class="text-gray-500">EOL</span><span class="text-cyber-red font-bold">{{ stats.eol }}</span></div>
<span class="text-xs text-gray-500">Decomissionnes</span> <div class="flex justify-between"><span class="text-gray-500">Assets Qualys</span><span class="text-cyber-accent font-bold">{{ stats.qualys_assets }}</span></div>
<span class="float-right text-cyber-red font-bold">{{ stats.decom }}</span> <div class="flex justify-between"><span class="text-gray-500">Tags Qualys</span><span class="text-cyber-yellow font-bold">{{ stats.qualys_tags }}</span></div>
</div> </div>
<div class="card p-3">
<span class="text-xs text-gray-500">EOL</span>
<span class="float-right text-cyber-red font-bold">{{ stats.eol }}</span>
</div>
<div class="card p-3">
<span class="text-xs text-gray-500">Assets Qualys</span>
<span class="float-right text-cyber-accent font-bold">{{ stats.qualys_assets }}</span>
</div> </div>
</div> </div>
{% endblock %} {% endblock %}