feat(qualys): dashboard vulnerabilites avec KPI + historique

This commit is contained in:
Pierre & Lumière 2026-04-24 23:49:46 +00:00
parent b06aedfc3b
commit 8f8e8c4d8f
6 changed files with 819 additions and 217 deletions

View File

@ -466,13 +466,21 @@ async def qualys_search(request: Request, db=Depends(get_db),
all_tags = db.execute(text("SELECT qualys_tag_id, name FROM qualys_tags ORDER BY name")).fetchall() all_tags = db.execute(text("SELECT qualys_tag_id, name FROM qualys_tags ORDER BY name")).fetchall()
# KPI : total / avec vuln / sans vuln + filtrage vuln_filter (with|zero) # KPI : total / avec vuln / sans vuln + filtrage vuln_filter (with|zero|critical|high|medium)
vuln_filter = request.query_params.get("vuln_filter", "") vuln_filter = request.query_params.get("vuln_filter", "")
def _vuln_total(a): def _vuln_total(a):
vc = vuln_map.get(str(a.ip_address), {}) vc = vuln_map.get(str(a.ip_address), {})
if isinstance(vc, dict): if isinstance(vc, dict):
return int(vc.get("total", 0) or 0) return int(vc.get("total", 0) or 0)
return int(vc or 0) return int(vc or 0)
def _max_sev(a):
vc = vuln_map.get(str(a.ip_address), {})
if not isinstance(vc, dict):
return 0
if vc.get("severity5", 0) > 0: return 5
if vc.get("severity4", 0) > 0: return 4
if vc.get("severity3", 0) > 0: return 3
return 0
kpi_total = len(assets) if assets else 0 kpi_total = len(assets) if assets else 0
kpi_with_vuln = sum(1 for a in assets if _vuln_total(a) > 0) if assets else 0 kpi_with_vuln = sum(1 for a in assets if _vuln_total(a) > 0) if assets else 0
kpi_zero_vuln = kpi_total - kpi_with_vuln kpi_zero_vuln = kpi_total - kpi_with_vuln
@ -480,6 +488,12 @@ async def qualys_search(request: Request, db=Depends(get_db),
assets = [a for a in assets if _vuln_total(a) > 0] assets = [a for a in assets if _vuln_total(a) > 0]
elif assets and vuln_filter == "zero": elif assets and vuln_filter == "zero":
assets = [a for a in assets if _vuln_total(a) == 0] assets = [a for a in assets if _vuln_total(a) == 0]
elif assets and vuln_filter == "critical":
assets = [a for a in assets if _max_sev(a) == 5]
elif assets and vuln_filter == "high":
assets = [a for a in assets if _max_sev(a) == 4]
elif assets and vuln_filter == "medium":
assets = [a for a in assets if _max_sev(a) == 3]
ctx = base_context(request, db, user) ctx = base_context(request, db, user)
ctx.update({ ctx.update({
@ -1159,3 +1173,81 @@ async def qualys_deploy_check(request: Request, db=Depends(get_db)):
"active": sum(1 for r in results if r["status"] == "ACTIVE"), "active": sum(1 for r in results if r["status"] == "ACTIVE"),
"not_installed": sum(1 for r in results if r["status"] == "NOT_INSTALLED"), "not_installed": sum(1 for r in results if r["status"] == "NOT_INSTALLED"),
"failed": sum(1 for r in results if r["status"] == "CONNECTION_FAILED")}) "failed": sum(1 for r in results if r["status"] == "CONNECTION_FAILED")})
# === DASHBOARD VULNERABILITES (KPI + historique) ===
@router.get("/qualys/dashboard", response_class=HTMLResponse)
async def qualys_dashboard(request: Request, db=Depends(get_db)):
user = get_current_user(request)
if not user:
return RedirectResponse(url="/login")
perms = get_user_perms(db, user)
# TODO: ajouter perm specifique 'qualys_dashboard_view' (admin + DSI)
if not can_view(perms, "qualys"):
return RedirectResponse(url="/dashboard")
from app.services.qualys_service import load_vuln_dashboard, is_dashboard_running
data = load_vuln_dashboard(db)
env_set = sorted({d["name"] for d in data["env"]})
pos_set = sorted({d["name"] for d in data["pos"]})
matrix = {(c["name"], c["name2"]): c for c in data["env_pos"]}
ctx = base_context(request, db, user)
ctx.update({"data": data, "env_list": env_set, "pos_list": pos_set,
"matrix": matrix, "is_running": is_dashboard_running()})
return templates.TemplateResponse("qualys_dashboard.html", ctx)
@router.post("/qualys/dashboard/refresh")
async def qualys_dashboard_refresh(request: Request, db=Depends(get_db)):
user = get_current_user(request)
if not user:
return RedirectResponse(url="/login")
perms = get_user_perms(db, user)
if not can_edit(perms, "qualys"):
return RedirectResponse(url="/qualys/dashboard")
from app.services.qualys_service import compute_vuln_dashboard, is_dashboard_running
if is_dashboard_running():
return RedirectResponse(url="/qualys/dashboard?msg=already_running", status_code=303)
import threading
def _runner():
from app.database import SessionLocal
s = SessionLocal()
try:
compute_vuln_dashboard(s, triggered_by=f"manual:{user.username}")
finally:
s.close()
threading.Thread(target=_runner, daemon=True).start()
return RedirectResponse(url="/qualys/dashboard?msg=refresh_started", status_code=303)
@router.get("/qualys/dashboard/history", response_class=HTMLResponse)
async def qualys_dashboard_history(request: Request, db=Depends(get_db),
period: str = "day", days: int = 30,
dimension: str = "global", dim_value: str = "all"):
user = get_current_user(request)
if not user:
return RedirectResponse(url="/login")
perms = get_user_perms(db, user)
if not can_view(perms, "qualys"):
return RedirectResponse(url="/dashboard")
from app.services.qualys_service import load_vuln_history
if period not in ("day", "week", "month"):
period = "day"
days = max(7, min(365, days))
series = load_vuln_history(db, period=period, days=days,
dimension=dimension, dimension_value=dim_value)
# Liste des dim/value disponibles dans les snapshots pour le selecteur
dim_options = db.execute(text("""SELECT DISTINCT dimension, dimension_value
FROM qualys_vuln_snapshot WHERE dimension IN ('global','env','pos','os')
ORDER BY dimension, dimension_value""")).fetchall()
runs_count = db.execute(text("SELECT COUNT(*) FROM qualys_vuln_snapshot_run WHERE status='ok'")).scalar()
ctx = base_context(request, db, user)
ctx.update({"series": series, "period": period, "days": days,
"dimension": dimension, "dim_value": dim_value,
"dim_options": dim_options, "runs_count": runs_count})
return templates.TemplateResponse("qualys_dashboard_history.html", ctx)

View File

@ -855,3 +855,211 @@ def cancel_refresh():
def is_refresh_running(): def is_refresh_running():
return _refresh_running return _refresh_running
# ===========================================================================
# DASHBOARD VULNERABILITES — agregation par dimension + historique
# Tables: qualys_vuln_snapshot_run (un row par calcul) + qualys_vuln_snapshot
# Dimensions: global, env, pos, os, domain, env_pos
# Niveaux: critical (sev5), high (sev4), medium (sev3), sain, non_scanne
# Un serveur est compte dans son niveau MAX (1 seule colonne)
# ===========================================================================
_dashboard_running = False
def is_dashboard_running():
return _dashboard_running
def _classify_severity(vc):
if not isinstance(vc, dict):
return "sain"
if vc.get("severity5", 0) > 0:
return "critical"
if vc.get("severity4", 0) > 0:
return "high"
if vc.get("severity3", 0) > 0:
return "medium"
return "sain"
def _is_scanned(asset_row, has_vuln_data):
status = (asset_row.agent_status or "").lower()
if status in ("active", "ok", "status_active"):
return True
if has_vuln_data:
return True
return False
def compute_vuln_dashboard(db, triggered_by="manual"):
"""Calcule un nouveau snapshot (insert run + snapshot rows).
Retourne dict {ok, msg, run_id, asset_count, duration_sec}."""
global _dashboard_running
if _dashboard_running:
return {"ok": False, "msg": "Calcul deja en cours", "run_id": None,
"asset_count": 0, "duration_sec": 0}
_dashboard_running = True
import time
t0 = time.time()
run_id = None
try:
# 1. Creer le run en pending
run_id = db.execute(text("""
INSERT INTO qualys_vuln_snapshot_run (status, triggered_by)
VALUES ('pending', :tb) RETURNING id
"""), {"tb": triggered_by}).scalar()
db.commit()
# 2. Charger tous les assets avec leurs tags + domaine AD
rows = db.execute(text("""
SELECT qa.qualys_asset_id, qa.hostname, qa.ip_address, qa.agent_status,
qa.last_checkin, qa.os_family, qa.server_id,
COALESCE(string_agg(DISTINCT qt.name, '|' ORDER BY qt.name), '') as tag_names,
s.domain_ltd
FROM qualys_assets qa
LEFT JOIN qualys_asset_tags qat ON qat.qualys_asset_id = qa.qualys_asset_id
LEFT JOIN qualys_tags qt ON qt.qualys_tag_id = qat.qualys_tag_id
LEFT JOIN servers s ON s.id = qa.server_id
GROUP BY qa.qualys_asset_id, qa.hostname, qa.ip_address, qa.agent_status,
qa.last_checkin, qa.os_family, qa.server_id, s.domain_ltd
""")).fetchall()
asset_count = len(rows)
# 3. Recuperer vulns par batch de 50 IPs (cache 10min via get_vuln_counts)
ip_to_vuln = {}
unique_ips = list({str(r.ip_address) for r in rows
if r.ip_address and str(r.ip_address) != "None"})
for i in range(0, len(unique_ips), 50):
batch = unique_ips[i:i+50]
try:
vmap = get_vuln_counts(db, ",".join(batch))
if vmap:
ip_to_vuln.update(vmap)
except Exception:
pass
# 4. Classifier + agreger
agg = {}
def _bump(dim, val, val2, level):
key = (dim, val or "(none)", val2 or "")
if key not in agg:
agg[key] = {"total": 0, "critical": 0, "high": 0, "medium": 0,
"sain": 0, "non_scanne": 0}
agg[key]["total"] += 1
agg[key][level] += 1
for r in rows:
ip = str(r.ip_address) if r.ip_address else None
vc = ip_to_vuln.get(ip) if ip else None
scanned = _is_scanned(r, vc is not None)
level = "non_scanne" if not scanned else _classify_severity(vc or {})
tags = (r.tag_names or "").split("|") if r.tag_names else []
envs = [t for t in tags if t.startswith("ENV-")]
poses = [t for t in tags if t.startswith("POS-")]
oses = [t for t in tags if t.startswith("OS-")]
dom = r.domain_ltd or "(sans domaine)"
_bump("global", "all", "", level)
for e in envs or ["(sans env)"]:
_bump("env", e, "", level)
for p in poses or ["(sans pos)"]:
_bump("pos", p, "", level)
for o in oses or ["(sans os)"]:
_bump("os", o, "", level)
_bump("domain", dom, "", level)
for e in envs or ["(sans env)"]:
for p in poses or ["(sans pos)"]:
_bump("env_pos", e, p, level)
# 5. Persister les agregats
for (dim, val, val2), counts in agg.items():
db.execute(text("""
INSERT INTO qualys_vuln_snapshot
(run_id, dimension, dimension_value, dimension_value2,
total, critical, high, medium, sain, non_scanne)
VALUES (:rid, :dim, :v1, :v2, :tot, :c, :h, :m, :s, :ns)
"""), {"rid": run_id, "dim": dim, "v1": val, "v2": val2 or None,
"tot": counts["total"], "c": counts["critical"],
"h": counts["high"], "m": counts["medium"],
"s": counts["sain"], "ns": counts["non_scanne"]})
duration = int(time.time() - t0)
db.execute(text("""UPDATE qualys_vuln_snapshot_run
SET status='ok', asset_count=:ac, duration_sec=:d, msg='OK' WHERE id=:rid"""),
{"ac": asset_count, "d": duration, "rid": run_id})
db.commit()
return {"ok": True, "msg": "OK", "run_id": run_id,
"asset_count": asset_count, "duration_sec": duration}
except Exception as ex:
db.rollback()
if run_id:
try:
db.execute(text("""UPDATE qualys_vuln_snapshot_run
SET status='error', msg=:m, duration_sec=:d WHERE id=:rid"""),
{"m": str(ex)[:500], "d": int(time.time() - t0), "rid": run_id})
db.commit()
except Exception:
pass
return {"ok": False, "msg": str(ex), "run_id": run_id,
"asset_count": 0, "duration_sec": int(time.time() - t0)}
finally:
_dashboard_running = False
def load_vuln_dashboard(db):
"""Charge le dernier run reussi + ses snapshots."""
last_run = db.execute(text("""SELECT id, run_at, asset_count, duration_sec, triggered_by
FROM qualys_vuln_snapshot_run WHERE status='ok' ORDER BY run_at DESC LIMIT 1""")).fetchone()
if not last_run:
return {"global": None, "env": [], "pos": [], "os": [], "domain": [],
"env_pos": [], "last_run": None}
rows = db.execute(text("""SELECT dimension, dimension_value, dimension_value2,
total, critical, high, medium, sain, non_scanne
FROM qualys_vuln_snapshot WHERE run_id=:rid
ORDER BY dimension, dimension_value, dimension_value2"""), {"rid": last_run.id}).fetchall()
out = {"global": None, "env": [], "pos": [], "os": [], "domain": [],
"env_pos": [], "last_run": last_run}
for r in rows:
d = {"name": r.dimension_value, "name2": r.dimension_value2,
"total": r.total, "critical": r.critical, "high": r.high,
"medium": r.medium, "sain": r.sain, "non_scanne": r.non_scanne,
"vuln_total": r.critical + r.high + r.medium,
"scanned": r.total - r.non_scanne}
d["pct_vuln"] = round(100 * d["vuln_total"] / d["scanned"], 1) if d["scanned"] > 0 else 0
if r.dimension == "global":
out["global"] = d
elif r.dimension in out:
out[r.dimension].append(d)
return out
def load_vuln_history(db, period="day", days=30, dimension="global", dimension_value="all"):
"""Retourne l'evolution dans le temps.
period: 'day' (1 point/jour), 'week' (lundi), 'month' (1er du mois)
dimension/dimension_value: filtre (defaut: global/all)
Renvoie list de dicts {bucket, total, critical, high, medium, sain, non_scanne}"""
if period == "month":
bucket_expr = "date_trunc('month', r.run_at)"
elif period == "week":
bucket_expr = "date_trunc('week', r.run_at)"
else:
bucket_expr = "date_trunc('day', r.run_at)"
rows = db.execute(text(f"""
SELECT {bucket_expr} as bucket,
s.total, s.critical, s.high, s.medium, s.sain, s.non_scanne,
r.run_at
FROM qualys_vuln_snapshot s
JOIN qualys_vuln_snapshot_run r ON r.id = s.run_id
WHERE r.status='ok' AND r.run_at >= now() - (:days || ' days')::interval
AND s.dimension = :dim AND s.dimension_value = :dv
ORDER BY r.run_at
"""), {"days": days, "dim": dimension, "dv": dimension_value}).fetchall()
# Garder le dernier snapshot de chaque bucket
by_bucket = {}
for r in rows:
by_bucket[r.bucket] = r
return [{"bucket": b.isoformat(), "total": r.total, "critical": r.critical,
"high": r.high, "medium": r.medium, "sain": r.sain,
"non_scanne": r.non_scanne, "vuln_total": r.critical + r.high + r.medium}
for b, r in sorted(by_bucket.items())]

View File

@ -1,217 +1,218 @@
<!DOCTYPE html> <!DOCTYPE html>
<html lang="fr"> <html lang="fr">
<head> <head>
<meta charset="UTF-8"> <meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0"> <meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>{{ app_name }} - {% block title %}{% endblock %}</title> <title>{{ app_name }} - {% block title %}{% endblock %}</title>
<link rel="icon" type="image/svg+xml" href="/static/favicon.svg"> <link rel="icon" type="image/svg+xml" href="/static/favicon.svg">
<link rel="stylesheet" href="/static/css/tailwind.css"> <link rel="stylesheet" href="/static/css/tailwind.css">
<script src="/static/js/htmx.min.js"></script> <script src="/static/js/htmx.min.js"></script>
<script src="/static/js/alpine.min.js" defer></script> <script src="/static/js/alpine.min.js" defer></script>
<style> <style>
body { background: #0a0e17; color: #e2e8f0; font-family: 'Segoe UI', system-ui, sans-serif; } body { background: #0a0e17; color: #e2e8f0; font-family: 'Segoe UI', system-ui, sans-serif; }
.sidebar { background: #111827; border-right: 1px solid #1e3a5f; } .sidebar { background: #111827; border-right: 1px solid #1e3a5f; }
.card { background: #111827; border: 1px solid #1e3a5f; border-radius: 8px; } .card { background: #111827; border: 1px solid #1e3a5f; border-radius: 8px; }
.btn-primary { background: #00d4ff; color: #0a0e17; font-weight: 600; border-radius: 6px; } .btn-primary { background: #00d4ff; color: #0a0e17; font-weight: 600; border-radius: 6px; }
.btn-primary:hover { background: #00b8e6; } .btn-primary:hover { background: #00b8e6; }
.btn-danger { background: #ff3366; color: white; border-radius: 6px; } .btn-danger { background: #ff3366; color: white; border-radius: 6px; }
.btn-sm { padding: 2px 10px; font-size: 0.75rem; border-radius: 4px; cursor: pointer; } .btn-sm { padding: 2px 10px; font-size: 0.75rem; border-radius: 4px; cursor: pointer; }
.table-cyber th { background: #1e3a5f; color: #00d4ff; font-size: 0.7rem; text-transform: uppercase; letter-spacing: 0.05em; } .table-cyber th { background: #1e3a5f; color: #00d4ff; font-size: 0.7rem; text-transform: uppercase; letter-spacing: 0.05em; }
.table-cyber td { border-bottom: 1px solid #1e3a5f; font-size: 0.8rem; } .table-cyber td { border-bottom: 1px solid #1e3a5f; font-size: 0.8rem; }
.table-cyber tr:hover { background: #1a2332; cursor: pointer; } .table-cyber tr:hover { background: #1a2332; cursor: pointer; }
.table-cyber tr.selected { background: #1e3a5f44; } .table-cyber tr.selected { background: #1e3a5f44; }
.badge { padding: 2px 8px; border-radius: 12px; font-size: 0.65rem; font-weight: 600; } .badge { padding: 2px 8px; border-radius: 12px; font-size: 0.65rem; font-weight: 600; }
.badge-green { background: #00ff8822; color: #00ff88; } .badge-green { background: #00ff8822; color: #00ff88; }
.badge-red { background: #ff336622; color: #ff3366; } .badge-red { background: #ff336622; color: #ff3366; }
.badge-yellow { background: #ffcc0022; color: #ffcc00; } .badge-yellow { background: #ffcc0022; color: #ffcc00; }
.badge-blue { background: #00d4ff22; color: #00d4ff; } .badge-blue { background: #00d4ff22; color: #00d4ff; }
.badge-gray { background: #4a556822; color: #94a3b8; } .badge-gray { background: #4a556822; color: #94a3b8; }
input, select, textarea { background: #0a0e17; border: 1px solid #1e3a5f; color: #e2e8f0; border-radius: 6px; padding: 6px 12px; font-size: 0.85rem; } input, select, textarea { background: #0a0e17; border: 1px solid #1e3a5f; color: #e2e8f0; border-radius: 6px; padding: 6px 12px; font-size: 0.85rem; }
input:focus, select:focus, textarea:focus { outline: none; border-color: #00d4ff; box-shadow: 0 0 0 2px #00d4ff33; } input:focus, select:focus, textarea:focus { outline: none; border-color: #00d4ff; box-shadow: 0 0 0 2px #00d4ff33; }
.panel-slide { transition: transform 0.3s ease, opacity 0.3s ease; } .panel-slide { transition: transform 0.3s ease, opacity 0.3s ease; }
.htmx-indicator { opacity: 0; transition: opacity 200ms; } .htmx-indicator { opacity: 0; transition: opacity 200ms; }
.htmx-request .htmx-indicator { opacity: 1; } .htmx-request .htmx-indicator { opacity: 1; }
.inline-edit { background: transparent; border: 1px solid transparent; padding: 2px 4px; } .inline-edit { background: transparent; border: 1px solid transparent; padding: 2px 4px; }
.inline-edit:hover { border-color: #1e3a5f; } .inline-edit:hover { border-color: #1e3a5f; }
.inline-edit:focus { background: #0a0e17; border-color: #00d4ff; } .inline-edit:focus { background: #0a0e17; border-color: #00d4ff; }
.toast { position: fixed; bottom: 20px; right: 20px; padding: 12px 24px; border-radius: 8px; z-index: 1000; animation: fadeIn 0.3s; } .toast { position: fixed; bottom: 20px; right: 20px; padding: 12px 24px; border-radius: 8px; z-index: 1000; animation: fadeIn 0.3s; }
@keyframes fadeIn { from { opacity: 0; transform: translateY(10px); } to { opacity: 1; transform: translateY(0); } } @keyframes fadeIn { from { opacity: 0; transform: translateY(10px); } to { opacity: 1; transform: translateY(0); } }
[x-cloak] { display: none !important; } [x-cloak] { display: none !important; }
</style> </style>
</head> </head>
<body class="min-h-screen" hx-headers='{"X-Requested-With": "htmx"}'> <body class="min-h-screen" hx-headers='{"X-Requested-With": "htmx"}'>
{% if user %} {% if user %}
<div class="flex min-h-screen"> <div class="flex min-h-screen">
<aside class="sidebar w-52 flex-shrink-0 flex flex-col"> <aside class="sidebar w-52 flex-shrink-0 flex flex-col">
<div class="p-4 border-b border-cyber-border"> <div class="p-4 border-b border-cyber-border">
<div class="flex items-center gap-2"> <div class="flex items-center gap-2">
<img src="/static/logo_sanef.jpg" alt="SANEF" class="h-8 rounded" style="opacity:0.85"> <img src="/static/logo_sanef.jpg" alt="SANEF" class="h-8 rounded" style="opacity:0.85">
</div> </div>
<h1 class="text-cyber-accent font-bold text-lg mt-1">PatchCenter</h1> <h1 class="text-cyber-accent font-bold text-lg mt-1">PatchCenter</h1>
<p class="text-xs text-gray-500">v2.0 — SecOps</p> <p class="text-xs text-gray-500">v2.0 — SecOps</p>
</div> </div>
<nav class="flex-1 p-3 space-y-1" x-data='{ <nav class="flex-1 p-3 space-y-1" x-data='{
open: localStorage.getItem("menu_open") || "", open: localStorage.getItem("menu_open") || "",
subOpen: localStorage.getItem("menu_sub_open") || "", subOpen: localStorage.getItem("menu_sub_open") || "",
toggle(k){ this.open = (this.open === k) ? "" : k; this.subOpen = ""; localStorage.setItem("menu_open", this.open); localStorage.setItem("menu_sub_open", ""); }, toggle(k){ this.open = (this.open === k) ? "" : k; this.subOpen = ""; localStorage.setItem("menu_open", this.open); localStorage.setItem("menu_sub_open", ""); },
toggleSub(k){ this.subOpen = (this.subOpen === k) ? "" : k; localStorage.setItem("menu_sub_open", this.subOpen); } toggleSub(k){ this.subOpen = (this.subOpen === k) ? "" : k; localStorage.setItem("menu_sub_open", this.subOpen); }
}'> }'>
{% set p = perms if perms is defined else request.state.perms %} {% set p = perms if perms is defined else request.state.perms %}
{% set path = request.url.path %} {% set path = request.url.path %}
{# Dashboard principal #} {# Dashboard principal #}
{% if p.servers or p.qualys or p.audit or p.planning %}<a href="/dashboard" class="block px-3 py-2 rounded-md text-sm hover:bg-cyber-border/30 {% if 'dashboard' in path %}bg-cyber-border/30 text-cyber-accent{% else %}text-gray-400{% endif %}">Dashboard</a>{% endif %} {% if p.servers or p.qualys or p.audit or p.planning %}<a href="/dashboard" class="block px-3 py-2 rounded-md text-sm hover:bg-cyber-border/30 {% if 'dashboard' in path %}bg-cyber-border/30 text-cyber-accent{% else %}text-gray-400{% endif %}">Dashboard</a>{% endif %}
{# Serveurs (groupe repliable avec Correspondance) #} {# Serveurs (groupe repliable avec Correspondance) #}
{% if p.servers %} {% if p.servers %}
<div> <div>
<button @click="toggle('servers')" class="w-full flex justify-between items-center px-3 py-2 rounded-md text-sm hover:bg-cyber-border/30 text-cyber-accent font-bold"> <button @click="toggle('servers')" class="w-full flex justify-between items-center px-3 py-2 rounded-md text-sm hover:bg-cyber-border/30 text-cyber-accent font-bold">
<span>Serveurs</span> <span>Serveurs</span>
<span x-text="open === 'servers' ? '▾' : '▸'" class="text-xs"></span> <span x-text="open === 'servers' ? '▾' : '▸'" class="text-xs"></span>
</button> </button>
<div x-show="open === 'servers'" x-cloak class="space-y-1 pl-1"> <div x-show="open === 'servers'" x-cloak class="space-y-1 pl-1">
<a href="/servers" class="block px-3 py-1.5 rounded-md text-xs hover:bg-cyber-border/30 {% if path == '/servers' or path.startswith('/servers/') %}bg-cyber-border/30 text-cyber-accent{% else %}text-gray-400{% endif %} pl-6">Liste</a> <a href="/servers" class="block px-3 py-1.5 rounded-md text-xs hover:bg-cyber-border/30 {% if path == '/servers' or path.startswith('/servers/') %}bg-cyber-border/30 text-cyber-accent{% else %}text-gray-400{% endif %} pl-6">Liste</a>
{% if p.campaigns or p.quickwin %}<a href="/patching/correspondance" class="block px-3 py-1.5 rounded-md text-xs hover:bg-cyber-border/30 {% if '/patching/correspondance' in path %}bg-cyber-border/30 text-cyber-accent{% else %}text-gray-400{% endif %} pl-6">Correspondance prod ↔ hors-prod</a>{% endif %} {% if p.campaigns or p.quickwin %}<a href="/patching/correspondance" class="block px-3 py-1.5 rounded-md text-xs hover:bg-cyber-border/30 {% if '/patching/correspondance' in path %}bg-cyber-border/30 text-cyber-accent{% else %}text-gray-400{% endif %} pl-6">Correspondance prod ↔ hors-prod</a>{% endif %}
</div> </div>
</div> </div>
{% endif %} {% endif %}
{# ===== PATCHING (groupe repliable) ===== #} {# ===== PATCHING (groupe repliable) ===== #}
{% if p.campaigns or p.planning or p.quickwin %} {% if p.campaigns or p.planning or p.quickwin %}
<div> <div>
<button @click="toggle('patching')" class="w-full flex justify-between items-center px-3 py-2 rounded-md text-sm hover:bg-cyber-border/30 text-cyber-accent font-bold"> <button @click="toggle('patching')" class="w-full flex justify-between items-center px-3 py-2 rounded-md text-sm hover:bg-cyber-border/30 text-cyber-accent font-bold">
<span>Patching</span> <span>Patching</span>
<span x-text="open === 'patching' ? '▾' : '▸'" class="text-xs"></span> <span x-text="open === 'patching' ? '▾' : '▸'" class="text-xs"></span>
</button> </button>
<div x-show="open === 'patching'" x-cloak class="space-y-1 pl-1"> <div x-show="open === 'patching'" x-cloak class="space-y-1 pl-1">
{% if p.planning %}<a href="/planning" class="block px-3 py-1.5 rounded-md text-xs hover:bg-cyber-border/30 {% if 'planning' in path %}bg-cyber-border/30 text-cyber-accent{% else %}text-gray-400{% endif %} pl-6">Planning</a>{% endif %} {% if p.planning %}<a href="/planning" class="block px-3 py-1.5 rounded-md text-xs hover:bg-cyber-border/30 {% if 'planning' in path %}bg-cyber-border/30 text-cyber-accent{% else %}text-gray-400{% endif %} pl-6">Planning</a>{% endif %}
{% if p.campaigns in ('edit', 'admin') %}<a href="/assignments" class="block px-3 py-1.5 rounded-md text-xs hover:bg-cyber-border/30 {% if 'assignments' in path %}bg-cyber-border/30 text-cyber-accent{% else %}text-gray-400{% endif %} pl-6">Assignation</a>{% endif %} {% if p.campaigns in ('edit', 'admin') %}<a href="/assignments" class="block px-3 py-1.5 rounded-md text-xs hover:bg-cyber-border/30 {% if 'assignments' in path %}bg-cyber-border/30 text-cyber-accent{% else %}text-gray-400{% endif %} pl-6">Assignation</a>{% endif %}
{% if p.campaigns %}<a href="/campaigns" class="block px-3 py-1.5 rounded-md text-xs hover:bg-cyber-border/30 {% if 'campaigns' in path and 'assignments' not in path %}bg-cyber-border/30 text-cyber-accent{% else %}text-gray-400{% endif %} pl-6">Campagnes</a>{% endif %} {% if p.campaigns %}<a href="/campaigns" class="block px-3 py-1.5 rounded-md text-xs hover:bg-cyber-border/30 {% if 'campaigns' in path and 'assignments' not in path %}bg-cyber-border/30 text-cyber-accent{% else %}text-gray-400{% endif %} pl-6">Campagnes</a>{% endif %}
{% if p.servers in ('edit','admin') or p.campaigns in ('edit','admin') or p.quickwin in ('edit','admin') %}<a href="/patching/config-exclusions" class="block px-3 py-1.5 rounded-md text-xs hover:bg-cyber-border/30 {% if 'config-exclusions' in path %}bg-cyber-border/30 text-cyber-accent{% else %}text-gray-400{% endif %} pl-6">Config exclusions</a>{% endif %} {% if p.servers in ('edit','admin') or p.campaigns in ('edit','admin') or p.quickwin in ('edit','admin') %}<a href="/patching/config-exclusions" class="block px-3 py-1.5 rounded-md text-xs hover:bg-cyber-border/30 {% if 'config-exclusions' in path %}bg-cyber-border/30 text-cyber-accent{% else %}text-gray-400{% endif %} pl-6">Config exclusions</a>{% endif %}
{% if p.campaigns in ('edit','admin') or p.quickwin in ('edit','admin') %}<a href="/patching/validations" class="block px-3 py-1.5 rounded-md text-xs hover:bg-cyber-border/30 {% if '/patching/validations' in path %}bg-cyber-border/30 text-cyber-accent{% else %}text-gray-400{% endif %} pl-6">Validations</a>{% endif %} {% if p.campaigns in ('edit','admin') or p.quickwin in ('edit','admin') %}<a href="/patching/validations" class="block px-3 py-1.5 rounded-md text-xs hover:bg-cyber-border/30 {% if '/patching/validations' in path %}bg-cyber-border/30 text-cyber-accent{% else %}text-gray-400{% endif %} pl-6">Validations</a>{% endif %}
<a href="/patching/historique" class="block px-3 py-1.5 rounded-md text-xs hover:bg-cyber-border/30 {% if '/patching/historique' in path %}bg-cyber-border/30 text-cyber-accent{% else %}text-gray-400{% endif %} pl-6">Historique</a> <a href="/patching/historique" class="block px-3 py-1.5 rounded-md text-xs hover:bg-cyber-border/30 {% if '/patching/historique' in path %}bg-cyber-border/30 text-cyber-accent{% else %}text-gray-400{% endif %} pl-6">Historique</a>
<a href="/duty" class="block px-3 py-1.5 rounded-md text-xs hover:bg-cyber-border/30 {% if '/duty' in path %}bg-cyber-border/30 text-cyber-accent{% else %}text-gray-400{% endif %} pl-6">Tour de garde</a> <a href="/duty" class="block px-3 py-1.5 rounded-md text-xs hover:bg-cyber-border/30 {% if '/duty' in path %}bg-cyber-border/30 text-cyber-accent{% else %}text-gray-400{% endif %} pl-6">Tour de garde</a>
{# Quickwin sous-groupe #} {# Quickwin sous-groupe #}
{% if p.campaigns or p.quickwin %} {% if p.campaigns or p.quickwin %}
<div> <div>
<button @click="toggleSub('quickwin')" class="w-full flex justify-between items-center px-3 py-1.5 rounded-md text-xs hover:bg-cyber-border/30 pl-6 {% if 'quickwin' in path %}text-cyber-accent{% else %}text-gray-400{% endif %}"> <button @click="toggleSub('quickwin')" class="w-full flex justify-between items-center px-3 py-1.5 rounded-md text-xs hover:bg-cyber-border/30 pl-6 {% if 'quickwin' in path %}text-cyber-accent{% else %}text-gray-400{% endif %}">
<span>QuickWin</span> <span>QuickWin</span>
<span x-text="subOpen === 'quickwin' ? '▾' : '▸'" class="text-xs opacity-60"></span> <span x-text="subOpen === 'quickwin' ? '▾' : '▸'" class="text-xs opacity-60"></span>
</button> </button>
<div x-show="subOpen === 'quickwin'" x-cloak class="space-y-1"> <div x-show="subOpen === 'quickwin'" x-cloak class="space-y-1">
<a href="/quickwin" style="padding-left:3rem" class="block py-1 pr-3 rounded-md text-xs hover:bg-cyber-border/30 {% if 'quickwin' in path and 'config' not in path and 'correspondance' not in path %}bg-cyber-border/30 text-cyber-accent{% else %}text-gray-500{% endif %}">Vue d'ensemble</a> <a href="/quickwin" style="padding-left:3rem" class="block py-1 pr-3 rounded-md text-xs hover:bg-cyber-border/30 {% if 'quickwin' in path and 'config' not in path and 'correspondance' not in path %}bg-cyber-border/30 text-cyber-accent{% else %}text-gray-500{% endif %}">Vue d'ensemble</a>
{% if p.quickwin in ('edit', 'admin') or p.campaigns in ('edit', 'admin') %} {% if p.quickwin in ('edit', 'admin') or p.campaigns in ('edit', 'admin') %}
<a href="/quickwin/config" style="padding-left:3rem" class="block py-1 pr-3 rounded-md text-xs hover:bg-cyber-border/30 {% if '/quickwin/config' in path %}bg-cyber-border/30 text-cyber-accent{% else %}text-gray-500{% endif %}">Config exclusion</a> <a href="/quickwin/config" style="padding-left:3rem" class="block py-1 pr-3 rounded-md text-xs hover:bg-cyber-border/30 {% if '/quickwin/config' in path %}bg-cyber-border/30 text-cyber-accent{% else %}text-gray-500{% endif %}">Config exclusion</a>
{% endif %} {% endif %}
</div> </div>
</div> </div>
{% endif %} {% endif %}
</div> </div>
</div> </div>
{% endif %} {% endif %}
{# ===== AUDIT (au meme niveau que Patching, repliable) ===== #} {# ===== AUDIT (au meme niveau que Patching, repliable) ===== #}
{% if p.audit %} {% if p.audit %}
<div> <div>
<button @click="toggle('audit')" class="w-full flex justify-between items-center px-3 py-2 rounded-md text-sm hover:bg-cyber-border/30 text-cyber-accent font-bold"> <button @click="toggle('audit')" class="w-full flex justify-between items-center px-3 py-2 rounded-md text-sm hover:bg-cyber-border/30 text-cyber-accent font-bold">
<span>Audit</span> <span>Audit</span>
<span x-text="open === 'audit' ? '▾' : '▸'" class="text-xs"></span> <span x-text="open === 'audit' ? '▾' : '▸'" class="text-xs"></span>
</button> </button>
<div x-show="open === 'audit'" x-cloak class="space-y-1 pl-1"> <div x-show="open === 'audit'" x-cloak class="space-y-1 pl-1">
<a href="/audit" class="block px-3 py-1.5 rounded-md text-xs hover:bg-cyber-border/30 {% if path == '/audit' %}bg-cyber-border/30 text-cyber-accent{% else %}text-gray-400{% endif %} pl-6">Audit global</a> <a href="/audit" class="block px-3 py-1.5 rounded-md text-xs hover:bg-cyber-border/30 {% if path == '/audit' %}bg-cyber-border/30 text-cyber-accent{% else %}text-gray-400{% endif %} pl-6">Audit global</a>
{% if p.audit in ('edit', 'admin') %}<a href="/audit/specific" class="block px-3 py-1.5 rounded-md text-xs hover:bg-cyber-border/30 {% if 'specific' in path %}bg-cyber-border/30 text-cyber-accent{% else %}text-gray-400{% endif %} pl-6">Spécifique</a>{% endif %} {% if p.audit in ('edit', 'admin') %}<a href="/audit/specific" class="block px-3 py-1.5 rounded-md text-xs hover:bg-cyber-border/30 {% if 'specific' in path %}bg-cyber-border/30 text-cyber-accent{% else %}text-gray-400{% endif %} pl-6">Spécifique</a>{% endif %}
</div> </div>
</div> </div>
{% endif %} {% endif %}
{# ===== QUALYS (groupe repliable) ===== #} {# ===== QUALYS (groupe repliable) ===== #}
{% if p.qualys %} {% if p.qualys %}
<div> <div>
<button @click="toggle('qualys')" class="w-full flex justify-between items-center px-3 py-2 rounded-md text-sm hover:bg-cyber-border/30 text-cyber-accent font-bold"> <button @click="toggle('qualys')" class="w-full flex justify-between items-center px-3 py-2 rounded-md text-sm hover:bg-cyber-border/30 text-cyber-accent font-bold">
<span>Qualys</span> <span>Qualys</span>
<span x-text="open === 'qualys' ? '▾' : '▸'" class="text-xs"></span> <span x-text="open === 'qualys' ? '▾' : '▸'" class="text-xs"></span>
</button> </button>
<div x-show="open === 'qualys'" x-cloak class="space-y-1 pl-1"> <div x-show="open === 'qualys'" x-cloak class="space-y-1 pl-1">
<a href="/qualys/search" class="block px-3 py-1.5 rounded-md text-xs hover:bg-cyber-border/30 {% if '/qualys/search' in path %}bg-cyber-border/30 text-cyber-accent{% else %}text-gray-400{% endif %} pl-6">Recherche</a> <a href="/qualys/dashboard" class="block px-3 py-1.5 rounded-md text-xs hover:bg-cyber-border/30 {% if '/qualys/dashboard' in path %}bg-cyber-border/30 text-cyber-accent{% else %}text-gray-400{% endif %} pl-6">📊 Dashboard Vulns</a>
<a href="/qualys/tags" class="block px-3 py-1.5 rounded-md text-xs hover:bg-cyber-border/30 {% if '/qualys/tags' in path and '/qualys/tagsv3' not in path %}bg-cyber-border/30 text-cyber-accent{% else %}text-gray-400{% endif %} pl-6">Tags</a> <a href="/qualys/search" class="block px-3 py-1.5 rounded-md text-xs hover:bg-cyber-border/30 {% if '/qualys/search' in path %}bg-cyber-border/30 text-cyber-accent{% else %}text-gray-400{% endif %} pl-6">Recherche</a>
<a href="/qualys/tagsv3" class="block px-3 py-1.5 rounded-md text-xs hover:bg-cyber-border/30 {% if '/qualys/tagsv3' in path and '/catalog' not in path and '/gap' not in path %}bg-cyber-border/30 text-cyber-accent{% else %}text-gray-400{% endif %} pl-8">↳ Tags V3 (vue)</a> <a href="/qualys/tags" class="block px-3 py-1.5 rounded-md text-xs hover:bg-cyber-border/30 {% if '/qualys/tags' in path and '/qualys/tagsv3' not in path %}bg-cyber-border/30 text-cyber-accent{% else %}text-gray-400{% endif %} pl-6">Tags</a>
<a href="/qualys/tagsv3/catalog" class="block px-3 py-1.5 rounded-md text-xs hover:bg-cyber-border/30 {% if '/qualys/tagsv3/catalog' in path %}bg-cyber-border/30 text-cyber-accent{% else %}text-gray-400{% endif %} pl-8">↳ Tags V3 (catalogue)</a> <a href="/qualys/tagsv3" class="block px-3 py-1.5 rounded-md text-xs hover:bg-cyber-border/30 {% if '/qualys/tagsv3' in path and '/catalog' not in path and '/gap' not in path %}bg-cyber-border/30 text-cyber-accent{% else %}text-gray-400{% endif %} pl-8">↳ Tags V3 (vue)</a>
<a href="/qualys/tagsv3/gap" class="block px-3 py-1.5 rounded-md text-xs hover:bg-cyber-border/30 {% if '/qualys/tagsv3/gap' in path %}bg-cyber-border/30 text-cyber-accent{% else %}text-gray-400{% endif %} pl-8">↳ Tags V3 (gap)</a> <a href="/qualys/tagsv3/catalog" class="block px-3 py-1.5 rounded-md text-xs hover:bg-cyber-border/30 {% if '/qualys/tagsv3/catalog' in path %}bg-cyber-border/30 text-cyber-accent{% else %}text-gray-400{% endif %} pl-8">↳ Tags V3 (catalogue)</a>
<a href="/qualys/agents" class="block px-3 py-1.5 rounded-md text-xs hover:bg-cyber-border/30 {% if 'agents' in path and 'deploy' not in path %}bg-cyber-border/30 text-cyber-accent{% else %}text-gray-400{% endif %} pl-6">Agents</a> <a href="/qualys/tagsv3/gap" class="block px-3 py-1.5 rounded-md text-xs hover:bg-cyber-border/30 {% if '/qualys/tagsv3/gap' in path %}bg-cyber-border/30 text-cyber-accent{% else %}text-gray-400{% endif %} pl-8">↳ Tags V3 (gap)</a>
{% if p.qualys in ('edit', 'admin') %}<a href="/qualys/deploy" class="block px-3 py-1.5 rounded-md text-xs hover:bg-cyber-border/30 {% if 'deploy' in path %}bg-cyber-border/30 text-cyber-accent{% else %}text-gray-400{% endif %} pl-6">Déployer Agent</a>{% endif %} <a href="/qualys/agents" class="block px-3 py-1.5 rounded-md text-xs hover:bg-cyber-border/30 {% if 'agents' in path and 'deploy' not in path %}bg-cyber-border/30 text-cyber-accent{% else %}text-gray-400{% endif %} pl-6">Agents</a>
</div> {% if p.qualys in ('edit', 'admin') %}<a href="/qualys/deploy" class="block px-3 py-1.5 rounded-md text-xs hover:bg-cyber-border/30 {% if 'deploy' in path %}bg-cyber-border/30 text-cyber-accent{% else %}text-gray-400{% endif %} pl-6">Déployer Agent</a>{% endif %}
</div> </div>
{% endif %} </div>
{% endif %}
{# ===== ADMIN (groupe repliable) ===== #}
{% if p.users or p.settings or p.servers or p.contacts %} {# ===== ADMIN (groupe repliable) ===== #}
<div> {% if p.users or p.settings or p.servers or p.contacts %}
<button @click="toggle('admin')" class="w-full flex justify-between items-center px-3 py-2 rounded-md text-sm hover:bg-cyber-border/30 text-cyber-accent font-bold"> <div>
<span>Administration</span> <button @click="toggle('admin')" class="w-full flex justify-between items-center px-3 py-2 rounded-md text-sm hover:bg-cyber-border/30 text-cyber-accent font-bold">
<span x-text="open === 'admin' ? '▾' : '▸'" class="text-xs"></span> <span>Administration</span>
</button> <span x-text="open === 'admin' ? '▾' : '▸'" class="text-xs"></span>
<div x-show="open === 'admin'" x-cloak class="space-y-1 pl-1"> </button>
{% if p.servers or p.contacts %}<a href="/contacts" class="block px-3 py-1.5 rounded-md text-xs hover:bg-cyber-border/30 {% if 'contacts' in path %}bg-cyber-border/30 text-cyber-accent{% else %}text-gray-400{% endif %} pl-6">Contacts</a>{% endif %} <div x-show="open === 'admin'" x-cloak class="space-y-1 pl-1">
{% if p.users %}<a href="/users" class="block px-3 py-1.5 rounded-md text-xs hover:bg-cyber-border/30 {% if path == '/users' or path.startswith('/users/') %}bg-cyber-border/30 text-cyber-accent{% else %}text-gray-400{% endif %} pl-6">Utilisateurs</a>{% endif %} {% if p.servers or p.contacts %}<a href="/contacts" class="block px-3 py-1.5 rounded-md text-xs hover:bg-cyber-border/30 {% if 'contacts' in path %}bg-cyber-border/30 text-cyber-accent{% else %}text-gray-400{% endif %} pl-6">Contacts</a>{% endif %}
{% if p.settings or p.users %}<a href="/admin/applications" class="block px-3 py-1.5 rounded-md text-xs hover:bg-cyber-border/30 {% if '/admin/applications' in path %}bg-cyber-border/30 text-cyber-accent{% else %}text-gray-400{% endif %} pl-6">Applications</a>{% endif %} {% if p.users %}<a href="/users" class="block px-3 py-1.5 rounded-md text-xs hover:bg-cyber-border/30 {% if path == '/users' or path.startswith('/users/') %}bg-cyber-border/30 text-cyber-accent{% else %}text-gray-400{% endif %} pl-6">Utilisateurs</a>{% endif %}
{% if p.settings %}<a href="/settings" class="block px-3 py-1.5 rounded-md text-xs hover:bg-cyber-border/30 {% if 'settings' in path %}bg-cyber-border/30 text-cyber-accent{% else %}text-gray-400{% endif %} pl-6">Settings</a>{% endif %} {% if p.settings or p.users %}<a href="/admin/applications" class="block px-3 py-1.5 rounded-md text-xs hover:bg-cyber-border/30 {% if '/admin/applications' in path %}bg-cyber-border/30 text-cyber-accent{% else %}text-gray-400{% endif %} pl-6">Applications</a>{% endif %}
{% if p.settings or p.referentiel %}<a href="/referentiel" class="block px-3 py-1.5 rounded-md text-xs hover:bg-cyber-border/30 {% if 'referentiel' in path %}bg-cyber-border/30 text-cyber-accent{% else %}text-gray-400{% endif %} pl-6">Référentiel</a>{% endif %} {% if p.settings %}<a href="/settings" class="block px-3 py-1.5 rounded-md text-xs hover:bg-cyber-border/30 {% if 'settings' in path %}bg-cyber-border/30 text-cyber-accent{% else %}text-gray-400{% endif %} pl-6">Settings</a>{% endif %}
</div> {% if p.settings or p.referentiel %}<a href="/referentiel" class="block px-3 py-1.5 rounded-md text-xs hover:bg-cyber-border/30 {% if 'referentiel' in path %}bg-cyber-border/30 text-cyber-accent{% else %}text-gray-400{% endif %} pl-6">Référentiel</a>{% endif %}
</div> </div>
{% endif %} </div>
</nav> {% endif %}
</aside> </nav>
<main class="flex-1 flex flex-col overflow-hidden"> </aside>
<!-- Top bar --> <main class="flex-1 flex flex-col overflow-hidden">
<header class="flex items-center justify-end px-6 py-2 border-b border-cyber-border bg-cyber-card"> <!-- Top bar -->
<div class="flex items-center gap-3"> <header class="flex items-center justify-end px-6 py-2 border-b border-cyber-border bg-cyber-card">
<span class="text-sm text-cyber-accent font-medium">{{ user.display or user.sub }}</span> <div class="flex items-center gap-3">
{% if user.auth == 'ldap' %}<span class="text-xs text-gray-500">(AD)</span>{% endif %} <span class="text-sm text-cyber-accent font-medium">{{ user.display or user.sub }}</span>
<span class="text-xs text-gray-500">·</span> {% if user.auth == 'ldap' %}<span class="text-xs text-gray-500">(AD)</span>{% endif %}
<span class="text-xs text-gray-400">{{ user.sub }}</span> <span class="text-xs text-gray-500">·</span>
<span class="badge badge-blue">{{ user.role }}</span> <span class="text-xs text-gray-400">{{ user.sub }}</span>
<a href="/logout" class="btn-sm bg-cyber-border text-gray-300 hover:bg-red-900/40 hover:text-cyber-red transition-colors">Deconnexion</a> <span class="badge badge-blue">{{ user.role }}</span>
</div> <a href="/logout" class="btn-sm bg-cyber-border text-gray-300 hover:bg-red-900/40 hover:text-cyber-red transition-colors">Deconnexion</a>
</header> </div>
<div class="flex flex-1 overflow-hidden"> </header>
<div class="flex-1 p-6 overflow-auto" id="main-content"> <div class="flex flex-1 overflow-hidden">
{% block content %}{% endblock %} <div class="flex-1 p-6 overflow-auto" id="main-content">
</div> {% block content %}{% endblock %}
<div id="detail-panel" class="w-0 overflow-hidden transition-all duration-300 border-l border-cyber-border bg-cyber-card"> </div>
</div> <div id="detail-panel" class="w-0 overflow-hidden transition-all duration-300 border-l border-cyber-border bg-cyber-card">
</div> </div>
</main> </div>
</div> </main>
{% else %} </div>
{% block fullpage %}{% endblock %} {% else %}
{% endif %} {% block fullpage %}{% endblock %}
<div id="toast-container"></div> {% endif %}
<!-- Overlay chargement --> <div id="toast-container"></div>
<div id="loading-overlay" style="display:none; position:fixed; top:0; left:0; width:100%; height:100%; background:rgba(10,14,23,0.85); z-index:9999; justify-content:center; align-items:center;"> <!-- Overlay chargement -->
<div style="text-align:center"> <div id="loading-overlay" style="display:none; position:fixed; top:0; left:0; width:100%; height:100%; background:rgba(10,14,23,0.85); z-index:9999; justify-content:center; align-items:center;">
<div style="border:3px solid #1e3a5f; border-top:3px solid #00d4ff; border-radius:50%; width:40px; height:40px; animation:spin 1s linear infinite; margin:0 auto 16px"></div> <div style="text-align:center">
<div id="loading-msg" style="color:#00d4ff; font-size:14px; font-weight:600">Opération en cours...</div> <div style="border:3px solid #1e3a5f; border-top:3px solid #00d4ff; border-radius:50%; width:40px; height:40px; animation:spin 1s linear infinite; margin:0 auto 16px"></div>
<div id="loading-sub" style="color:#94a3b8; font-size:12px; margin-top:6px"></div> <div id="loading-msg" style="color:#00d4ff; font-size:14px; font-weight:600">Opération en cours...</div>
</div> <div id="loading-sub" style="color:#94a3b8; font-size:12px; margin-top:6px"></div>
</div> </div>
<style>@keyframes spin { 0% { transform: rotate(0deg); } 100% { transform: rotate(360deg); } }</style> </div>
<script> <style>@keyframes spin { 0% { transform: rotate(0deg); } 100% { transform: rotate(360deg); } }</style>
function showLoading(msg, sub) { <script>
document.getElementById('loading-msg').textContent = msg || 'Opération en cours...'; function showLoading(msg, sub) {
document.getElementById('loading-sub').textContent = sub || ''; document.getElementById('loading-msg').textContent = msg || 'Opération en cours...';
document.getElementById('loading-overlay').style.display = 'flex'; document.getElementById('loading-sub').textContent = sub || '';
} document.getElementById('loading-overlay').style.display = 'flex';
function hideLoading() { document.getElementById('loading-overlay').style.display = 'none'; } }
// Auto-attach: tout bouton avec data-loading affiche l'overlay au clic function hideLoading() { document.getElementById('loading-overlay').style.display = 'none'; }
document.addEventListener('DOMContentLoaded', function() { // Auto-attach: tout bouton avec data-loading affiche l'overlay au clic
document.querySelectorAll('[data-loading]').forEach(function(btn) { document.addEventListener('DOMContentLoaded', function() {
btn.addEventListener('click', function(e) { document.querySelectorAll('[data-loading]').forEach(function(btn) {
var parts = (btn.dataset.loading || 'Opération en cours...|').split('|'); btn.addEventListener('click', function(e) {
showLoading(parts[0], parts[1] || ''); var parts = (btn.dataset.loading || 'Opération en cours...|').split('|');
}); showLoading(parts[0], parts[1] || '');
}); });
}); });
</script> });
</body> </script>
</html> </body>
</html>

View File

@ -0,0 +1,163 @@
{% extends 'base.html' %}
{% block title %}Dashboard Vulnérabilités{% endblock %}
{% block content %}
{% set msg = request.query_params.get('msg', '') %}
{% if msg == 'refresh_started' %}
<div class="card p-3 mb-4 bg-blue-900/20 border-blue-500/40 text-blue-300 text-sm">
Recalcul lancé en arrière-plan. Rafraîchis la page dans 1-2 minutes pour voir les nouveaux chiffres.
</div>
{% elif msg == 'already_running' %}
<div class="card p-3 mb-4 bg-yellow-900/20 border-yellow-500/40 text-yellow-300 text-sm">
Un calcul est déjà en cours. Patiente puis rafraîchis.
</div>
{% endif %}
<div class="flex justify-between items-center mb-4">
<h2 class="text-xl font-bold text-cyber-accent">Dashboard Vulnérabilités</h2>
<div class="flex gap-2 items-center">
{% if data.last_run %}
<span class="text-xs text-gray-500">
Dernier calcul : {{ data.last_run.run_at.strftime('%Y-%m-%d %H:%M') }}
({{ data.last_run.asset_count }} assets, {{ data.last_run.duration_sec }}s)
</span>
{% endif %}
<a href="/qualys/dashboard/history" class="btn-sm bg-cyber-border text-cyber-accent">📈 Historique</a>
<form method="POST" action="/qualys/dashboard/refresh" style="display:inline">
<button class="btn-sm bg-cyber-accent text-black" {% if is_running %}disabled{% endif %}
data-loading="Recalcul en cours...|Calcul des KPI sur tous les assets">
{% if is_running %}⏳ En cours...{% else %}🔄 Recalculer{% endif %}
</button>
</form>
</div>
</div>
{% if not data.last_run %}
<div class="card p-6 text-center">
<p class="text-gray-400 mb-4">Aucun snapshot disponible. Lance un premier calcul.</p>
<form method="POST" action="/qualys/dashboard/refresh">
<button class="btn-primary px-6 py-2">🔄 Lancer le premier calcul</button>
</form>
</div>
{% else %}
<!-- KPI bandeau (6 cards) -->
{% set g = data.global %}
<div class="grid grid-cols-6 gap-2 mb-4">
<div class="card p-3 border-cyber-accent">
<div class="text-xs text-gray-500 uppercase">Total</div>
<div class="text-2xl font-bold text-cyber-accent">{{ g.total if g else 0 }}</div>
<div class="text-xs text-gray-500">serveurs</div>
</div>
<a href="/qualys/search?vuln_filter=critical" class="card p-3 border-red-700 hover:border-red-500 transition">
<div class="text-xs text-gray-500 uppercase">🔴 Critique (sev 5)</div>
<div class="text-2xl font-bold text-red-500">{{ g.critical if g else 0 }}</div>
<div class="text-xs text-gray-500">{{ ((g.critical * 100 / g.scanned) | round(1)) if g and g.scanned > 0 else 0 }}%</div>
</a>
<a href="/qualys/search?vuln_filter=high" class="card p-3 border-orange-700 hover:border-orange-500 transition">
<div class="text-xs text-gray-500 uppercase">🟠 High (sev 4)</div>
<div class="text-2xl font-bold text-orange-500">{{ g.high if g else 0 }}</div>
<div class="text-xs text-gray-500">{{ ((g.high * 100 / g.scanned) | round(1)) if g and g.scanned > 0 else 0 }}%</div>
</a>
<a href="/qualys/search?vuln_filter=medium" class="card p-3 border-yellow-700 hover:border-yellow-500 transition">
<div class="text-xs text-gray-500 uppercase">🟡 Medium (sev 3)</div>
<div class="text-2xl font-bold text-yellow-500">{{ g.medium if g else 0 }}</div>
<div class="text-xs text-gray-500">{{ ((g.medium * 100 / g.scanned) | round(1)) if g and g.scanned > 0 else 0 }}%</div>
</a>
<a href="/qualys/search?vuln_filter=zero" class="card p-3 border-green-700 hover:border-green-500 transition">
<div class="text-xs text-gray-500 uppercase">🟢 Sans vuln</div>
<div class="text-2xl font-bold text-green-500">{{ g.sain if g else 0 }}</div>
<div class="text-xs text-gray-500">{{ ((g.sain * 100 / g.scanned) | round(1)) if g and g.scanned > 0 else 0 }}%</div>
</a>
<div class="card p-3 border-gray-700">
<div class="text-xs text-gray-500 uppercase">⚫ Non scanné</div>
<div class="text-2xl font-bold text-gray-400">{{ g.non_scanne if g else 0 }}</div>
<div class="text-xs text-gray-500">{{ ((g.non_scanne * 100 / g.total) | round(1)) if g and g.total > 0 else 0 }}% du total</div>
</div>
</div>
{% macro pivot_table(title, items, tag_prefix='') %}
<div class="card p-3 mb-4">
<h3 class="text-sm font-bold text-cyber-accent mb-2">{{ title }}</h3>
<table class="w-full text-xs">
<thead class="text-gray-400 border-b border-cyber-border">
<tr>
<th class="text-left py-1">Catégorie</th>
<th class="text-right py-1">Total</th>
<th class="text-right py-1 text-red-400">🔴 Critique</th>
<th class="text-right py-1 text-orange-400">🟠 High</th>
<th class="text-right py-1 text-yellow-400">🟡 Medium</th>
<th class="text-right py-1 text-green-400">🟢 Sain</th>
<th class="text-right py-1 text-gray-500">⚫ Non scanné</th>
<th class="text-right py-1">% vuln</th>
</tr>
</thead>
<tbody>
{% for it in items|sort(attribute='vuln_total', reverse=true) %}
<tr class="border-b border-cyber-border/30 hover:bg-cyber-card/50">
<td class="py-1 font-mono">{{ it.name }}</td>
<td class="text-right py-1">{{ it.total }}</td>
<td class="text-right py-1">
{% if it.critical > 0 %}<a href="/qualys/search?field=tag&search={{ it.name }}&vuln_filter=critical" class="text-red-400 hover:underline">{{ it.critical }}</a>{% else %}-{% endif %}
</td>
<td class="text-right py-1">
{% if it.high > 0 %}<a href="/qualys/search?field=tag&search={{ it.name }}&vuln_filter=high" class="text-orange-400 hover:underline">{{ it.high }}</a>{% else %}-{% endif %}
</td>
<td class="text-right py-1">
{% if it.medium > 0 %}<a href="/qualys/search?field=tag&search={{ it.name }}&vuln_filter=medium" class="text-yellow-400 hover:underline">{{ it.medium }}</a>{% else %}-{% endif %}
</td>
<td class="text-right py-1">
{% if it.sain > 0 %}<a href="/qualys/search?field=tag&search={{ it.name }}&vuln_filter=zero" class="text-green-400 hover:underline">{{ it.sain }}</a>{% else %}-{% endif %}
</td>
<td class="text-right py-1 text-gray-500">{{ it.non_scanne if it.non_scanne > 0 else '-' }}</td>
<td class="text-right py-1 font-bold {% if it.pct_vuln >= 50 %}text-red-400{% elif it.pct_vuln >= 25 %}text-orange-400{% else %}text-green-400{% endif %}">
{{ it.pct_vuln }}%
</td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
{% endmacro %}
{{ pivot_table('Par environnement (ENV-*)', data.env, 'ENV-') }}
{{ pivot_table('Par position réseau (POS-*)', data.pos, 'POS-') }}
{{ pivot_table('Par OS (OS-*)', data.os, 'OS-') }}
{{ pivot_table('Par domaine AD', data.domain, '') }}
<!-- Matrice ENV x POS heatmap -->
<div class="card p-3 mb-4">
<h3 class="text-sm font-bold text-cyber-accent mb-2">Matrice Environnement × Position (% vuln)</h3>
<table class="w-full text-xs">
<thead>
<tr>
<th class="text-left py-1 text-gray-400">ENV \ POS</th>
{% for p in pos_list %}<th class="text-center py-1 text-gray-400 font-mono">{{ p }}</th>{% endfor %}
</tr>
</thead>
<tbody>
{% for e in env_list %}
<tr>
<td class="py-1 font-mono text-cyber-accent">{{ e }}</td>
{% for p in pos_list %}
{% set cell = matrix.get((e,p)) %}
{% if cell and cell.total > 0 %}
<td class="text-center py-1 px-1 {% if cell.pct_vuln >= 50 %}bg-red-900/40{% elif cell.pct_vuln >= 25 %}bg-orange-900/30{% elif cell.pct_vuln > 0 %}bg-yellow-900/20{% else %}bg-green-900/20{% endif %}">
<a href="/qualys/search?field=tag&search={{ e }}" class="hover:underline">
<div class="font-bold">{{ cell.vuln_total }}/{{ cell.scanned }}</div>
<div class="text-[10px] text-gray-500">{{ cell.pct_vuln }}%</div>
</a>
</td>
{% else %}
<td class="text-center py-1 px-1 text-gray-700">-</td>
{% endif %}
{% endfor %}
</tr>
{% endfor %}
</tbody>
</table>
<p class="text-xs text-gray-500 mt-2">Format : <strong>vulnérables / scannés</strong> — couleur = % vulnérables (rouge ≥50%, orange ≥25%, jaune &gt;0)</p>
</div>
{% endif %}
{% endblock %}

View File

@ -0,0 +1,124 @@
{% extends 'base.html' %}
{% block title %}Historique Vulnérabilités{% endblock %}
{% block content %}
<div class="flex justify-between items-center mb-4">
<h2 class="text-xl font-bold text-cyber-accent">Historique Vulnérabilités</h2>
<a href="/qualys/dashboard" class="btn-sm bg-cyber-border text-cyber-accent">← Dashboard</a>
</div>
<form method="GET" class="card p-3 mb-4 flex gap-3 items-end flex-wrap">
<div>
<label class="text-xs text-gray-500">Granularité</label>
<select name="period" class="text-xs py-1 px-2">
<option value="day" {% if period == 'day' %}selected{% endif %}>Journalier</option>
<option value="week" {% if period == 'week' %}selected{% endif %}>Hebdomadaire (lundi)</option>
<option value="month" {% if period == 'month' %}selected{% endif %}>Mensuel</option>
</select>
</div>
<div>
<label class="text-xs text-gray-500">Période (jours)</label>
<select name="days" class="text-xs py-1 px-2">
<option value="7" {% if days == 7 %}selected{% endif %}>7 jours</option>
<option value="30" {% if days == 30 %}selected{% endif %}>30 jours</option>
<option value="90" {% if days == 90 %}selected{% endif %}>90 jours</option>
<option value="180" {% if days == 180 %}selected{% endif %}>180 jours</option>
<option value="365" {% if days == 365 %}selected{% endif %}>365 jours</option>
</select>
</div>
<div>
<label class="text-xs text-gray-500">Périmètre</label>
<select name="dim_value" class="text-xs py-1 px-2" onchange="
var v = this.value;
var parts = v.split('|');
document.getElementById('dim-input').value = parts[0];
document.getElementById('dimv-input').value = parts[1];
">
<option value="global|all" {% if dimension == 'global' %}selected{% endif %}>Global (tous serveurs)</option>
{% for o in dim_options %}
{% if o.dimension != 'global' %}
<option value="{{ o.dimension }}|{{ o.dimension_value }}"
{% if dimension == o.dimension and dim_value == o.dimension_value %}selected{% endif %}>
{{ o.dimension|upper }} : {{ o.dimension_value }}
</option>
{% endif %}
{% endfor %}
</select>
<input type="hidden" name="dimension" id="dim-input" value="{{ dimension }}">
<input type="hidden" name="dim_value" id="dimv-input" value="{{ dim_value }}">
</div>
<button type="submit" class="btn-primary px-4 py-1 text-sm">Afficher</button>
<span class="text-xs text-gray-500 ml-auto">{{ runs_count }} snapshot(s) total en base</span>
</form>
{% if not series %}
<div class="card p-6 text-center text-gray-400">
Aucune donnée historique pour ce périmètre/période. Lance des snapshots quotidiens pour alimenter.
</div>
{% else %}
<div class="card p-3 mb-4">
<canvas id="chart" style="max-height:400px"></canvas>
</div>
<div class="card p-3">
<h3 class="text-sm font-bold text-cyber-accent mb-2">Données ({{ series|length }} points)</h3>
<table class="w-full text-xs">
<thead class="text-gray-400 border-b border-cyber-border">
<tr>
<th class="text-left py-1">Date</th>
<th class="text-right py-1">Total</th>
<th class="text-right py-1 text-red-400">🔴 Critique</th>
<th class="text-right py-1 text-orange-400">🟠 High</th>
<th class="text-right py-1 text-yellow-400">🟡 Medium</th>
<th class="text-right py-1 text-green-400">🟢 Sain</th>
<th class="text-right py-1 text-gray-500">⚫ Non scanné</th>
<th class="text-right py-1">Total vuln</th>
</tr>
</thead>
<tbody>
{% for p in series|reverse %}
<tr class="border-b border-cyber-border/30">
<td class="py-1 font-mono">{{ p.bucket[:10] }}</td>
<td class="text-right">{{ p.total }}</td>
<td class="text-right text-red-400">{{ p.critical }}</td>
<td class="text-right text-orange-400">{{ p.high }}</td>
<td class="text-right text-yellow-400">{{ p.medium }}</td>
<td class="text-right text-green-400">{{ p.sain }}</td>
<td class="text-right text-gray-500">{{ p.non_scanne }}</td>
<td class="text-right font-bold">{{ p.vuln_total }}</td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
<script src="https://cdn.jsdelivr.net/npm/chart.js@4"></script>
<script>
const data = {{ series|tojson }};
const labels = data.map(p => p.bucket.substring(0,10));
new Chart(document.getElementById('chart'), {
type: 'line',
data: {
labels: labels,
datasets: [
{label: '🔴 Critique', data: data.map(p => p.critical), borderColor: '#ef4444', backgroundColor: 'rgba(239,68,68,.1)', tension: .2, fill: true},
{label: '🟠 High', data: data.map(p => p.high), borderColor: '#f97316', backgroundColor: 'rgba(249,115,22,.1)', tension: .2, fill: true},
{label: '🟡 Medium', data: data.map(p => p.medium), borderColor: '#eab308', backgroundColor: 'rgba(234,179,8,.1)', tension: .2, fill: true},
{label: '🟢 Sain', data: data.map(p => p.sain), borderColor: '#22c55e', backgroundColor: 'rgba(34,197,94,.1)', tension: .2, fill: false, hidden: true},
{label: '⚫ Non scanné', data: data.map(p => p.non_scanne), borderColor: '#6b7280', backgroundColor: 'rgba(107,114,128,.1)', tension: .2, fill: false, hidden: true}
]
},
options: {
responsive: true,
plugins: {legend: {labels: {color: '#cbd5e1'}}},
scales: {
x: {ticks: {color: '#94a3b8'}, grid: {color: 'rgba(255,255,255,.05)'}},
y: {ticks: {color: '#94a3b8'}, grid: {color: 'rgba(255,255,255,.05)'}, beginAtZero: true}
}
}
});
</script>
{% endif %}
{% endblock %}

14
scripts/snapshot_runner.py Executable file
View File

@ -0,0 +1,14 @@
#!/usr/bin/env python3
"""Lance un snapshot dashboard vuln. Appele par systemd timer (1x/jour)."""
import sys, os
sys.path.insert(0, "/opt/patchcenter")
from app.database import SessionLocal
from app.services.qualys_service import compute_vuln_dashboard
s = SessionLocal()
try:
res = compute_vuln_dashboard(s, triggered_by="cron")
print(f"[snapshot] ok={res['ok']} run_id={res['run_id']} assets={res['asset_count']} dur={res['duration_sec']}s msg={res['msg']}")
sys.exit(0 if res["ok"] else 1)
finally:
s.close()