Add page Historique patching : vue unifiee import xlsx + campagnes + quickwin

This commit is contained in:
Pierre & Lumière 2026-04-17 08:47:25 +00:00
parent c9890a274f
commit 4b1794d4d1
3 changed files with 481 additions and 252 deletions

View File

@ -1,4 +1,4 @@
"""Router Historique patching — vue de patch_history""" """Router Historique patching — vue unifiee patch_history + quickwin_entries"""
from fastapi import APIRouter, Request, Depends, Query from fastapi import APIRouter, Request, Depends, Query
from fastapi.responses import HTMLResponse, RedirectResponse from fastapi.responses import HTMLResponse, RedirectResponse
from fastapi.templating import Jinja2Templates from fastapi.templating import Jinja2Templates
@ -13,7 +13,8 @@ templates = Jinja2Templates(directory="app/templates")
@router.get("/patching/historique", response_class=HTMLResponse) @router.get("/patching/historique", response_class=HTMLResponse)
async def patch_history_page(request: Request, db=Depends(get_db), async def patch_history_page(request: Request, db=Depends(get_db),
year: int = Query(None), week: int = Query(None), year: int = Query(None), week: int = Query(None),
hostname: str = Query(None), page: int = Query(1)): hostname: str = Query(None), source: str = Query(None),
page: int = Query(1)):
user = get_current_user(request) user = get_current_user(request)
if not user: if not user:
return RedirectResponse(url="/login") return RedirectResponse(url="/login")
@ -25,14 +26,27 @@ async def patch_history_page(request: Request, db=Depends(get_db),
per_page = 100 per_page = 100
offset = (page - 1) * per_page offset = (page - 1) * per_page
# KPIs
kpis = {} kpis = {}
kpis["total"] = db.execute(text( kpis["total_ph"] = db.execute(text(
"SELECT COUNT(*) FROM patch_history WHERE EXTRACT(YEAR FROM date_patch)=:y" "SELECT COUNT(*) FROM patch_history WHERE EXTRACT(YEAR FROM date_patch)=:y"
), {"y": year}).scalar() ), {"y": year}).scalar()
kpis["servers"] = db.execute(text( kpis["total_qw"] = db.execute(text("""
"SELECT COUNT(DISTINCT server_id) FROM patch_history WHERE EXTRACT(YEAR FROM date_patch)=:y" SELECT COUNT(*) FROM quickwin_entries qe
), {"y": year}).scalar() JOIN quickwin_runs qr ON qe.run_id=qr.id
WHERE qe.status='patched' AND qr.year=:y
"""), {"y": year}).scalar()
kpis["total"] = kpis["total_ph"] + kpis["total_qw"]
kpis["servers"] = db.execute(text("""
SELECT COUNT(DISTINCT sid) FROM (
SELECT server_id AS sid FROM patch_history WHERE EXTRACT(YEAR FROM date_patch)=:y
UNION
SELECT qe.server_id FROM quickwin_entries qe
JOIN quickwin_runs qr ON qe.run_id=qr.id
WHERE qe.status='patched' AND qr.year=:y
) u
"""), {"y": year}).scalar()
kpis["patchables"] = db.execute(text( kpis["patchables"] = db.execute(text(
"SELECT COUNT(*) FROM servers WHERE etat='Production' AND patch_os_owner='secops'" "SELECT COUNT(*) FROM servers WHERE etat='Production' AND patch_os_owner='secops'"
)).scalar() )).scalar()
@ -41,56 +55,131 @@ async def patch_history_page(request: Request, db=Depends(get_db),
WHERE s.etat='Production' AND s.patch_os_owner='secops' WHERE s.etat='Production' AND s.patch_os_owner='secops'
AND NOT EXISTS (SELECT 1 FROM patch_history ph AND NOT EXISTS (SELECT 1 FROM patch_history ph
WHERE ph.server_id=s.id AND EXTRACT(YEAR FROM ph.date_patch)=:y) WHERE ph.server_id=s.id AND EXTRACT(YEAR FROM ph.date_patch)=:y)
AND NOT EXISTS (SELECT 1 FROM quickwin_entries qe
JOIN quickwin_runs qr ON qe.run_id=qr.id
WHERE qe.server_id=s.id AND qe.status='patched' AND qr.year=:y)
"""), {"y": year}).scalar() """), {"y": year}).scalar()
kpis["coverage_pct"] = round((kpis["servers"] / kpis["patchables"] * 100), 1) if kpis["patchables"] else 0 kpis["coverage_pct"] = round((kpis["servers"] / kpis["patchables"] * 100), 1) if kpis["patchables"] else 0
# Par semaine by_source = {}
by_source["import"] = db.execute(text(
"SELECT COUNT(*) FROM patch_history WHERE campaign_id IS NULL AND EXTRACT(YEAR FROM date_patch)=:y"
), {"y": year}).scalar()
by_source["standard"] = db.execute(text("""
SELECT COUNT(*) FROM patch_history ph
JOIN campaigns c ON ph.campaign_id=c.id
WHERE c.campaign_type='standard' AND EXTRACT(YEAR FROM ph.date_patch)=:y
"""), {"y": year}).scalar()
by_source["quickwin"] = kpis["total_qw"]
by_week = db.execute(text(""" by_week = db.execute(text("""
SELECT TO_CHAR(date_patch, 'IW') as week_num, SELECT week_num, SUM(cnt)::int as servers FROM (
COUNT(DISTINCT server_id) as servers SELECT TO_CHAR(date_patch, 'IW') as week_num, COUNT(DISTINCT server_id) as cnt
FROM patch_history FROM patch_history
WHERE EXTRACT(YEAR FROM date_patch)=:y WHERE EXTRACT(YEAR FROM date_patch)=:y
GROUP BY TO_CHAR(date_patch, 'IW') GROUP BY TO_CHAR(date_patch, 'IW')
ORDER BY week_num UNION ALL
SELECT LPAD(qr.week_number::text, 2, '0') as week_num, COUNT(DISTINCT qe.server_id) as cnt
FROM quickwin_entries qe
JOIN quickwin_runs qr ON qe.run_id=qr.id
WHERE qe.status='patched' AND qr.year=:y
GROUP BY qr.week_number
) u GROUP BY week_num ORDER BY week_num
"""), {"y": year}).fetchall() """), {"y": year}).fetchall()
# Filtres where_ph = ["EXTRACT(YEAR FROM ph.date_patch)=:y"]
where = ["EXTRACT(YEAR FROM ph.date_patch)=:y"] where_qw = ["qr.year=:y", "qe.status='patched'"]
params = {"y": year, "limit": per_page, "offset": offset} params = {"y": year, "limit": per_page, "offset": offset}
if week: if week:
where.append("EXTRACT(WEEK FROM ph.date_patch)=:wk") where_ph.append("EXTRACT(WEEK FROM ph.date_patch)=:wk")
where_qw.append("qr.week_number=:wk")
params["wk"] = week params["wk"] = week
if hostname: if hostname:
where.append("s.hostname ILIKE :h") where_ph.append("s.hostname ILIKE :h")
where_qw.append("s.hostname ILIKE :h")
params["h"] = f"%{hostname}%" params["h"] = f"%{hostname}%"
wc = " AND ".join(where) if source == "import":
where_ph.append("ph.campaign_id IS NULL")
elif source == "standard":
where_ph.append("c.campaign_type='standard'")
total_filtered = db.execute(text( wc_ph = " AND ".join(where_ph)
f"SELECT COUNT(*) FROM patch_history ph JOIN servers s ON ph.server_id=s.id WHERE {wc}" wc_qw = " AND ".join(where_qw)
), params).scalar()
skip_qw = source in ("import", "standard")
skip_ph = source == "quickwin"
count_parts = []
if not skip_ph:
count_parts.append(f"""
SELECT COUNT(*) FROM patch_history ph
JOIN servers s ON ph.server_id=s.id
LEFT JOIN campaigns c ON ph.campaign_id=c.id
WHERE {wc_ph}
""")
if not skip_qw:
count_parts.append(f"""
SELECT COUNT(*) FROM quickwin_entries qe
JOIN quickwin_runs qr ON qe.run_id=qr.id
JOIN servers s ON qe.server_id=s.id
WHERE {wc_qw}
""")
count_sql = " + ".join(f"({p})" for p in count_parts) if count_parts else "0"
total_filtered = db.execute(text(f"SELECT {count_sql}"), params).scalar()
union_parts = []
if not skip_ph:
union_parts.append(f"""
SELECT s.id as sid, s.hostname, s.os_family, s.etat,
ph.date_patch, ph.status, ph.notes,
z.name as zone,
CASE WHEN ph.campaign_id IS NULL THEN 'import'
ELSE COALESCE(c.campaign_type, 'standard') END as source_type,
c.id as campaign_id, c.label as campaign_label,
NULL::int as run_id, NULL::text as run_label
FROM patch_history ph
JOIN servers s ON ph.server_id=s.id
LEFT JOIN zones z ON s.zone_id=z.id
LEFT JOIN campaigns c ON ph.campaign_id=c.id
WHERE {wc_ph}
""")
if not skip_qw:
union_parts.append(f"""
SELECT s.id as sid, s.hostname, s.os_family, s.etat,
qe.patch_date as date_patch, qe.status, qe.notes,
z.name as zone,
'quickwin' as source_type,
NULL::int as campaign_id, NULL::text as campaign_label,
qr.id as run_id, qr.label as run_label
FROM quickwin_entries qe
JOIN quickwin_runs qr ON qe.run_id=qr.id
JOIN servers s ON qe.server_id=s.id
LEFT JOIN zones z ON s.zone_id=z.id
WHERE {wc_qw}
""")
if not union_parts:
union_parts.append("SELECT NULL::int as sid, NULL as hostname, NULL as os_family, NULL as etat, NULL::timestamptz as date_patch, NULL as status, NULL as notes, NULL as zone, NULL as source_type, NULL::int as campaign_id, NULL as campaign_label, NULL::int as run_id, NULL as run_label WHERE 1=0")
union_sql = " UNION ALL ".join(union_parts)
rows = db.execute(text(f""" rows = db.execute(text(f"""
SELECT s.id as sid, s.hostname, s.os_family, s.etat, SELECT * FROM ({union_sql}) combined
ph.date_patch, ph.status, ph.notes, ORDER BY date_patch DESC NULLS LAST
z.name as zone
FROM patch_history ph
JOIN servers s ON ph.server_id = s.id
LEFT JOIN zones z ON s.zone_id = z.id
WHERE {wc}
ORDER BY ph.date_patch DESC
LIMIT :limit OFFSET :offset LIMIT :limit OFFSET :offset
"""), params).fetchall() """), params).fetchall()
# Années dispo
years = db.execute(text(""" years = db.execute(text("""
SELECT DISTINCT EXTRACT(YEAR FROM date_patch)::int as y SELECT DISTINCT y FROM (
FROM patch_history ORDER BY y DESC SELECT EXTRACT(YEAR FROM date_patch)::int as y FROM patch_history
UNION
SELECT year as y FROM quickwin_runs
) u ORDER BY y DESC
""")).fetchall() """)).fetchall()
return templates.TemplateResponse("patch_history.html", { return templates.TemplateResponse("patch_history.html", {
"request": request, "user": user, "app_name": APP_NAME, "request": request, "user": user, "app_name": APP_NAME,
"kpis": kpis, "by_week": by_week, "rows": rows, "kpis": kpis, "by_week": by_week, "by_source": by_source,
"year": year, "week": week, "hostname": hostname, "rows": rows, "year": year, "week": week, "hostname": hostname,
"page": page, "per_page": per_page, "total_filtered": total_filtered, "source": source, "page": page, "per_page": per_page,
"years": [y.y for y in years], "total_filtered": total_filtered, "years": [y.y for y in years],
}) })

View File

@ -1,215 +1,216 @@
<!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>
{# Quickwin sous-groupe #}
{% if p.campaigns or p.quickwin %} {# Quickwin sous-groupe #}
<div> {% if p.campaigns or p.quickwin %}
<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 %}"> <div>
<span>QuickWin</span> <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 x-text="subOpen === 'quickwin' ? '▾' : '▸'" class="text-xs opacity-60"></span> <span>QuickWin</span>
</button> <span x-text="subOpen === 'quickwin' ? '▾' : '▸'" class="text-xs opacity-60"></span>
<div x-show="subOpen === 'quickwin'" x-cloak class="space-y-1"> </button>
<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> <div x-show="subOpen === 'quickwin'" x-cloak class="space-y-1">
{% if p.quickwin in ('edit', 'admin') or p.campaigns in ('edit', 'admin') %} <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/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> {% if p.quickwin in ('edit', 'admin') or p.campaigns in ('edit', 'admin') %}
{% endif %} <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>
</div> {% endif %}
</div> </div>
{% endif %} </div>
</div> {% endif %}
</div> </div>
{% endif %} </div>
{% endif %}
{# ===== AUDIT (au meme niveau que Patching, repliable) ===== #}
{% if p.audit %} {# ===== AUDIT (au meme niveau que Patching, repliable) ===== #}
<div> {% if p.audit %}
<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"> <div>
<span>Audit</span> <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 x-text="open === 'audit' ? '▾' : '▸'" class="text-xs"></span> <span>Audit</span>
</button> <span x-text="open === 'audit' ? '▾' : '▸'" class="text-xs"></span>
<div x-show="open === 'audit'" x-cloak class="space-y-1 pl-1"> </button>
<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> <div x-show="open === 'audit'" x-cloak class="space-y-1 pl-1">
{% 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 %} <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>
</div> {% 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>
{% endif %} </div>
{% endif %}
{# ===== QUALYS (groupe repliable) ===== #}
{% if p.qualys %} {# ===== QUALYS (groupe repliable) ===== #}
<div> {% if p.qualys %}
<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"> <div>
<span>Qualys</span> <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 x-text="open === 'qualys' ? '▾' : '▸'" class="text-xs"></span> <span>Qualys</span>
</button> <span x-text="open === 'qualys' ? '▾' : '▸'" class="text-xs"></span>
<div x-show="open === 'qualys'" x-cloak class="space-y-1 pl-1"> </button>
<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> <div x-show="open === 'qualys'" x-cloak class="space-y-1 pl-1">
<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,139 @@
{% extends 'base.html' %}
{% block title %}Historique patching{% endblock %}
{% block content %}
<div class="flex justify-between items-center mb-4">
<div>
<h2 class="text-xl font-bold text-cyber-accent">Historique patching</h2>
<p class="text-xs text-gray-500 mt-1">Vue unifiée : imports xlsx + campagnes standard + QuickWin.</p>
</div>
<div class="flex gap-2">
{% for y in years %}<a href="?year={{ y }}" class="btn-sm {% if y == year %}bg-cyber-accent text-black{% else %}bg-cyber-border text-gray-300{% endif %} px-3 py-1 text-xs">{{ y }}</a>{% endfor %}
</div>
</div>
<!-- KPIs -->
<div style="display:flex;flex-wrap:wrap;gap:8px;margin-bottom:16px;">
<div class="card p-3 text-center" style="flex:1;min-width:0">
<div class="text-2xl font-bold text-cyber-accent">{{ kpis.total }}</div>
<div class="text-xs text-gray-500">Events {{ year }}</div>
</div>
<div class="card p-3 text-center" style="flex:1;min-width:0">
<div class="text-2xl font-bold text-cyber-green">{{ kpis.servers }}</div>
<div class="text-xs text-gray-500">Serveurs distincts</div>
</div>
<div class="card p-3 text-center" style="flex:1;min-width:0">
<div class="text-2xl font-bold text-white">{{ kpis.patchables }}</div>
<div class="text-xs text-gray-500">Patchables SecOps</div>
</div>
<div class="card p-3 text-center" style="flex:1;min-width:0">
<div class="text-2xl font-bold {% if kpis.never > 0 %}text-cyber-red{% else %}text-cyber-green{% endif %}">{{ kpis.never }}</div>
<div class="text-xs text-gray-500">Jamais patchés {{ year }}</div>
</div>
<div class="card p-3 text-center" style="flex:1;min-width:0">
<div class="text-2xl font-bold {% if kpis.coverage_pct >= 80 %}text-cyber-green{% elif kpis.coverage_pct >= 50 %}text-cyber-yellow{% else %}text-cyber-red{% endif %}">{{ kpis.coverage_pct }}%</div>
<div class="text-xs text-gray-500">Couverture</div>
</div>
</div>
<!-- Répartition par source -->
<div style="display:flex;flex-wrap:wrap;gap:8px;margin-bottom:16px;">
<a href="?year={{ year }}&source=import" class="card p-3 text-center hover:border-cyber-accent" style="flex:1;min-width:0">
<div class="text-2xl font-bold text-blue-400">{{ by_source.import }}</div>
<div class="text-xs text-gray-500">Import xlsx</div>
</a>
<a href="?year={{ year }}&source=standard" class="card p-3 text-center hover:border-cyber-accent" style="flex:1;min-width:0">
<div class="text-2xl font-bold text-cyan-400">{{ by_source.standard }}</div>
<div class="text-xs text-gray-500">Campagnes standard</div>
</a>
<a href="?year={{ year }}&source=quickwin" class="card p-3 text-center hover:border-cyber-accent" style="flex:1;min-width:0">
<div class="text-2xl font-bold text-purple-400">{{ by_source.quickwin }}</div>
<div class="text-xs text-gray-500">QuickWin</div>
</a>
</div>
<!-- Graphique par semaine -->
{% if by_week %}
<div class="card p-4 mb-4">
<h3 class="text-sm font-bold text-cyber-accent mb-3">Serveurs patchés par semaine ({{ year }})</h3>
<div style="display:flex;align-items:flex-end;gap:2px;height:120px;">
{% set max_val = by_week|map(attribute='servers')|max %}
{% for w in by_week %}
<a href="?year={{ year }}&week={{ w.week_num|int }}" title="S{{ w.week_num }} : {{ w.servers }} serveur(s)" style="flex:1;display:flex;flex-direction:column;align-items:center;min-width:0;">
<div style="width:100%;background:{% if week and week == w.week_num|int %}#00ff88{% elif w.servers >= 30 %}#06b6d4{% elif w.servers >= 15 %}#0e7490{% else %}#164e63{% endif %};border-radius:2px 2px 0 0;height:{{ (w.servers / max_val * 100)|int if max_val else 0 }}px;min-height:2px;"></div>
<span style="font-size:8px;color:#6b7280;margin-top:2px;">{{ w.week_num }}</span>
</a>
{% endfor %}
</div>
</div>
{% endif %}
<!-- Filtres -->
<div class="card p-3 mb-4">
<form method="GET" class="flex gap-2 items-center flex-wrap">
<input type="hidden" name="year" value="{{ year }}">
<select name="week" class="text-xs py-1 px-2">
<option value="">Toutes semaines</option>
{% for w in by_week %}<option value="{{ w.week_num|int }}" {% if week == w.week_num|int %}selected{% endif %}>S{{ w.week_num }} ({{ w.servers }})</option>{% endfor %}
</select>
<select name="source" class="text-xs py-1 px-2">
<option value="">Toutes sources</option>
<option value="import" {% if source == 'import' %}selected{% endif %}>Import xlsx</option>
<option value="standard" {% if source == 'standard' %}selected{% endif %}>Campagne standard</option>
<option value="quickwin" {% if source == 'quickwin' %}selected{% endif %}>QuickWin</option>
</select>
<input type="text" name="hostname" value="{{ hostname or '' }}" placeholder="Hostname..." class="text-xs py-1 px-2" style="width:180px">
<button type="submit" class="btn-primary px-3 py-1 text-xs">Filtrer</button>
<a href="/patching/historique?year={{ year }}" class="text-xs text-gray-500 hover:text-cyber-accent">Reset</a>
<span class="text-xs text-gray-500 ml-auto">{{ total_filtered }} résultat{{ 's' if total_filtered != 1 }}</span>
</form>
</div>
<!-- Tableau -->
<div class="card overflow-x-auto">
<table class="w-full table-cyber text-xs">
<thead><tr>
<th class="p-2 text-left">Hostname</th>
<th class="p-2 text-center">OS</th>
<th class="p-2 text-center">Zone</th>
<th class="p-2 text-center">État</th>
<th class="p-2 text-center">Date</th>
<th class="p-2 text-center">Semaine</th>
<th class="p-2 text-center">Source</th>
<th class="p-2 text-center">Status</th>
<th class="p-2 text-left">Notes</th>
</tr></thead>
<tbody>
{% for r in rows %}
<tr class="border-t border-cyber-border/30 hover:bg-cyber-hover/20">
<td class="p-2 font-mono text-cyber-accent"><a href="/servers/{{ r.sid }}" class="hover:underline">{{ r.hostname }}</a></td>
<td class="p-2 text-center text-gray-400">{{ (r.os_family or '-')[:6] }}</td>
<td class="p-2 text-center"><span class="badge {% if r.zone == 'DMZ' %}badge-red{% else %}badge-gray{% endif %}">{{ r.zone or '-' }}</span></td>
<td class="p-2 text-center"><span class="badge {% if r.etat == 'Production' %}badge-green{% else %}badge-yellow{% endif %}">{{ (r.etat or '-')[:6] }}</span></td>
<td class="p-2 text-center text-gray-300">{{ r.date_patch.strftime('%Y-%m-%d %H:%M') if r.date_patch else '-' }}</td>
<td class="p-2 text-center text-gray-400">{% if r.date_patch %}S{{ r.date_patch.strftime('%V') }}{% else %}-{% endif %}</td>
<td class="p-2 text-center">
{% if r.source_type == 'import' %}<span class="badge" style="background:#1e3a5f;color:#60a5fa;">xlsx</span>
{% elif r.source_type == 'standard' %}<a href="/campaigns/{{ r.campaign_id }}" class="badge" style="background:#164e63;color:#22d3ee;text-decoration:none">{{ r.campaign_label or 'Campagne' }}</a>
{% elif r.source_type == 'quickwin' %}<a href="/quickwin/{{ r.run_id }}" class="badge" style="background:#3b1f5e;color:#c084fc;text-decoration:none">{{ r.run_label or 'QuickWin' }}</a>
{% else %}<span class="badge badge-gray">{{ r.source_type or '?' }}</span>{% endif %}
</td>
<td class="p-2 text-center"><span class="badge {% if r.status == 'ok' or r.status == 'patched' %}badge-green{% elif r.status == 'ko' or r.status == 'failed' %}badge-red{% else %}badge-yellow{% endif %}">{{ r.status }}</span></td>
<td class="p-2 text-gray-400" style="max-width:200px;overflow:hidden;text-overflow:ellipsis;white-space:nowrap" title="{{ r.notes or '' }}">{{ (r.notes or '-')[:50] }}</td>
</tr>
{% endfor %}
{% if not rows %}
<tr><td colspan="9" class="p-6 text-center text-gray-500">Aucun event de patching pour ce filtre</td></tr>
{% endif %}
</tbody>
</table>
</div>
<!-- Pagination -->
{% if total_filtered > per_page %}
<div class="flex justify-center gap-2 mt-4">
{% if page > 1 %}<a href="?year={{ year }}{% if week %}&week={{ week }}{% endif %}{% if source %}&source={{ source }}{% endif %}{% if hostname %}&hostname={{ hostname }}{% endif %}&page={{ page - 1 }}" class="btn-sm bg-cyber-border text-gray-300 px-3 py-1 text-xs">← Précédent</a>{% endif %}
<span class="text-xs text-gray-500 py-1">Page {{ page }} / {{ ((total_filtered - 1) // per_page) + 1 }}</span>
{% if page * per_page < total_filtered %}<a href="?year={{ year }}{% if week %}&week={{ week }}{% endif %}{% if source %}&source={{ source }}{% endif %}{% if hostname %}&hostname={{ hostname }}{% endif %}&page={{ page + 1 }}" class="btn-sm bg-cyber-border text-gray-300 px-3 py-1 text-xs">Suivant →</a>{% endif %}
</div>
{% endif %}
{% endblock %}