diff --git a/app/routers/patch_history.py b/app/routers/patch_history.py
new file mode 100644
index 0000000..f995f22
--- /dev/null
+++ b/app/routers/patch_history.py
@@ -0,0 +1,96 @@
+"""Router Historique patching — vue de patch_history"""
+from fastapi import APIRouter, Request, Depends, Query
+from fastapi.responses import HTMLResponse, RedirectResponse
+from fastapi.templating import Jinja2Templates
+from sqlalchemy import text
+from ..dependencies import get_db, get_current_user
+from ..config import APP_NAME
+
+router = APIRouter()
+templates = Jinja2Templates(directory="app/templates")
+
+
+@router.get("/patching/historique", response_class=HTMLResponse)
+async def patch_history_page(request: Request, db=Depends(get_db),
+ year: int = Query(None), week: int = Query(None),
+ hostname: str = Query(None), page: int = Query(1)):
+ user = get_current_user(request)
+ if not user:
+ return RedirectResponse(url="/login")
+
+ from datetime import datetime
+ if not year:
+ year = datetime.now().year
+
+ per_page = 100
+ offset = (page - 1) * per_page
+
+ # KPIs
+ kpis = {}
+ kpis["total"] = db.execute(text(
+ "SELECT COUNT(*) FROM patch_history WHERE EXTRACT(YEAR FROM date_patch)=:y"
+ ), {"y": year}).scalar()
+ kpis["servers"] = db.execute(text(
+ "SELECT COUNT(DISTINCT server_id) FROM patch_history WHERE EXTRACT(YEAR FROM date_patch)=:y"
+ ), {"y": year}).scalar()
+ kpis["patchables"] = db.execute(text(
+ "SELECT COUNT(*) FROM servers WHERE etat='Production' AND patch_os_owner='secops'"
+ )).scalar()
+ kpis["never"] = db.execute(text("""
+ SELECT COUNT(*) FROM servers s
+ WHERE s.etat='Production' AND s.patch_os_owner='secops'
+ AND NOT EXISTS (SELECT 1 FROM patch_history ph
+ WHERE ph.server_id=s.id AND EXTRACT(YEAR FROM ph.date_patch)=:y)
+ """), {"y": year}).scalar()
+ kpis["coverage_pct"] = round((kpis["servers"] / kpis["patchables"] * 100), 1) if kpis["patchables"] else 0
+
+ # Par semaine
+ by_week = db.execute(text("""
+ SELECT TO_CHAR(date_patch, 'IW') as week_num,
+ COUNT(DISTINCT server_id) as servers
+ FROM patch_history
+ WHERE EXTRACT(YEAR FROM date_patch)=:y
+ GROUP BY TO_CHAR(date_patch, 'IW')
+ ORDER BY week_num
+ """), {"y": year}).fetchall()
+
+ # Filtres
+ where = ["EXTRACT(YEAR FROM ph.date_patch)=:y"]
+ params = {"y": year, "limit": per_page, "offset": offset}
+ if week:
+ where.append("EXTRACT(WEEK FROM ph.date_patch)=:wk")
+ params["wk"] = week
+ if hostname:
+ where.append("s.hostname ILIKE :h")
+ params["h"] = f"%{hostname}%"
+ wc = " AND ".join(where)
+
+ total_filtered = db.execute(text(
+ f"SELECT COUNT(*) FROM patch_history ph JOIN servers s ON ph.server_id=s.id WHERE {wc}"
+ ), params).scalar()
+
+ rows = db.execute(text(f"""
+ SELECT s.id as sid, s.hostname, s.os_family, s.etat,
+ ph.date_patch, ph.status, ph.notes,
+ 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
+ """), params).fetchall()
+
+ # Années dispo
+ years = db.execute(text("""
+ SELECT DISTINCT EXTRACT(YEAR FROM date_patch)::int as y
+ FROM patch_history ORDER BY y DESC
+ """)).fetchall()
+
+ return templates.TemplateResponse("patch_history.html", {
+ "request": request, "user": user, "app_name": APP_NAME,
+ "kpis": kpis, "by_week": by_week, "rows": rows,
+ "year": year, "week": week, "hostname": hostname,
+ "page": page, "per_page": per_page, "total_filtered": total_filtered,
+ "years": [y.y for y in years],
+ })
diff --git a/app/routers/servers.py b/app/routers/servers.py
index adcd907..97adacf 100644
--- a/app/routers/servers.py
+++ b/app/routers/servers.py
@@ -20,7 +20,7 @@ async def servers_list(request: Request, db=Depends(get_db),
domain: str = Query(None), env: str = Query(None),
tier: str = Query(None), etat: str = Query(None),
os: str = Query(None), owner: str = Query(None),
- zone: str = Query(None),
+ zone: str = Query(None), licence: str = Query(None),
application: str = Query(None), application_id: int = Query(None),
search: str = Query(None), page: int = Query(1),
sort: str = Query("hostname"), sort_dir: str = Query("asc")):
@@ -29,7 +29,7 @@ async def servers_list(request: Request, db=Depends(get_db),
return RedirectResponse(url="/login")
filters = {"domain": domain, "env": env, "tier": tier, "etat": etat, "os": os,
- "owner": owner, "zone": zone,
+ "owner": owner, "zone": zone, "licence": licence,
"application": application, "application_id": application_id,
"search": search}
servers, total = list_servers(db, filters, page, sort=sort, sort_dir=sort_dir)
diff --git a/app/services/server_service.py b/app/services/server_service.py
index d4967b0..eb11af3 100644
--- a/app/services/server_service.py
+++ b/app/services/server_service.py
@@ -132,6 +132,11 @@ def list_servers(db, filters, page=1, per_page=50, sort="hostname", sort_dir="as
params["zone"] = filters["zone"]
if filters.get("owner"):
where.append("s.patch_os_owner = :owner"); params["owner"] = filters["owner"]
+ if filters.get("licence"):
+ if filters["licence"] == "__null__":
+ where.append("s.licence_support IS NULL")
+ else:
+ where.append("s.licence_support = :licence"); params["licence"] = filters["licence"]
if filters.get("application_id"):
where.append("s.application_id = :app_id"); params["app_id"] = filters["application_id"]
elif filters.get("application"):
diff --git a/app/templates/servers.html b/app/templates/servers.html
index 61a7888..6bfba3b 100644
--- a/app/templates/servers.html
+++ b/app/templates/servers.html
@@ -53,6 +53,12 @@
+