diff --git a/app/routers/dashboard.py b/app/routers/dashboard.py
index 8ce0a2c..7ca1ea1 100644
--- a/app/routers/dashboard.py
+++ b/app/routers/dashboard.py
@@ -15,7 +15,7 @@ async def dashboard(request: Request, db=Depends(get_db)):
if not user:
return RedirectResponse(url="/login")
- # Stats
+ # Stats generales
stats = {}
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()
@@ -42,7 +42,80 @@ async def dashboard(request: Request, db=Depends(get_db)):
# Par tier
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", {
"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,
})
diff --git a/app/templates/dashboard.html b/app/templates/dashboard.html
index 8d1281f..aeb0c2f 100644
--- a/app/templates/dashboard.html
+++ b/app/templates/dashboard.html
@@ -3,41 +3,153 @@
{% block content %}
Dashboard
-
-
-
-
{{ stats.total_servers }}
-
Serveurs
-
-
-
{{ stats.patchable }}
-
Patchables SecOps
-
-
-
{{ stats.linux }} / {{ stats.windows }}
-
Linux / Windows
-
-
-
{{ stats.qualys_tags }}
-
Tags Qualys
-
-
-
{{ stats.eol }}
-
EOL
-
+
+
+
{{ stats.total_servers }}
Serveurs
+
{{ stats.patchable }}
Patchables SecOps
+
{{ stats.linux }} / {{ stats.windows }}
Linux / Windows
+
{{ stats.qualys_tags }}
Tags Qualys
+
-
-
-
Par domaine
-
-
- | Domaine |
- Total |
- Actifs |
- Linux |
- Windows |
-
+
+{% if patch_stats %}
+
+
Patching 2026
+
+
+
+
+
+ {% set pct = (patch_stats.patched_2026 / patch_stats.audited * 100)|int if patch_stats.audited > 0 else 0 %}
+
+
+ Couverture patching 2026
+ {{ pct }}%
+
+
+
+
+
+ {% if patch_weekly %}
+
+
Serveurs patches par semaine
+
+ {% set max_cnt = patch_weekly|map(attribute='cnt')|max %}
+ {% for w in patch_weekly %}
+
+
{{ w.cnt }}
+
+
{{ w.week }}
+
+ {% endfor %}
+
+
+ {% endif %}
+
+
+
+
+
Par domaine
+
+ | Domaine | Total | Patche | 2x | % |
+
+ {% for d in patch_by_domain %}
+ {% set pct_d = (d.patched / d.total * 100)|int if d.total > 0 else 0 %}
+
+ | {{ d.domain }} |
+ {{ d.total }} |
+ {{ d.patched }} |
+ {{ d.patched_twice }} |
+
+ {{ pct_d }}%
+ |
+
+ {% endfor %}
+
+
+
+
+
+
+
Par environnement
+
+ | Env | Total | Patche | % |
+
+ {% for e in patch_by_env %}
+ {% set pct_e = (e.patched / e.total * 100)|int if e.total > 0 else 0 %}
+
+ | {{ e.env }} |
+ {{ e.total }} |
+ {{ e.patched }} |
+ {{ pct_e }}% |
+
+ {% endfor %}
+
+
+
+
+
Par zone reseau
+
+ | Zone | Total | Patche | Jamais | % |
+
+ {% for z in patch_by_zone %}
+ {% set pct_z = (z.patched / z.total * 100)|int if z.total > 0 else 0 %}
+
+ | {{ z.zone }} |
+ {{ z.total }} |
+ {{ z.patched }} |
+ {{ z.never }} |
+ {{ pct_z }}% |
+
+ {% endfor %}
+
+
+
+
+
+{% endif %}
+
+
+
+
Inventaire par domaine
+
+ | Domaine | Total | Actifs | Linux | Windows |
{% for d in domains %}
@@ -52,32 +164,27 @@
-
-
-
Par tier
-
- {% for t in tiers %}
-
-
{{ t[1] }}
-
{{ t[0] }}
+
+
+
+
Par tier
+
+ {% for t in tiers %}
+
+
{{ t[1] }}
+
{{ t[0] }}
+
+ {% endfor %}
- {% endfor %}
-
-
-
-
-
- Decomissionnes
- {{ stats.decom }}
-
-
- EOL
- {{ stats.eol }}
-
-
-
Assets Qualys
-
{{ stats.qualys_assets }}
+
+
Quick stats
+
+
Decommissionnes{{ stats.decom }}
+
EOL{{ stats.eol }}
+
Assets Qualys{{ stats.qualys_assets }}
+
Tags Qualys{{ stats.qualys_tags }}
+
{% endblock %}