Admin applications + correspondance cleanup + tools presentation DSI

- Admin applications: CRUD module (list/add/edit/delete/assign/multi-app)
  avec push iTop bidirectionnel (applications.py + 3 templates)
- Correspondance prod<->hors-prod: migration vers server_correspondance
  globale, suppression ancien code quickwin, ajout filtre environnement
  et solution applicative, colonne environnement dans builder
- Servers page: colonne application_name + equivalent(s) via get_links_bulk,
  filtre application_id, push iTop sur changement application
- Patching: bulk_update_application, bulk_update_excludes, validations
- Fix paramiko sftp.put (remote_path -> positional arg)
- Tools: wiki_to_pdf.py (DokuWiki -> PDF) + generate_ppt.py (PPTX 19 slides
  DSI patching) + contenu source (processus_patching.txt, script_presentation.txt)

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
Pierre & Lumière 2026-04-13 21:11:58 +02:00
parent caa2be71a4
commit 677f621c81
25 changed files with 2848 additions and 532 deletions

View File

@ -6,7 +6,7 @@ from starlette.middleware.base import BaseHTTPMiddleware
from .config import APP_NAME, APP_VERSION from .config import APP_NAME, APP_VERSION
from .dependencies import get_current_user, get_user_perms from .dependencies import get_current_user, get_user_perms
from .database import SessionLocal, SessionLocalDemo from .database import SessionLocal, SessionLocalDemo
from .routers import auth, dashboard, servers, settings, users, campaigns, planning, specifics, audit, contacts, qualys, quickwin, referentiel, patching from .routers import auth, dashboard, servers, settings, users, campaigns, planning, specifics, audit, contacts, qualys, quickwin, referentiel, patching, applications
class PermissionsMiddleware(BaseHTTPMiddleware): class PermissionsMiddleware(BaseHTTPMiddleware):
@ -63,6 +63,7 @@ app.include_router(qualys.router)
app.include_router(quickwin.router) app.include_router(quickwin.router)
app.include_router(referentiel.router) app.include_router(referentiel.router)
app.include_router(patching.router) app.include_router(patching.router)
app.include_router(applications.router)
@app.get("/") @app.get("/")

481
app/routers/applications.py Normal file
View File

@ -0,0 +1,481 @@
"""Router Administration — gestion des applications (solutions applicatives).
Catalogue applications local + sync bidirectionnelle avec iTop ApplicationSolution.
"""
from fastapi import APIRouter, Request, Depends, Query, Form
from fastapi.responses import HTMLResponse, RedirectResponse, JSONResponse
from fastapi.templating import Jinja2Templates
from sqlalchemy import text
from ..dependencies import get_db, get_current_user, get_user_perms, can_view, can_edit, can_admin, base_context
from ..config import APP_NAME
router = APIRouter()
templates = Jinja2Templates(directory="app/templates")
CRIT_CHOICES = [
("critique", "Critique"),
("haute", "Haute"),
("standard", "Standard"),
("basse", "Basse"),
]
STATUS_CHOICES = [
("active", "Active"),
("obsolete", "Obsolete"),
("implementation", "En implémentation"),
]
# Reverse map pour push iTop (iTop utilise low/medium/high/critical)
CRIT_TO_ITOP = {"critique": "critical", "haute": "high", "standard": "medium", "basse": "low"}
def _check_admin(request, db):
user = get_current_user(request)
if not user:
return None, None, RedirectResponse(url="/login")
perms = get_user_perms(db, user)
if not can_view(perms, "settings") and not can_admin(perms, "users"):
return None, None, RedirectResponse(url="/dashboard")
return user, perms, None
def _push_itop(db, itop_id, fields, operation="update"):
"""Push vers iTop (best effort). operation : 'update' | 'delete' | 'create'."""
try:
from ..services.itop_service import ITopClient
from ..services.secrets_service import get_secret
url = get_secret(db, "itop_url")
u = get_secret(db, "itop_user")
p = get_secret(db, "itop_pass")
if not (url and u and p):
return {"pushed": False, "msg": "Credentials iTop manquants"}
client = ITopClient(url, u, p)
if operation == "update":
r = client.update("ApplicationSolution", itop_id, fields)
elif operation == "create":
r = client.create("ApplicationSolution", fields)
if r.get("code") == 0 and r.get("objects"):
new_id = list(r["objects"].values())[0]["key"]
return {"pushed": True, "itop_id": int(new_id), "msg": "Créée dans iTop"}
elif operation == "delete":
r = client._call("core/delete", **{"class": "ApplicationSolution",
"key": str(itop_id), "comment": "PatchCenter delete"})
if r.get("code") == 0:
return {"pushed": True, "msg": "iTop OK"}
return {"pushed": False, "msg": (r.get("message") or "")[:120]}
except Exception as e:
return {"pushed": False, "msg": str(e)[:120]}
@router.get("/admin/applications", response_class=HTMLResponse)
async def applications_page(request: Request, db=Depends(get_db),
search: str = Query(""), criticite: str = Query(""),
status: str = Query(""), has_itop: str = Query(""),
domain: str = Query("")):
user, perms, redirect = _check_admin(request, db)
if redirect:
return redirect
where = ["1=1"]
params = {}
if search:
where.append("(a.nom_court ILIKE :s OR a.nom_complet ILIKE :s)")
params["s"] = f"%{search}%"
if criticite:
where.append("a.criticite = :c"); params["c"] = criticite
if status:
where.append("a.status = :st"); params["st"] = status
if has_itop == "yes":
where.append("a.itop_id IS NOT NULL")
elif has_itop == "no":
where.append("a.itop_id IS NULL")
if domain:
where.append("""a.id IN (
SELECT DISTINCT s.application_id FROM servers s
LEFT JOIN domain_environments de ON s.domain_env_id = de.id
LEFT JOIN domains d ON de.domain_id = d.id
WHERE d.name = :dom AND s.application_id IS NOT NULL
)""")
params["dom"] = domain
wc = " AND ".join(where)
apps = db.execute(text(f"""
SELECT a.id, a.itop_id, a.nom_court, a.nom_complet, a.description,
a.criticite, a.status, a.editeur, a.created_at, a.updated_at,
(SELECT COUNT(*) FROM servers s WHERE s.application_id = a.id) as nb_servers,
(SELECT string_agg(DISTINCT d.name, ', ' ORDER BY d.name)
FROM servers s
LEFT JOIN domain_environments de ON s.domain_env_id = de.id
LEFT JOIN domains d ON de.domain_id = d.id
WHERE s.application_id = a.id AND d.name IS NOT NULL) as domains
FROM applications a
WHERE {wc}
ORDER BY a.nom_court
"""), params).fetchall()
domains_list = db.execute(text("SELECT name FROM domains ORDER BY name")).fetchall()
stats = {
"total": db.execute(text("SELECT COUNT(*) FROM applications")).scalar() or 0,
"from_itop": db.execute(text("SELECT COUNT(*) FROM applications WHERE itop_id IS NOT NULL")).scalar() or 0,
"used": db.execute(text("SELECT COUNT(*) FROM applications WHERE id IN (SELECT DISTINCT application_id FROM servers WHERE application_id IS NOT NULL)")).scalar() or 0,
}
ctx = base_context(request, db, user)
ctx.update({
"app_name": APP_NAME, "apps": apps, "stats": stats,
"crit_choices": CRIT_CHOICES, "status_choices": STATUS_CHOICES,
"domains_list": [d.name for d in domains_list],
"filters": {"search": search, "criticite": criticite, "status": status,
"has_itop": has_itop, "domain": domain},
"can_edit": can_admin(perms, "users") or can_edit(perms, "settings"),
"msg": request.query_params.get("msg", ""),
})
return templates.TemplateResponse("admin_applications.html", ctx)
@router.post("/admin/applications/add")
async def applications_add(request: Request, db=Depends(get_db),
nom_court: str = Form(...), nom_complet: str = Form(""),
description: str = Form(""), editeur: str = Form(""),
criticite: str = Form("basse"), status: str = Form("active"),
push_itop: str = Form("")):
user, perms, redirect = _check_admin(request, db)
if redirect:
return redirect
if not (can_admin(perms, "users") or can_edit(perms, "settings")):
return RedirectResponse(url="/admin/applications?msg=forbidden", status_code=303)
name = nom_court.strip()[:50]
full = (nom_complet.strip() or name)[:200]
existing = db.execute(text("SELECT id FROM applications WHERE LOWER(nom_court)=LOWER(:n)"),
{"n": name}).fetchone()
if existing:
return RedirectResponse(url="/admin/applications?msg=exists", status_code=303)
itop_id = None
if push_itop == "on":
r = _push_itop(db, None, {
"name": name,
"description": description.strip()[:500],
"business_criticity": CRIT_TO_ITOP.get(criticite, "low"),
"status": status,
}, operation="create")
if r.get("pushed") and r.get("itop_id"):
itop_id = r["itop_id"]
db.execute(text("""INSERT INTO applications (nom_court, nom_complet, description,
editeur, criticite, status, itop_id)
VALUES (:n, :nc, :d, :e, :c, :s, :iid)"""),
{"n": name, "nc": full, "d": description.strip()[:500],
"e": editeur.strip()[:100], "c": criticite, "s": status, "iid": itop_id})
db.commit()
return RedirectResponse(url="/admin/applications?msg=added", status_code=303)
@router.post("/admin/applications/{app_id}/edit")
async def applications_edit(request: Request, app_id: int, db=Depends(get_db),
nom_court: str = Form(...), nom_complet: str = Form(""),
description: str = Form(""), editeur: str = Form(""),
criticite: str = Form("basse"), status: str = Form("active")):
user, perms, redirect = _check_admin(request, db)
if redirect:
return redirect
if not (can_admin(perms, "users") or can_edit(perms, "settings")):
return RedirectResponse(url="/admin/applications?msg=forbidden", status_code=303)
row = db.execute(text("SELECT itop_id, nom_court FROM applications WHERE id=:id"),
{"id": app_id}).fetchone()
if not row:
return RedirectResponse(url="/admin/applications?msg=notfound", status_code=303)
name = nom_court.strip()[:50]
full = (nom_complet.strip() or name)[:200]
db.execute(text("""UPDATE applications SET nom_court=:n, nom_complet=:nc,
description=:d, editeur=:e, criticite=:c, status=:s, updated_at=NOW()
WHERE id=:id"""),
{"n": name, "nc": full, "d": description.strip()[:500],
"e": editeur.strip()[:100], "c": criticite, "s": status, "id": app_id})
db.commit()
# Propager le nom court aux serveurs liés
db.execute(text("UPDATE servers SET application_name=:an WHERE application_id=:aid"),
{"an": name, "aid": app_id})
db.commit()
# Push iTop si lié
itop_msg = ""
if row.itop_id:
r = _push_itop(db, row.itop_id, {
"name": name,
"description": description.strip()[:500],
"business_criticity": CRIT_TO_ITOP.get(criticite, "low"),
"status": status,
}, operation="update")
itop_msg = "_itop_ok" if r.get("pushed") else "_itop_ko"
return RedirectResponse(url=f"/admin/applications?msg=edited{itop_msg}", status_code=303)
@router.get("/admin/applications/multi-app", response_class=HTMLResponse)
async def applications_multi_app(request: Request, db=Depends(get_db)):
"""Liste les serveurs qui sont liés à plusieurs apps (source : iTop applicationsolution_list)."""
user, perms, redirect = _check_admin(request, db)
if redirect:
return redirect
multi = []
try:
from ..services.itop_service import ITopClient
from ..services.secrets_service import get_secret
url = get_secret(db, "itop_url")
u = get_secret(db, "itop_user")
p = get_secret(db, "itop_pass")
if url and u and p:
client = ITopClient(url, u, p)
# VMs
r = client._call("core/get", **{"class": "VirtualMachine", "key": "SELECT VirtualMachine",
"output_fields": "name,applicationsolution_list"})
for k, v in (r.get("objects") or {}).items():
apps = v["fields"].get("applicationsolution_list", [])
if len(apps) >= 2:
multi.append({
"hostname": v["fields"].get("name"),
"apps": [{"name": a.get("applicationsolution_name"),
"itop_id": int(a.get("applicationsolution_id", 0))} for a in apps]
})
# Servers
r2 = client._call("core/get", **{"class": "Server", "key": "SELECT Server",
"output_fields": "name,applicationsolution_list"})
for k, v in (r2.get("objects") or {}).items():
apps = v["fields"].get("applicationsolution_list", [])
if len(apps) >= 2:
multi.append({
"hostname": v["fields"].get("name"),
"apps": [{"name": a.get("applicationsolution_name"),
"itop_id": int(a.get("applicationsolution_id", 0))} for a in apps]
})
except Exception as e:
pass
# Enrichir : app actuelle dans PatchCenter
for m in multi:
hn = (m["hostname"] or "").split(".")[0].lower()
row = db.execute(text("""SELECT application_id, application_name FROM servers
WHERE LOWER(hostname)=:h"""), {"h": hn}).fetchone()
m["current_app_name"] = row.application_name if row else None
m["current_app_id"] = row.application_id if row else None
ctx = base_context(request, db, user)
ctx.update({"app_name": APP_NAME, "multi_servers": multi})
return templates.TemplateResponse("admin_applications_multi.html", ctx)
@router.post("/admin/applications/keep-single-app")
async def applications_keep_single(request: Request, db=Depends(get_db)):
"""Pour un serveur, garde une seule app parmi plusieurs (PatchCenter + push iTop)."""
user, perms, redirect = _check_admin(request, db)
if redirect:
return JSONResponse({"ok": False, "msg": "Non authentifié"}, status_code=401)
if not (can_admin(perms, "users") or can_edit(perms, "settings")):
return JSONResponse({"ok": False, "msg": "Permission refusée"}, status_code=403)
body = await request.json()
hostname = (body.get("hostname") or "").strip().split(".")[0].lower()
keep_itop_id = body.get("keep_itop_id")
if not hostname or not keep_itop_id:
return JSONResponse({"ok": False, "msg": "Paramètres manquants"})
# Trouver app locale par itop_id
app = db.execute(text("SELECT id, nom_court FROM applications WHERE itop_id=:iid"),
{"iid": int(keep_itop_id)}).fetchone()
if not app:
return JSONResponse({"ok": False, "msg": "App iTop introuvable dans catalogue"})
# Update local
db.execute(text("""UPDATE servers SET application_id=:aid, application_name=:an, updated_at=NOW()
WHERE LOWER(hostname)=:h"""),
{"aid": app.id, "an": app.nom_court, "h": hostname})
db.commit()
# Push iTop : remplacer applicationsolution_list par [keep]
try:
from ..services.itop_service import ITopClient
from ..services.secrets_service import get_secret
url = get_secret(db, "itop_url")
u = get_secret(db, "itop_user")
p = get_secret(db, "itop_pass")
if url and u and p:
client = ITopClient(url, u, p)
for cls in ("VirtualMachine", "Server"):
r = client._call("core/get", **{"class": cls,
"key": f'SELECT {cls} WHERE name = "{hostname}"', "output_fields": "name"})
if r.get("objects"):
vm_id = list(r["objects"].values())[0]["key"]
client.update(cls, vm_id,
{"applicationsolution_list": [{"applicationsolution_id": int(keep_itop_id)}]})
break
except Exception:
pass
return JSONResponse({"ok": True, "app_name": app.nom_court})
@router.get("/admin/applications/{app_id}/assign", response_class=HTMLResponse)
async def applications_assign_page(request: Request, app_id: int, db=Depends(get_db),
search: str = Query(""), domain: str = Query(""),
env: str = Query(""), assigned: str = Query(""),
page: int = Query(1), per_page: int = Query(50)):
"""Page d'association en masse de serveurs à une application."""
user, perms, redirect = _check_admin(request, db)
if redirect:
return redirect
app = db.execute(text("""SELECT id, itop_id, nom_court, nom_complet
FROM applications WHERE id=:id"""), {"id": app_id}).fetchone()
if not app:
return RedirectResponse(url="/admin/applications?msg=notfound", status_code=303)
where = ["s.etat NOT IN ('stock','obsolete')"]
params = {}
if search:
where.append("s.hostname ILIKE :s"); params["s"] = f"%{search}%"
if domain:
where.append("d.name = :dom"); params["dom"] = domain
if env:
where.append("e.name = :env"); params["env"] = env
if assigned == "none":
where.append("s.application_id IS NULL")
elif assigned == "other":
where.append("s.application_id IS NOT NULL AND s.application_id != :aid")
params["aid"] = app_id
elif assigned == "current":
where.append("s.application_id = :aid2")
params["aid2"] = app_id
wc = " AND ".join(where)
total = db.execute(text(f"""SELECT COUNT(*) FROM servers s
LEFT JOIN domain_environments de ON s.domain_env_id = de.id
LEFT JOIN domains d ON de.domain_id = d.id
LEFT JOIN environments e ON de.environment_id = e.id
WHERE {wc}"""), params).scalar() or 0
per_page = max(20, min(per_page, 200))
total_pages = max(1, (total + per_page - 1) // per_page)
page = max(1, min(page, total_pages))
offset = (page - 1) * per_page
rows = db.execute(text(f"""SELECT s.id, s.hostname, s.os_family, s.application_id, s.application_name,
d.name as domain_name, e.name as env_name
FROM servers s
LEFT JOIN domain_environments de ON s.domain_env_id = de.id
LEFT JOIN domains d ON de.domain_id = d.id
LEFT JOIN environments e ON de.environment_id = e.id
WHERE {wc}
ORDER BY s.hostname
LIMIT :lim OFFSET :off"""), {**params, "lim": per_page, "off": offset}).fetchall()
domains_list = db.execute(text("SELECT name FROM domains ORDER BY name")).fetchall()
envs_list = db.execute(text("SELECT name FROM environments ORDER BY name")).fetchall()
ctx = base_context(request, db, user)
ctx.update({
"app_name": APP_NAME, "app": app, "servers": rows,
"total": total, "page": page, "per_page": per_page, "total_pages": total_pages,
"domains_list": [d.name for d in domains_list],
"envs_list": [e.name for e in envs_list],
"filters": {"search": search, "domain": domain, "env": env, "assigned": assigned},
})
return templates.TemplateResponse("admin_applications_assign.html", ctx)
@router.post("/admin/applications/{app_id}/assign")
async def applications_assign(request: Request, app_id: int, db=Depends(get_db)):
user, perms, redirect = _check_admin(request, db)
if redirect:
return JSONResponse({"ok": False, "msg": "Non authentifié"}, status_code=401)
if not (can_admin(perms, "users") or can_edit(perms, "settings")):
return JSONResponse({"ok": False, "msg": "Permission refusée"}, status_code=403)
app = db.execute(text("SELECT id, itop_id, nom_court FROM applications WHERE id=:id"),
{"id": app_id}).fetchone()
if not app:
return JSONResponse({"ok": False, "msg": "Application introuvable"}, status_code=404)
body = await request.json()
server_ids = [int(x) for x in body.get("server_ids", []) if str(x).isdigit()]
if not server_ids:
return JSONResponse({"ok": False, "msg": "Aucun serveur sélectionné"})
placeholders = ",".join(str(i) for i in server_ids)
db.execute(text(f"""UPDATE servers SET application_id=:aid, application_name=:an, updated_at=NOW()
WHERE id IN ({placeholders})"""),
{"aid": app_id, "an": app.nom_court})
db.commit()
# Push iTop
itop_pushed = 0
itop_errors = 0
if app.itop_id:
try:
from ..services.itop_service import ITopClient
from ..services.secrets_service import get_secret
url = get_secret(db, "itop_url")
u = get_secret(db, "itop_user")
p = get_secret(db, "itop_pass")
if url and u and p:
client = ITopClient(url, u, p)
new_list = [{"applicationsolution_id": int(app.itop_id)}]
hosts = db.execute(text(f"SELECT hostname FROM servers WHERE id IN ({placeholders})")).fetchall()
for h in hosts:
try:
rr = client._call("core/get", **{"class": "VirtualMachine",
"key": f'SELECT VirtualMachine WHERE name = "{h.hostname}"',
"output_fields": "name"})
if rr.get("objects"):
vm_id = list(rr["objects"].values())[0]["key"]
upd = client.update("VirtualMachine", vm_id,
{"applicationsolution_list": new_list})
if upd.get("code") == 0:
itop_pushed += 1
else:
itop_errors += 1
except Exception:
itop_errors += 1
except Exception:
pass
return JSONResponse({"ok": True, "updated": len(server_ids),
"itop_pushed": itop_pushed, "itop_errors": itop_errors,
"app_name": app.nom_court})
@router.post("/admin/applications/{app_id}/delete")
async def applications_delete(request: Request, app_id: int, db=Depends(get_db)):
user, perms, redirect = _check_admin(request, db)
if redirect:
return redirect
if not (can_admin(perms, "users") or can_edit(perms, "settings")):
return RedirectResponse(url="/admin/applications?msg=forbidden", status_code=303)
row = db.execute(text("SELECT itop_id, nom_court FROM applications WHERE id=:id"),
{"id": app_id}).fetchone()
if not row:
return RedirectResponse(url="/admin/applications?msg=notfound", status_code=303)
# Délier les serveurs d'abord
n_servers = db.execute(text("UPDATE servers SET application_id=NULL, application_name=NULL WHERE application_id=:aid"),
{"aid": app_id}).rowcount
# Supprimer localement
db.execute(text("DELETE FROM applications WHERE id=:id"), {"id": app_id})
db.commit()
# Push iTop delete si lié
itop_msg = ""
if row.itop_id:
r = _push_itop(db, row.itop_id, {}, operation="delete")
itop_msg = "_itop_ok" if r.get("pushed") else "_itop_ko"
return RedirectResponse(url=f"/admin/applications?msg=deleted_{n_servers}{itop_msg}", status_code=303)

View File

@ -338,6 +338,7 @@ async def correspondance_page(request: Request, db=Depends(get_db),
servers = corr.get_servers_for_builder(db, search=search, app=application, servers = corr.get_servers_for_builder(db, search=search, app=application,
domain=domain, env=env) domain=domain, env=env)
server_links = corr.get_links_bulk(db, [s.id for s in servers])
applications = db.execute(text("""SELECT DISTINCT application_name FROM servers applications = db.execute(text("""SELECT DISTINCT application_name FROM servers
WHERE application_name IS NOT NULL AND application_name != '' WHERE application_name IS NOT NULL AND application_name != ''
@ -355,6 +356,7 @@ async def correspondance_page(request: Request, db=Depends(get_db),
ctx = base_context(request, db, user) ctx = base_context(request, db, user)
ctx.update({"app_name": APP_NAME, "servers": servers, "stats": stats, ctx.update({"app_name": APP_NAME, "servers": servers, "stats": stats,
"server_links": server_links,
"applications": applications, "applications": applications,
"envs": [e.name for e in envs], "envs": [e.name for e in envs],
"domains": [d.name for d in domains], "domains": [d.name for d in domains],

View File

@ -15,8 +15,6 @@ from ..services.quickwin_service import (
build_yum_commands, get_available_servers, get_available_filters, build_yum_commands, get_available_servers, get_available_filters,
add_entries_to_run, remove_entries_from_run, add_entries_to_run, remove_entries_from_run,
get_campaign_scope, apply_scope, get_campaign_scope, apply_scope,
get_correspondance, get_available_prod_entries,
compute_correspondance, set_prod_pair, clear_all_pairs,
DEFAULT_GENERAL_EXCLUDES, DEFAULT_GENERAL_EXCLUDES,
) )
from ..services.quickwin_log_service import get_logs, get_log_stats, clear_logs from ..services.quickwin_log_service import get_logs, get_log_stats, clear_logs
@ -140,17 +138,8 @@ async def quickwin_create(request: Request, db=Depends(get_db),
@router.get("/quickwin/correspondance", response_class=HTMLResponse) @router.get("/quickwin/correspondance", response_class=HTMLResponse)
async def quickwin_correspondance_redirect(request: Request, db=Depends(get_db)): async def quickwin_correspondance_redirect(request: Request, db=Depends(get_db)):
"""Redirige vers la correspondance de la derniere campagne active""" """Redirige vers la nouvelle correspondance globale."""
user = get_current_user(request) return RedirectResponse(url="/patching/correspondance", status_code=303)
if not user:
return RedirectResponse(url="/login")
perms = get_user_perms(db, user)
if not can_edit(perms, "campaigns") and not can_edit(perms, "quickwin"):
return RedirectResponse(url="/dashboard")
runs = list_runs(db)
if not runs:
return RedirectResponse(url="/quickwin?msg=no_run")
return RedirectResponse(url=f"/quickwin/{runs[0].id}/correspondance")
@router.get("/quickwin/{run_id}", response_class=HTMLResponse) @router.get("/quickwin/{run_id}", response_class=HTMLResponse)
@ -887,86 +876,7 @@ async def quickwin_prod_check(request: Request, run_id: int, db=Depends(get_db))
return JSONResponse({"can_start_prod": ok}) return JSONResponse({"can_start_prod": ok})
# ========== CORRESPONDANCE HPROD ↔ PROD ========== # Correspondance par-run supprimée — utiliser /patching/correspondance (global)
@router.get("/quickwin/{run_id}/correspondance") @router.get("/quickwin/{run_id}/correspondance")
async def quickwin_correspondance_page(request: Request, run_id: int, db=Depends(get_db), async def quickwin_correspondance_deprecated(request: Request, run_id: int, db=Depends(get_db)):
search: str = Query(""), pair_filter: str = Query(""), return RedirectResponse(url="/patching/correspondance", status_code=303)
env_filter: str = Query(""), domain_filter: str = Query(""),
page: int = Query(1), per_page: int = Query(50)):
user = get_current_user(request)
if not user:
return RedirectResponse(url="/login")
run = get_run(db, run_id)
if not run:
return RedirectResponse(url="/quickwin")
pairs = get_correspondance(db, run_id, search=search or None,
pair_filter=pair_filter or None, env_filter=env_filter or None,
domain_filter=domain_filter or None)
available = get_available_prod_entries(db, run_id)
matched = sum(1 for p in pairs if p["is_matched"])
unmatched = sum(1 for p in pairs if not p["is_matched"])
anomalies = sum(1 for p in pairs if p["is_anomaly"])
# Get unfiltered totals for KPIs
all_pairs = get_correspondance(db, run_id) if (search or pair_filter or env_filter or domain_filter) else pairs
# Extract domain list for filter dropdown
domains_in_run = sorted(set(p["hprod_domaine"] for p in all_pairs if p["hprod_domaine"]))
total = len(all_pairs)
total_matched = sum(1 for p in all_pairs if p["is_matched"])
total_unmatched = sum(1 for p in all_pairs if not p["is_matched"])
total_anomalies = sum(1 for p in all_pairs if p["is_anomaly"])
# Pagination
per_page = max(10, min(per_page, 200))
total_filtered = len(pairs)
total_pages = max(1, (total_filtered + per_page - 1) // per_page)
page = max(1, min(page, total_pages))
start = (page - 1) * per_page
pairs_page = pairs[start:start + per_page]
ctx = base_context(request, db, user)
ctx.update({
"app_name": APP_NAME, "run": run, "pairs": pairs_page, "available": available,
"stats": {"total": total, "matched": total_matched, "unmatched": total_unmatched, "anomalies": total_anomalies},
"filters": {"search": search, "pair_filter": pair_filter, "env_filter": env_filter, "domain_filter": domain_filter},
"domains_in_run": domains_in_run,
"page": page, "per_page": per_page, "total_pages": total_pages, "total_filtered": total_filtered,
"msg": request.query_params.get("msg"),
})
return templates.TemplateResponse("quickwin_correspondance.html", ctx)
@router.post("/quickwin/{run_id}/correspondance/auto")
async def quickwin_correspondance_auto(request: Request, run_id: int, 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, "campaigns") and not can_edit(perms, "quickwin"):
return RedirectResponse(url=f"/quickwin/{run_id}/correspondance")
m, u, a = compute_correspondance(db, run_id, user=user)
return RedirectResponse(url=f"/quickwin/{run_id}/correspondance?msg=auto&am={m}&au={u}&aa={a}", status_code=303)
@router.post("/quickwin/{run_id}/correspondance/clear-all")
async def quickwin_correspondance_clear(request: Request, run_id: int, db=Depends(get_db)):
user = get_current_user(request)
if not user:
return RedirectResponse(url="/login")
clear_all_pairs(db, run_id)
return RedirectResponse(url=f"/quickwin/{run_id}/correspondance?msg=cleared", status_code=303)
@router.post("/api/quickwin/correspondance/set-pair")
async def quickwin_set_pair_api(request: Request, db=Depends(get_db)):
user = get_current_user(request)
if not user:
return JSONResponse({"error": "unauthorized"}, 401)
body = await request.json()
hprod_id = body.get("hprod_id")
prod_id = body.get("prod_id") # 0 or null to clear
if not hprod_id:
return JSONResponse({"error": "missing hprod_id"}, 400)
set_prod_pair(db, hprod_id, prod_id if prod_id else None)
return JSONResponse({"ok": True})

View File

@ -20,7 +20,7 @@ async def servers_list(request: Request, db=Depends(get_db),
domain: str = Query(None), env: str = Query(None), domain: str = Query(None), env: str = Query(None),
tier: str = Query(None), etat: str = Query(None), tier: str = Query(None), etat: str = Query(None),
os: str = Query(None), owner: str = Query(None), os: str = Query(None), owner: str = Query(None),
application: str = Query(None), application: str = Query(None), application_id: int = Query(None),
search: str = Query(None), page: int = Query(1), search: str = Query(None), page: int = Query(1),
sort: str = Query("hostname"), sort_dir: str = Query("asc")): sort: str = Query("hostname"), sort_dir: str = Query("asc")):
user = get_current_user(request) user = get_current_user(request)
@ -28,7 +28,8 @@ async def servers_list(request: Request, db=Depends(get_db),
return RedirectResponse(url="/login") return RedirectResponse(url="/login")
filters = {"domain": domain, "env": env, "tier": tier, "etat": etat, "os": os, filters = {"domain": domain, "env": env, "tier": tier, "etat": etat, "os": os,
"owner": owner, "application": application, "search": search} "owner": owner, "application": application, "application_id": application_id,
"search": search}
servers, total = list_servers(db, filters, page, sort=sort, sort_dir=sort_dir) servers, total = list_servers(db, filters, page, sort=sort, sort_dir=sort_dir)
domains_list, envs_list = get_reference_data(db) domains_list, envs_list = get_reference_data(db)

View File

@ -226,7 +226,7 @@ def deploy_agent(hostname, ssh_user, ssh_key_path, ssh_port, os_family,
emit(f"Copie {pkg_name} ({pkg_size} Mo)...") emit(f"Copie {pkg_name} ({pkg_size} Mo)...")
sftp = client.open_sftp() sftp = client.open_sftp()
sftp.put(package_path, remote_path=f"/tmp/{pkg_name}") sftp.put(package_path, f"/tmp/{pkg_name}")
sftp.close() sftp.close()
emit("Copie terminee") emit("Copie terminee")

View File

@ -53,7 +53,7 @@ def detect_correspondances(db, dry_run=False):
# Tous les serveurs actifs (exclut stock/obsolete) # Tous les serveurs actifs (exclut stock/obsolete)
rows = db.execute(text("""SELECT id, hostname FROM servers rows = db.execute(text("""SELECT id, hostname FROM servers
WHERE etat NOT IN ('stock','obsolete') ORDER BY hostname""")).fetchall() WHERE etat NOT IN ('stock','obsolete','eol') ORDER BY hostname""")).fetchall()
by_signature = defaultdict(list) # signature -> [(server_id, env_char, hostname)] by_signature = defaultdict(list) # signature -> [(server_id, env_char, hostname)]
for r in rows: for r in rows:
@ -125,7 +125,7 @@ def detect_correspondances(db, dry_run=False):
def get_servers_for_builder(db, search="", app="", domain="", env=""): def get_servers_for_builder(db, search="", app="", domain="", env=""):
"""Retourne tous les serveurs matchant les filtres, avec leurs correspondances existantes. """Retourne tous les serveurs matchant les filtres, avec leurs correspondances existantes.
Exclut les serveurs en stock / obsolete (décommissionnés, EOL).""" Exclut les serveurs en stock / obsolete (décommissionnés, EOL)."""
where = ["s.etat NOT IN ('stock','obsolete')"] where = ["s.etat NOT IN ('stock','obsolete','eol')"]
params = {} params = {}
if search: if search:
where.append("s.hostname ILIKE :s"); params["s"] = f"%{search}%" where.append("s.hostname ILIKE :s"); params["s"] = f"%{search}%"
@ -181,7 +181,7 @@ def bulk_create_correspondance(db, prod_ids, nonprod_ids, env_labels, user_id):
def get_correspondance_view(db, search="", app="", env=""): def get_correspondance_view(db, search="", app="", env=""):
"""Vue hiérarchique des correspondances groupées par application. """Vue hiérarchique des correspondances groupées par application.
Exclut les serveurs en stock/obsolete.""" Exclut les serveurs en stock/obsolete."""
where = ["s.etat NOT IN ('stock','obsolete')"] where = ["s.etat NOT IN ('stock','obsolete','eol')"]
params = {} params = {}
if search: if search:
where.append("s.hostname ILIKE :s"); params["s"] = f"%{search}%" where.append("s.hostname ILIKE :s"); params["s"] = f"%{search}%"
@ -330,7 +330,7 @@ def get_orphan_nonprod(db):
LEFT JOIN environments e ON de.environment_id = e.id LEFT JOIN environments e ON de.environment_id = e.id
LEFT JOIN domains d ON de.domain_id = d.id LEFT JOIN domains d ON de.domain_id = d.id
WHERE e.name IS NOT NULL AND e.name NOT ILIKE '%production%' WHERE e.name IS NOT NULL AND e.name NOT ILIKE '%production%'
AND s.etat NOT IN ('stock','obsolete') AND s.etat NOT IN ('stock','obsolete','eol')
AND NOT EXISTS (SELECT 1 FROM server_correspondance sc WHERE sc.nonprod_server_id = s.id) AND NOT EXISTS (SELECT 1 FROM server_correspondance sc WHERE sc.nonprod_server_id = s.id)
ORDER BY s.application_name, s.hostname ORDER BY s.application_name, s.hostname
LIMIT 500 LIMIT 500

View File

@ -337,9 +337,10 @@ def sync_from_itop(db, itop_url, itop_user, itop_pass):
"patch_excludes,domain_ldap_name,last_patch_date," "patch_excludes,domain_ldap_name,last_patch_date,"
"applicationsolution_list") "applicationsolution_list")
# PatchCenter etat = iTop status (meme enum: production, implementation, stock, obsolete) # PatchCenter etat = iTop status (meme enum: production, implementation, stock, obsolete, eol)
itop_status = {"production": "production", "stock": "stock", itop_status = {"production": "production", "stock": "stock",
"implementation": "implementation", "obsolete": "obsolete"} "implementation": "implementation", "obsolete": "obsolete",
"eol": "eol"}
for v in vms: for v in vms:
hostname = v.get("name", "").split(".")[0].lower() hostname = v.get("name", "").split(".")[0].lower()
@ -562,7 +563,7 @@ def sync_to_itop(db, itop_url, itop_user, itop_pass):
itop_vms[v["name"].split(".")[0].lower()] = v itop_vms[v["name"].split(".")[0].lower()] = v
status_map = {"production": "production", "implementation": "implementation", status_map = {"production": "production", "implementation": "implementation",
"stock": "stock", "obsolete": "obsolete"} "stock": "stock", "obsolete": "obsolete", "eol": "eol"}
tier_map = {"tier0": "Tier 0", "tier1": "Tier 1", "tier2": "Tier 2", "tier3": "Tier 3"} tier_map = {"tier0": "Tier 0", "tier1": "Tier 1", "tier2": "Tier 2", "tier3": "Tier 3"}
# Build OSVersion cache: name.lower() → itop_id # Build OSVersion cache: name.lower() → itop_id

View File

@ -680,151 +680,8 @@ def inject_yum_history(db, data):
return updated, inserted return updated, inserted
# ========== CORRESPONDANCE HPROD ↔ PROD ========== # Correspondance HPROD ↔ PROD : logique déplacée vers server_correspondance (global)
# Les fonctions obsolètes ont été supprimées : compute_correspondance, get_correspondance,
def compute_correspondance(db, run_id, user=None): # get_available_prod_entries, set_prod_pair, clear_all_pairs.
"""Auto-apparie chaque serveur hprod avec son homologue prod (2e lettre → p). # La colonne prod_pair_entry_id de quickwin_entries est laissée en place pour compatibilité
Retourne (matched, unmatched, anomalies).""" # mais n'est plus utilisée. Les liens sont désormais dans server_correspondance.
by = user.get("display_name", user.get("username", "")) if user else ""
hprod_rows = db.execute(text("""
SELECT qe.id, LOWER(s.hostname) as hostname
FROM quickwin_entries qe JOIN servers s ON qe.server_id = s.id
WHERE qe.run_id = :rid AND qe.branch = 'hprod' AND qe.status != 'excluded'
"""), {"rid": run_id}).fetchall()
prod_rows = db.execute(text("""
SELECT qe.id, LOWER(s.hostname) as hostname
FROM quickwin_entries qe JOIN servers s ON qe.server_id = s.id
WHERE qe.run_id = :rid AND qe.branch = 'prod' AND qe.status != 'excluded'
"""), {"rid": run_id}).fetchall()
prod_by_host = {r.hostname: r.id for r in prod_rows}
matched = 0
unmatched = 0
anomalies = 0
skipped = 0
# Existing pairs — ne pas toucher
existing = {r.id for r in db.execute(text("""
SELECT id FROM quickwin_entries
WHERE run_id = :rid AND branch = 'hprod' AND prod_pair_entry_id IS NOT NULL
"""), {"rid": run_id}).fetchall()}
for h in hprod_rows:
if h.id in existing:
skipped += 1
continue
if len(h.hostname) < 2:
unmatched += 1
continue
candidate = h.hostname[0] + 'p' + h.hostname[2:]
if candidate == h.hostname:
anomalies += 1
if candidate in prod_by_host:
db.execute(text("""
UPDATE quickwin_entries SET prod_pair_entry_id = :pid WHERE id = :hid
"""), {"pid": prod_by_host[candidate], "hid": h.id})
matched += 1
else:
unmatched += 1
log_info(db, run_id, "correspondance",
f"Auto-appariement: {matched} nouveaux, {skipped} conservés, {unmatched} sans homologue, {anomalies} anomalies",
created_by=by)
db.commit()
return matched, unmatched, anomalies
def get_correspondance(db, run_id, search=None, pair_filter=None, env_filter=None, domain_filter=None):
"""Retourne la liste des hprod avec leur homologue prod (ou NULL)."""
rows = db.execute(text("""
SELECT hp.id as hprod_id, sh.hostname as hprod_hostname,
dh.name as hprod_domaine, eh.name as hprod_env,
SUBSTRING(LOWER(sh.hostname), 2, 1) as letter2,
hp.prod_pair_entry_id,
pp.id as prod_id, sp.hostname as prod_hostname,
dp.name as prod_domaine
FROM quickwin_entries hp
JOIN servers sh ON hp.server_id = sh.id
LEFT JOIN domain_environments deh ON sh.domain_env_id = deh.id
LEFT JOIN domains dh ON deh.domain_id = dh.id
LEFT JOIN environments eh ON deh.environment_id = eh.id
LEFT JOIN quickwin_entries pp ON hp.prod_pair_entry_id = pp.id
LEFT JOIN servers sp ON pp.server_id = sp.id
LEFT JOIN domain_environments dep ON sp.domain_env_id = dep.id
LEFT JOIN domains dp ON dep.domain_id = dp.id
WHERE hp.run_id = :rid AND hp.branch = 'hprod' AND hp.status != 'excluded'
ORDER BY sh.hostname
"""), {"rid": run_id}).fetchall()
result = []
for r in rows:
candidate = ""
if len(r.hprod_hostname) >= 2:
candidate = r.hprod_hostname[0] + 'p' + r.hprod_hostname[2:]
is_anomaly = (r.letter2 == 'p')
is_matched = r.prod_pair_entry_id is not None
if pair_filter == "matched" and not is_matched:
continue
if pair_filter == "unmatched" and is_matched:
continue
if pair_filter == "anomaly" and not is_anomaly:
continue
if env_filter:
env_map = {"preprod": "i", "recette": "r", "dev": "d", "test": "vt"}
allowed_letters = env_map.get(env_filter, "")
if r.letter2 not in allowed_letters:
continue
if domain_filter and (r.hprod_domaine or '') != domain_filter:
continue
if search and search.lower() not in r.hprod_hostname.lower():
if not (r.prod_hostname and search.lower() in r.prod_hostname.lower()):
continue
result.append({
"hprod_id": r.hprod_id,
"hprod_hostname": r.hprod_hostname,
"hprod_domaine": r.hprod_domaine or "",
"hprod_env": r.hprod_env or "",
"letter2": r.letter2,
"candidate": candidate,
"is_anomaly": is_anomaly,
"prod_id": r.prod_id,
"prod_hostname": r.prod_hostname or "",
"prod_domaine": r.prod_domaine or "",
"is_matched": is_matched,
})
return result
def get_available_prod_entries(db, run_id):
"""Retourne toutes les entries prod (un prod peut etre apparie a plusieurs hprod)."""
return db.execute(text("""
SELECT qe.id, s.hostname, d.name as domaine
FROM quickwin_entries qe
JOIN servers s ON qe.server_id = s.id
LEFT JOIN domain_environments de ON s.domain_env_id = de.id
LEFT JOIN domains d ON de.domain_id = d.id
WHERE qe.run_id = :rid AND qe.branch = 'prod' AND qe.status != 'excluded'
ORDER BY s.hostname
"""), {"rid": run_id}).fetchall()
def set_prod_pair(db, hprod_entry_id, prod_entry_id):
"""Associe manuellement un hprod à un prod (ou NULL pour dissocier)."""
pid = prod_entry_id if prod_entry_id else None
db.execute(text("""
UPDATE quickwin_entries SET prod_pair_entry_id = :pid, updated_at = now() WHERE id = :hid
"""), {"pid": pid, "hid": hprod_entry_id})
db.commit()
def clear_all_pairs(db, run_id):
"""Supprime tous les appariements d'un run."""
db.execute(text("""
UPDATE quickwin_entries SET prod_pair_entry_id = NULL, updated_at = now()
WHERE run_id = :rid AND branch = 'hprod'
"""), {"rid": run_id})
db.commit()

View File

@ -115,17 +115,20 @@ def list_servers(db, filters, page=1, per_page=50, sort="hostname", sort_dir="as
if filters.get("tier"): if filters.get("tier"):
where.append("s.tier = :tier"); params["tier"] = filters["tier"] where.append("s.tier = :tier"); params["tier"] = filters["tier"]
if filters.get("etat"): if filters.get("etat"):
if filters["etat"] == "obsolete":
where.append("s.licence_support = 'obsolete'")
else:
where.append("s.etat = :etat"); params["etat"] = filters["etat"] where.append("s.etat = :etat"); params["etat"] = filters["etat"]
where.append("COALESCE(s.licence_support, '') != 'obsolete'")
if filters.get("os"): if filters.get("os"):
where.append("s.os_family = :os"); params["os"] = filters["os"] where.append("s.os_family = :os"); params["os"] = filters["os"]
if filters.get("owner"): if filters.get("owner"):
where.append("s.patch_os_owner = :owner"); params["owner"] = filters["owner"] where.append("s.patch_os_owner = :owner"); params["owner"] = filters["owner"]
if filters.get("application"): if filters.get("application_id"):
where.append("s.application_name = :application"); params["application"] = filters["application"] where.append("s.application_id = :app_id"); params["app_id"] = filters["application_id"]
elif filters.get("application"):
# Matche soit application_name exact, soit via jointure application catalogue (nom_court ou nom_complet)
where.append("""(s.application_name = :application
OR s.application_id IN (SELECT id FROM applications
WHERE LOWER(nom_court) = LOWER(:application)
OR LOWER(nom_complet) = LOWER(:application)))""")
params["application"] = filters["application"]
if filters.get("search"): if filters.get("search"):
where.append("s.hostname ILIKE :search"); params["search"] = f"%{filters['search']}%" where.append("s.hostname ILIKE :search"); params["search"] = f"%{filters['search']}%"

View File

@ -0,0 +1,184 @@
{% extends 'base.html' %}
{% block title %}Administration — Applications{% endblock %}
{% block content %}
<div class="flex justify-between items-center mb-4">
<div>
<h2 class="text-xl font-bold text-cyber-accent">Applications (Solutions applicatives)</h2>
<p class="text-xs text-gray-500 mt-1">Catalogue des solutions applicatives. Synchronisé bidirectionnellement avec iTop.</p>
</div>
<div class="flex gap-2">
<a href="/admin/applications/multi-app" class="btn-sm bg-cyber-border text-cyber-accent px-3 py-2">Serveurs multi-app</a>
{% if can_edit %}
<button onclick="openAdd()" class="btn-primary px-4 py-2 text-sm">+ Nouvelle application</button>
{% endif %}
</div>
</div>
{% if msg %}
<div class="mb-3 p-2 rounded text-sm {% if 'forbidden' in msg or 'notfound' in msg or 'exists' in msg or 'itop_ko' in msg %}bg-red-900/30 text-cyber-red{% else %}bg-green-900/30 text-cyber-green{% endif %}">
{% if msg == 'added' %}Application créée.
{% elif msg == 'exists' %}Cette application existe déjà (nom court).
{% elif msg.startswith('edited') %}Application modifiée{% if 'itop_ok' in msg %} et poussée vers iTop{% elif 'itop_ko' in msg %} (push iTop échoué){% endif %}.
{% elif msg.startswith('deleted') %}Application supprimée (dissociée de {{ msg.split('_')[1] if '_' in msg else '?' }} serveurs){% if 'itop_ok' in msg %} + iTop{% elif 'itop_ko' in msg %} — push iTop KO{% endif %}.
{% elif msg == 'forbidden' %}Permission refusée.
{% elif msg == 'notfound' %}Application introuvable.
{% else %}{{ msg }}{% endif %}
</div>
{% endif %}
<!-- KPIs -->
<div class="flex gap-2 mb-4">
<div class="card p-3 text-center" style="flex:1"><div class="text-2xl font-bold text-cyber-accent">{{ stats.total }}</div><div class="text-xs text-gray-500">Total applications</div></div>
<div class="card p-3 text-center" style="flex:1"><div class="text-2xl font-bold text-cyber-blue">{{ stats.from_itop }}</div><div class="text-xs text-gray-500">Liées iTop</div></div>
<div class="card p-3 text-center" style="flex:1"><div class="text-2xl font-bold text-cyber-green">{{ stats.used }}</div><div class="text-xs text-gray-500">Utilisées (avec serveurs)</div></div>
<div class="card p-3 text-center" style="flex:1"><div class="text-2xl font-bold text-cyber-yellow">{{ stats.total - stats.used }}</div><div class="text-xs text-gray-500">Non utilisées</div></div>
</div>
<!-- Filtres -->
<div class="card p-3 mb-4">
<form method="GET" class="flex gap-2 items-center flex-wrap">
<input type="text" name="search" value="{{ filters.search }}" placeholder="Nom / description..." class="text-xs py-1 px-2" style="width:250px">
<select name="criticite" class="text-xs py-1 px-2" style="width:140px">
<option value="">Toutes criticités</option>
{% for v,l in crit_choices %}<option value="{{ v }}" {% if filters.criticite == v %}selected{% endif %}>{{ l }}</option>{% endfor %}
</select>
<select name="status" class="text-xs py-1 px-2" style="width:140px">
<option value="">Tous statuts</option>
{% for v,l in status_choices %}<option value="{{ v }}" {% if filters.status == v %}selected{% endif %}>{{ l }}</option>{% endfor %}
</select>
<select name="has_itop" class="text-xs py-1 px-2" style="width:140px">
<option value="">Toutes</option>
<option value="yes" {% if filters.has_itop == 'yes' %}selected{% endif %}>Liées iTop</option>
<option value="no" {% if filters.has_itop == 'no' %}selected{% endif %}>Locales uniquement</option>
</select>
<select name="domain" class="text-xs py-1 px-2" style="width:160px">
<option value="">Tous domaines</option>
{% for d in domains_list %}<option value="{{ d }}" {% if filters.domain == d %}selected{% endif %}>{{ d }}</option>{% endfor %}
</select>
<button type="submit" class="btn-primary px-3 py-1 text-xs">Filtrer</button>
<a href="/admin/applications" class="text-xs text-gray-500 hover:text-cyber-accent">Reset</a>
<span class="text-xs text-gray-500 ml-auto">{{ apps|length }} apps</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">Nom court</th>
<th class="p-2 text-left">Nom complet</th>
<th class="p-2">Criticité</th>
<th class="p-2">Statut</th>
<th class="p-2">iTop ID</th>
<th class="p-2 text-left">Domaine(s)</th>
<th class="p-2">Serveurs liés</th>
{% if can_edit %}<th class="p-2">Actions</th>{% endif %}
</tr></thead>
<tbody>
{% for a in apps %}
<tr class="border-t border-cyber-border/30">
<td class="p-2 font-mono text-cyber-accent">{{ a.nom_court }}</td>
<td class="p-2 text-gray-300" title="{{ a.description or '' }}">{{ (a.nom_complet or '-')[:60] }}</td>
<td class="p-2 text-center">
<span class="badge {% if a.criticite == 'critique' %}badge-red{% elif a.criticite == 'haute' %}badge-yellow{% elif a.criticite == 'standard' %}badge-blue{% else %}badge-gray{% endif %}">{{ a.criticite }}</span>
</td>
<td class="p-2 text-center">
<span class="badge {% if a.status == 'active' %}badge-green{% elif a.status == 'obsolete' %}badge-red{% else %}badge-gray{% endif %}">{{ a.status }}</span>
</td>
<td class="p-2 text-center text-gray-500">{{ a.itop_id or '—' }}</td>
<td class="p-2 text-xs text-gray-300" style="max-width:200px" title="{{ a.domains or '' }}">{{ (a.domains or '—')[:50] }}</td>
<td class="p-2 text-center">
{% if a.nb_servers %}<a href="/servers?application_id={{ a.id }}" class="text-cyber-accent hover:underline">{{ a.nb_servers }}</a>{% else %}<span class="text-gray-600">0</span>{% endif %}
</td>
{% if can_edit %}
<td class="p-2 text-center">
<a href="/admin/applications/{{ a.id }}/assign" class="btn-sm bg-cyber-blue text-black" style="padding:2px 8px;text-decoration:none">+ Serveurs</a>
<button onclick='openEdit({{ a.id }}, {{ a.nom_court|tojson }}, {{ a.nom_complet|tojson }}, {{ (a.description or "")|tojson }}, {{ (a.editeur or "")|tojson }}, "{{ a.criticite }}", "{{ a.status }}")' class="btn-sm bg-cyber-border text-cyber-accent">Éditer</button>
<form method="POST" action="/admin/applications/{{ a.id }}/delete" style="display:inline" onsubmit="return confirm('Supprimer {{ a.nom_court }} ? {% if a.nb_servers %}{{ a.nb_servers }} serveurs seront dissociés.{% endif %}{% if a.itop_id %} Également supprimée dans iTop.{% endif %}')">
<button class="btn-sm bg-red-900/30 text-cyber-red">Suppr.</button>
</form>
</td>
{% endif %}
</tr>
{% endfor %}
{% if not apps %}
<tr><td colspan="8" class="p-6 text-center text-gray-500">Aucune application</td></tr>
{% endif %}
</tbody>
</table>
</div>
<!-- Modal Add/Edit -->
{% if can_edit %}
<div id="app-modal" style="display:none;position:fixed;inset:0;background:rgba(0,0,0,0.7);z-index:9999;justify-content:center;align-items:center">
<div class="card p-5" style="width:500px;max-width:95vw">
<h3 class="text-sm font-bold text-cyber-accent mb-3" id="modal-title">Nouvelle application</h3>
<form id="app-form" method="POST" action="/admin/applications/add" class="space-y-3">
<div>
<label class="text-xs text-gray-500 block mb-1">Nom court *</label>
<input type="text" name="nom_court" id="f-nom-court" required maxlength="50" class="w-full text-xs">
</div>
<div>
<label class="text-xs text-gray-500 block mb-1">Nom complet</label>
<input type="text" name="nom_complet" id="f-nom-complet" maxlength="200" class="w-full text-xs">
</div>
<div>
<label class="text-xs text-gray-500 block mb-1">Description</label>
<textarea name="description" id="f-description" rows="2" maxlength="500" class="w-full text-xs"></textarea>
</div>
<div>
<label class="text-xs text-gray-500 block mb-1">Éditeur</label>
<input type="text" name="editeur" id="f-editeur" maxlength="100" class="w-full text-xs">
</div>
<div class="grid grid-cols-2 gap-3">
<div>
<label class="text-xs text-gray-500 block mb-1">Criticité</label>
<select name="criticite" id="f-criticite" class="w-full text-xs">
{% for v,l in crit_choices %}<option value="{{ v }}">{{ l }}</option>{% endfor %}
</select>
</div>
<div>
<label class="text-xs text-gray-500 block mb-1">Statut</label>
<select name="status" id="f-status" class="w-full text-xs">
{% for v,l in status_choices %}<option value="{{ v }}">{{ l }}</option>{% endfor %}
</select>
</div>
</div>
<div id="push-zone">
<label class="text-xs text-gray-400 flex items-center gap-2">
<input type="checkbox" name="push_itop" checked> Créer aussi dans iTop (recommandé)
</label>
</div>
<div class="flex gap-2 justify-end pt-2">
<button type="button" onclick="document.getElementById('app-modal').style.display='none'" class="btn-sm bg-cyber-border text-gray-300 px-4 py-2">Annuler</button>
<button type="submit" class="btn-primary px-4 py-2 text-sm">Enregistrer</button>
</div>
</form>
</div>
</div>
<script>
function openAdd() {
document.getElementById('modal-title').textContent = 'Nouvelle application';
const form = document.getElementById('app-form');
form.action = '/admin/applications/add';
form.reset();
document.getElementById('push-zone').style.display = 'block';
document.getElementById('app-modal').style.display = 'flex';
}
function openEdit(id, nomCourt, nomComplet, description, editeur, criticite, status) {
document.getElementById('modal-title').textContent = 'Éditer : ' + nomCourt;
const form = document.getElementById('app-form');
form.action = '/admin/applications/' + id + '/edit';
document.getElementById('f-nom-court').value = nomCourt || '';
document.getElementById('f-nom-complet').value = nomComplet || '';
document.getElementById('f-description').value = description || '';
document.getElementById('f-editeur').value = editeur || '';
document.getElementById('f-criticite').value = criticite || 'basse';
document.getElementById('f-status').value = status || 'active';
document.getElementById('push-zone').style.display = 'none'; // pas de checkbox en édit
document.getElementById('app-modal').style.display = 'flex';
}
</script>
{% endif %}
{% endblock %}

View File

@ -0,0 +1,126 @@
{% extends 'base.html' %}
{% block title %}Associer serveurs — {{ app.nom_court }}{% endblock %}
{% block content %}
<div class="flex justify-between items-center mb-4">
<div>
<a href="/admin/applications" class="text-xs text-gray-500 hover:text-gray-300">&larr; Applications</a>
<h2 class="text-xl font-bold text-cyber-accent">Associer serveurs à : <span class="font-mono">{{ app.nom_court }}</span></h2>
<p class="text-xs text-gray-500 mt-1">Sélectionner des serveurs et les lier à cette application. Push iTop automatique.</p>
</div>
</div>
<!-- Filtres -->
<div class="card p-3 mb-4">
<form method="GET" class="flex gap-2 items-center flex-wrap">
<input type="text" name="search" value="{{ filters.search }}" placeholder="Hostname..." class="text-xs py-1 px-2" style="width:200px">
<select name="domain" class="text-xs py-1 px-2" style="width:160px">
<option value="">Tous domaines</option>
{% for d in domains_list %}<option value="{{ d }}" {% if filters.domain == d %}selected{% endif %}>{{ d }}</option>{% endfor %}
</select>
<select name="env" class="text-xs py-1 px-2" style="width:140px">
<option value="">Tous envs</option>
{% for e in envs_list %}<option value="{{ e }}" {% if filters.env == e %}selected{% endif %}>{{ e }}</option>{% endfor %}
</select>
<select name="assigned" class="text-xs py-1 px-2" style="width:200px">
<option value="">Tous serveurs</option>
<option value="none" {% if filters.assigned == 'none' %}selected{% endif %}>Sans app</option>
<option value="other" {% if filters.assigned == 'other' %}selected{% endif %}>Liés à autre app</option>
<option value="current" {% if filters.assigned == 'current' %}selected{% endif %}>Déjà liés à celle-ci</option>
</select>
<button type="submit" class="btn-primary px-3 py-1 text-xs">Filtrer</button>
<a href="/admin/applications/{{ app.id }}/assign" class="text-xs text-gray-500 hover:text-cyber-accent">Reset</a>
<span class="text-xs text-gray-500 ml-auto">{{ total }} serveurs</span>
</form>
</div>
<!-- Bulk bar -->
<div id="bulk-bar" class="card p-3 mb-2 flex gap-2 items-center flex-wrap" style="display:none;border-left:3px solid #00d4ff">
<span class="text-xs text-gray-400"><b id="bulk-count">0</b> sélectionné(s)</span>
<button onclick="assignSelected()" class="btn-primary px-4 py-2 text-sm">Associer à <span class="font-mono">{{ app.nom_court }}</span></button>
<span id="bulk-result" class="text-xs ml-2"></span>
</div>
<!-- Tableau -->
<div class="card overflow-x-auto">
<table class="w-full table-cyber text-xs">
<thead><tr>
<th class="p-2 w-8"><input type="checkbox" id="check-all" onchange="toggleAll(this)"></th>
<th class="p-2 text-left">Hostname</th>
<th class="p-2">OS</th>
<th class="p-2">Domaine</th>
<th class="p-2">Env</th>
<th class="p-2 text-left">App actuelle</th>
</tr></thead>
<tbody>
{% for s in servers %}
<tr class="border-t border-cyber-border/30 {% if s.application_id == app.id %}bg-green-900/10{% endif %}">
<td class="p-2 text-center">
<input type="checkbox" class="srv-check" value="{{ s.id }}" onchange="updateBulk()" {% if s.application_id == app.id %}disabled title="Déjà lié"{% endif %}>
</td>
<td class="p-2 font-mono text-cyber-accent">{{ s.hostname }}</td>
<td class="p-2 text-center">{{ s.os_family or '-' }}</td>
<td class="p-2 text-center text-gray-400">{{ s.domain_name or '-' }}</td>
<td class="p-2 text-center">{{ s.env_name or '-' }}</td>
<td class="p-2 text-xs">
{% if s.application_id == app.id %}
<span class="text-cyber-green">✓ déjà lié</span>
{% elif s.application_name %}
<span class="text-cyber-yellow" title="Sera remplacée">{{ s.application_name }}</span>
{% else %}
<span class="text-gray-600"></span>
{% endif %}
</td>
</tr>
{% endfor %}
{% if not servers %}
<tr><td colspan="6" class="p-6 text-center text-gray-500">Aucun serveur</td></tr>
{% endif %}
</tbody>
</table>
</div>
<!-- Pagination -->
{% if total_pages > 1 %}
<div class="flex justify-center gap-2 mt-4">
{% for p in range(1, total_pages + 1) %}
<a href="?page={{ p }}{% for k,v in filters.items() %}{% if v %}&{{ k }}={{ v }}{% endif %}{% endfor %}"
class="btn-sm {% if p == page %}bg-cyber-accent text-black{% else %}bg-cyber-border text-gray-300{% endif %} px-2 py-1">{{ p }}</a>
{% endfor %}
</div>
{% endif %}
<script>
function toggleAll(cb) {
document.querySelectorAll('.srv-check:not(:disabled)').forEach(c => c.checked = cb.checked);
updateBulk();
}
function updateBulk() {
const ids = Array.from(document.querySelectorAll('.srv-check:checked')).map(c => parseInt(c.value));
window._selectedIds = ids;
document.getElementById('bulk-count').textContent = ids.length;
document.getElementById('bulk-bar').style.display = ids.length > 0 ? 'flex' : 'none';
}
function assignSelected() {
const ids = window._selectedIds || [];
if (!ids.length) return;
if (!confirm('Associer ' + ids.length + ' serveur(s) à "{{ app.nom_court }}" ?\n(Cela écrasera leur app actuelle + push iTop)')) return;
const res = document.getElementById('bulk-result');
res.textContent = 'En cours...'; res.className = 'text-xs ml-2 text-gray-400';
fetch('/admin/applications/{{ app.id }}/assign', {
method: 'POST', credentials: 'same-origin',
headers: {'Content-Type': 'application/json'},
body: JSON.stringify({server_ids: ids})
})
.then(r => r.json())
.then(d => {
if (d.ok) {
res.innerHTML = '✓ ' + d.updated + ' associés — iTop: <b class="text-cyber-green">' + d.itop_pushed + '</b> OK / <b class="text-cyber-red">' + d.itop_errors + '</b> KO';
res.className = 'text-xs ml-2';
setTimeout(() => location.reload(), 1500);
} else {
res.textContent = '✗ ' + (d.msg || 'Erreur'); res.className = 'text-xs ml-2 text-cyber-red';
}
});
}
</script>
{% endblock %}

View File

@ -0,0 +1,79 @@
{% extends 'base.html' %}
{% block title %}Serveurs multi-applications{% endblock %}
{% block content %}
<div class="flex justify-between items-center mb-4">
<div>
<a href="/admin/applications" class="text-xs text-gray-500 hover:text-gray-300">&larr; Applications</a>
<h2 class="text-xl font-bold text-cyber-accent">Serveurs liés à plusieurs applications</h2>
<p class="text-xs text-gray-500 mt-1">Source : iTop (<code>applicationsolution_list</code> avec 2+ entrées). Cliquer sur une app pour la garder comme unique.</p>
</div>
</div>
<div class="card p-3 mb-4 text-xs text-gray-400" style="background:#111827">
<b class="text-cyber-accent">Règle :</b> si un serveur apparaît sous plusieurs apps, souvent c'est une duplication (même app avec noms différents) ou une erreur de saisie. Sélectionner l'app à conserver → les autres seront retirées du serveur (côté iTop également).
</div>
{% if not multi_servers %}
<div class="card p-6 text-center text-gray-500">
<p>Aucun serveur avec plusieurs applications dans iTop.</p>
</div>
{% else %}
<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-left">App actuelle (PatchCenter)</th>
<th class="p-2 text-left">Apps iTop</th>
<th class="p-2">Action</th>
</tr></thead>
<tbody>
{% for m in multi_servers %}
<tr class="border-t border-cyber-border/30" id="row-{{ loop.index }}">
<td class="p-2 font-mono text-cyber-accent">{{ m.hostname }}</td>
<td class="p-2 text-xs text-gray-300">{{ m.current_app_name or '—' }}</td>
<td class="p-2">
{% for a in m.apps %}
<button onclick="keepApp('{{ m.hostname }}', {{ a.itop_id }}, '{{ a.name|e }}', {{ loop.index0 }}, this)"
class="btn-sm bg-cyber-border text-cyber-accent" style="margin:2px;padding:4px 10px">
<span class="font-mono">{{ a.name }}</span>
</button>
{% endfor %}
</td>
<td class="p-2 text-center">
<span id="result-{{ loop.index }}" class="text-xs"></span>
</td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
{% endif %}
<script>
function keepApp(hostname, keepItopId, appName, btnIdx, btn) {
if (!confirm('Garder UNIQUEMENT "' + appName + '" pour ' + hostname + ' ?\n(Les autres apps seront retirées, iTop mis à jour)')) return;
const row = btn.closest('tr');
const resultEl = row.querySelector('[id^=result-]');
resultEl.textContent = '…';
resultEl.className = 'text-xs text-gray-400';
fetch('/admin/applications/keep-single-app', {
method: 'POST', credentials: 'same-origin',
headers: {'Content-Type': 'application/json'},
body: JSON.stringify({hostname: hostname, keep_itop_id: keepItopId})
})
.then(r => r.json())
.then(d => {
if (d.ok) {
resultEl.textContent = '✓ ' + d.app_name;
resultEl.className = 'text-xs text-cyber-green';
// Griser la ligne
row.style.opacity = '0.5';
row.querySelectorAll('button').forEach(b => b.disabled = true);
} else {
resultEl.textContent = '✗ ' + (d.msg || 'Erreur');
resultEl.className = 'text-xs text-cyber-red';
}
});
}
</script>
{% endblock %}

View File

@ -101,7 +101,6 @@
<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>
<a href="/quickwin/correspondance" style="padding-left:3rem" class="block py-1 pr-3 rounded-md text-xs hover:bg-cyber-border/30 {% if 'correspondance' in path %}bg-cyber-border/30 text-cyber-accent{% else %}text-gray-500{% endif %}">Correspondance</a>
{% endif %} {% endif %}
</div> </div>
</div> </div>
@ -149,7 +148,8 @@
</button> </button>
<div x-show="open === 'admin'" x-cloak class="space-y-1 pl-1"> <div x-show="open === 'admin'" x-cloak class="space-y-1 pl-1">
{% 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.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.users %}<a href="/users" class="block px-3 py-1.5 rounded-md text-xs hover:bg-cyber-border/30 {% if 'users' in path %}bg-cyber-border/30 text-cyber-accent{% else %}text-gray-400{% endif %} pl-6">Utilisateurs</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 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 %}<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 %}<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.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 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>

View File

@ -51,7 +51,7 @@
<div class="flex justify-between"><span class="text-gray-500">Environnement</span><span class="badge {% if s.environnement == 'Production' %}badge-green{% else %}badge-yellow{% endif %}">{{ s.environnement }}</span></div> <div class="flex justify-between"><span class="text-gray-500">Environnement</span><span class="badge {% if s.environnement == 'Production' %}badge-green{% else %}badge-yellow{% endif %}">{{ s.environnement }}</span></div>
<div class="flex justify-between"><span class="text-gray-500">Zone</span><span class="badge {% if s.zone == 'DMZ' %}badge-red{% else %}badge-blue{% endif %}">{{ s.zone or 'LAN' }}</span></div> <div class="flex justify-between"><span class="text-gray-500">Zone</span><span class="badge {% if s.zone == 'DMZ' %}badge-red{% else %}badge-blue{% endif %}">{{ s.zone or 'LAN' }}</span></div>
<div class="flex justify-between"><span class="text-gray-500">Tier</span><span class="badge {% if s.tier == 'tier0' %}badge-red{% elif s.tier == 'tier1' %}badge-yellow{% else %}badge-blue{% endif %}">{{ s.tier }}</span></div> <div class="flex justify-between"><span class="text-gray-500">Tier</span><span class="badge {% if s.tier == 'tier0' %}badge-red{% elif s.tier == 'tier1' %}badge-yellow{% else %}badge-blue{% endif %}">{{ s.tier }}</span></div>
<div class="flex justify-between"><span class="text-gray-500">Etat</span><span class="badge {% if s.etat == 'production' %}badge-green{% elif s.etat == 'obsolete' %}badge-red{% else %}badge-yellow{% endif %}">{{ s.etat }}</span></div> <div class="flex justify-between"><span class="text-gray-500">Etat</span><span class="badge {% if s.etat == 'production' %}badge-green{% elif s.etat in ('obsolete','eol') %}badge-red{% else %}badge-yellow{% endif %}">{% if s.etat == 'obsolete' %}Décommissionné{% elif s.etat == 'eol' %}EOL{% else %}{{ s.etat }}{% endif %}</span></div>
</div> </div>
</div> </div>

View File

@ -58,7 +58,7 @@
<div> <div>
<label class="text-xs text-gray-500">Etat</label> <label class="text-xs text-gray-500">Etat</label>
<select name="etat" class="w-full"> <select name="etat" class="w-full">
{% for e in ['production','implementation','stock','obsolete'] %}<option value="{{ e }}" {% if e == s.etat %}selected{% endif %}>{{ e }}</option>{% endfor %} {% for e,l in [('production','Production'),('implementation','Implementation'),('stock','Stock'),('obsolete','Décommissionné'),('eol','EOL')] %}<option value="{{ e }}" {% if e == s.etat %}selected{% endif %}>{{ l }}</option>{% endfor %}
</select> </select>
</div> </div>
<div> <div>

View File

@ -115,10 +115,17 @@
<td class="p-2 text-xs text-gray-300" title="{{ s.application_name or '' }}">{{ (s.application_name or '-')[:35] }}</td> <td class="p-2 text-xs text-gray-300" title="{{ s.application_name or '' }}">{{ (s.application_name or '-')[:35] }}</td>
<td class="p-2 text-center text-gray-400">{{ s.domain_name or '-' }}</td> <td class="p-2 text-center text-gray-400">{{ s.domain_name or '-' }}</td>
<td class="p-2 text-center text-gray-400">{{ s.zone_name or '-' }}</td> <td class="p-2 text-center text-gray-400">{{ s.zone_name or '-' }}</td>
<td class="p-2 text-center"> <td class="p-2 text-xs" style="max-width:260px">
{% if s.n_as_prod %}<span class="badge badge-green" style="font-size:9px" title="Liés comme prod">{{ s.n_as_prod }}P</span>{% endif %} {% set link = server_links.get(s.id, {}) %}
{% if s.n_as_nonprod %}<span class="badge badge-yellow" style="font-size:9px" title="Liés comme non-prod">{{ s.n_as_nonprod }}N</span>{% endif %} {% if link and link.as_prod %}
{% if not s.n_as_prod and not s.n_as_nonprod %}<span class="text-gray-600">-</span>{% endif %} <span class="text-cyber-green" style="font-size:10px">→ non-prod :</span>
{% for l in link.as_prod %}<span class="font-mono text-gray-300" title="{{ l.env_name or '' }}">{{ l.hostname }}{% if not loop.last %}, {% endif %}</span>{% endfor %}
{% elif link and link.as_nonprod %}
<span class="text-cyber-yellow" style="font-size:10px">→ prod :</span>
{% for l in link.as_nonprod %}<span class="font-mono text-gray-300">{{ l.hostname }}{% if not loop.last %}, {% endif %}</span>{% endfor %}
{% else %}
<span class="text-gray-600"></span>
{% endif %}
</td> </td>
{% if can_edit %} {% if can_edit %}
<td class="p-2 text-center"> <td class="p-2 text-center">

View File

@ -201,7 +201,7 @@ function refreshAgents() {
<td class="p-2 text-center text-gray-400">{{ s.domain or '-' }}</td> <td class="p-2 text-center text-gray-400">{{ s.domain or '-' }}</td>
<td class="p-2 text-center">{{ s.env or '-' }}</td> <td class="p-2 text-center">{{ s.env or '-' }}</td>
<td class="p-2 text-center">{% if s.zone == 'DMZ' %}<span class="badge badge-red">DMZ</span>{% else %}{{ s.zone or '-' }}{% endif %}</td> <td class="p-2 text-center">{% if s.zone == 'DMZ' %}<span class="badge badge-red">DMZ</span>{% else %}{{ s.zone or '-' }}{% endif %}</td>
<td class="p-2 text-center" title="{{ s.etat or '' }}"><span class="badge {% if s.etat == 'production' %}badge-green{% elif s.etat == 'obsolete' %}badge-red{% elif s.etat == 'stock' %}badge-gray{% else %}badge-yellow{% endif %}">{{ (s.etat or '-')[:8] }}</span></td> <td class="p-2 text-center" title="{{ s.etat or '' }}"><span class="badge {% if s.etat == 'production' %}badge-green{% elif s.etat in ('obsolete','eol') %}badge-red{% elif s.etat == 'stock' %}badge-gray{% else %}badge-yellow{% endif %}">{% if s.etat == 'obsolete' %}Décom.{% elif s.etat == 'eol' %}EOL{% elif s.etat == 'production' %}Prod{% else %}{{ (s.etat or '-')[:8] }}{% endif %}</span></td>
</tr> </tr>
{% endfor %} {% endfor %}
</tbody> </tbody>

View File

@ -1,258 +0,0 @@
{% extends "base.html" %}
{% block title %}Correspondance QuickWin #{{ run.id }}{% endblock %}
{% macro qs(pg=page) -%}
?page={{ pg }}&per_page={{ per_page }}&search={{ filters.search or '' }}&pair_filter={{ filters.pair_filter or '' }}&domain_filter={{ filters.domain_filter or '' }}&env_filter={{ filters.env_filter or '' }}
{%- endmacro %}
{% block content %}
<div class="flex items-center justify-between mb-4">
<div>
<a href="/quickwin/{{ run.id }}" class="text-xs text-gray-500 hover:text-gray-300">&larr; Retour campagne</a>
<h1 class="text-xl font-bold" style="color:#a78bfa">Correspondance H-Prod &harr; Prod</h1>
<p class="text-xs text-gray-500">{{ run.label }} &mdash; Appariement des serveurs hors-production avec leur homologue production</p>
</div>
<div class="flex gap-2 items-center">
<form method="post" action="/quickwin/{{ run.id }}/correspondance/auto">
<button class="btn-primary" style="padding:5px 16px;font-size:0.85rem">Auto-apparier</button>
</form>
<form method="post" action="/quickwin/{{ run.id }}/correspondance/clear-all"
onsubmit="return confirm('Supprimer tous les appariements ?')">
<button class="btn-sm btn-danger" style="padding:4px 12px">Tout effacer</button>
</form>
</div>
</div>
{% if msg %}
{% if msg == 'auto' %}
{% set am = request.query_params.get('am', '0') %}
{% set au = request.query_params.get('au', '0') %}
{% set aa = request.query_params.get('aa', '0') %}
<div style="background:#1a5a2e;color:#8f8;padding:8px 16px;border-radius:6px;margin-bottom:12px;font-size:0.85rem">
Auto-appariement termin&eacute; : {{ am }} appari&eacute;(s), {{ au }} sans homologue, {{ aa }} anomalie(s)
</div>
{% elif msg == 'cleared' %}
<div style="background:#5a3a1a;color:#ffcc00;padding:8px 16px;border-radius:6px;margin-bottom:12px;font-size:0.85rem">
Tous les appariements ont &eacute;t&eacute; supprim&eacute;s.
</div>
{% elif msg == 'bulk' %}
{% set bc = request.query_params.get('bc', '0') %}
<div style="background:#1a5a2e;color:#8f8;padding:8px 16px;border-radius:6px;margin-bottom:12px;font-size:0.85rem">
{{ bc }} appariement(s) modifi&eacute;(s) en masse.
</div>
{% endif %}
{% endif %}
<!-- KPIs -->
<div style="display:grid;grid-template-columns:repeat(4,1fr);gap:12px;margin-bottom:16px">
<div class="card p-3 text-center">
<div class="text-2xl font-bold" style="color:#fff">{{ stats.total }}</div>
<div class="text-xs text-gray-500">Total H-Prod</div>
</div>
<div class="card p-3 text-center">
<div class="text-2xl font-bold" style="color:#00ff88">{{ stats.matched }}</div>
<div class="text-xs text-gray-500">Appari&eacute;s</div>
</div>
<div class="card p-3 text-center">
<div class="text-2xl font-bold" style="color:#ffcc00">{{ stats.unmatched }}</div>
<div class="text-xs text-gray-500">Sans homologue</div>
</div>
<div class="card p-3 text-center">
<div class="text-2xl font-bold" style="color:#ff3366">{{ stats.anomalies }}</div>
<div class="text-xs text-gray-500">Anomalies</div>
</div>
</div>
<!-- Filtres -->
<form method="GET" class="card mb-4" style="padding:10px 16px;display:flex;gap:12px;align-items:center;flex-wrap:wrap">
<input type="text" name="search" value="{{ filters.search or '' }}" placeholder="Recherche hostname..." style="width:200px">
<select name="pair_filter" onchange="this.form.submit()" style="width:160px">
<option value="">Tous</option>
<option value="matched" {% if filters.pair_filter == 'matched' %}selected{% endif %}>Appari&eacute;s</option>
<option value="unmatched" {% if filters.pair_filter == 'unmatched' %}selected{% endif %}>Sans homologue</option>
<option value="anomaly" {% if filters.pair_filter == 'anomaly' %}selected{% endif %}>Anomalies</option>
</select>
<select name="domain_filter" onchange="this.form.submit()" style="width:150px">
<option value="">Tous domaines</option>
{% for d in domains_in_run %}
<option value="{{ d }}" {% if filters.domain_filter == d %}selected{% endif %}>{{ d }}</option>
{% endfor %}
</select>
<select name="env_filter" onchange="this.form.submit()" style="width:140px">
<option value="">Tous envs</option>
<option value="preprod" {% if filters.env_filter == 'preprod' %}selected{% endif %}>Pr&eacute;-Prod</option>
<option value="recette" {% if filters.env_filter == 'recette' %}selected{% endif %}>Recette</option>
<option value="dev" {% if filters.env_filter == 'dev' %}selected{% endif %}>D&eacute;veloppement</option>
<option value="test" {% if filters.env_filter == 'test' %}selected{% endif %}>Test</option>
</select>
<select name="per_page" onchange="this.form.submit()" style="width:70px">
{% for n in [20,50,100,200] %}
<option value="{{ n }}" {% if per_page == n %}selected{% endif %}>{{ n }}</option>
{% endfor %}
</select>
<button type="submit" class="btn-primary" style="padding:4px 14px;font-size:0.8rem">Filtrer</button>
<a href="/quickwin/{{ run.id }}/correspondance" class="text-xs text-gray-500 hover:text-gray-300">Reset</a>
<span class="text-xs text-gray-500" style="margin-left:auto">{{ total_filtered }} r&eacute;sultat(s)</span>
</form>
<!-- Actions en masse -->
<div class="card mb-3" style="padding:8px 16px;display:flex;gap:10px;align-items:center">
<span class="text-xs text-gray-400"><span id="sel-count">0</span> s&eacute;lectionn&eacute;(s)</span>
<button class="btn-sm" style="background:#ff336622;color:#ff3366;padding:3px 12px" onclick="bulkClear()">Dissocier la s&eacute;lection</button>
<span style="color:#1e3a5f">|</span>
<span class="text-xs text-gray-400">Associer la s&eacute;lection &agrave; :</span>
<select id="bulk-prod" style="width:220px;font-size:0.8rem;padding:3px 6px">
<option value="">-- Serveur prod --</option>
{% for a in available %}
<option value="{{ a.id }}">{{ a.hostname }}{% if a.domaine %} ({{ a.domaine }}){% endif %}</option>
{% endfor %}
</select>
<button class="btn-sm" style="background:#00ff8822;color:#00ff88;padding:3px 12px" onclick="bulkAssign()">Associer</button>
</div>
<!-- Table -->
<div class="card">
<div class="table-wrap" style="max-height:65vh;overflow-y:auto">
<table class="table-cyber w-full">
<thead style="position:sticky;top:0;z-index:1"><tr>
<th class="px-1 py-2" style="width:28px"><input type="checkbox" id="check-all" title="Tout"></th>
<th class="px-2 py-2" style="width:160px">Serveur H-Prod</th>
<th class="px-2 py-2" style="width:100px">Domaine</th>
<th class="px-2 py-2" style="width:90px">Env</th>
<th class="px-2 py-2" style="width:160px">Candidat auto</th>
<th class="px-2 py-2" style="width:50px">Statut</th>
<th class="px-2 py-2">Serveur Prod appari&eacute;</th>
<th class="px-2 py-2" style="width:100px">Domaine Prod</th>
<th class="px-2 py-2" style="width:80px">Action</th>
</tr></thead>
<tbody>
{% for p in pairs %}
<tr id="row-{{ p.hprod_id }}" style="{% if p.is_anomaly %}background:#ff336610{% elif not p.is_matched %}background:#ffcc0008{% endif %}">
<td class="px-1 py-2"><input type="checkbox" class="row-check" value="{{ p.hprod_id }}"></td>
<td class="px-2 py-2 font-bold" style="color:#00d4ff">{{ p.hprod_hostname }}</td>
<td class="px-2 py-2 text-xs text-gray-400">{{ p.hprod_domaine }}</td>
<td class="px-2 py-2 text-xs">
{% if p.is_anomaly %}<span class="badge badge-red" title="Lettre 'p' mais class&eacute; hprod">{{ p.hprod_env or '?' }}</span>
{% else %}<span class="text-gray-400">{{ p.hprod_env }}</span>{% endif %}
</td>
<td class="px-2 py-2 text-xs text-gray-500">{{ p.candidate }}</td>
<td class="px-2 py-2 text-center">
{% if p.is_matched %}<span class="badge badge-green">OK</span>
{% elif p.is_anomaly %}<span class="badge badge-red">!</span>
{% else %}<span class="badge badge-yellow">--</span>{% endif %}
</td>
<td class="px-2 py-2">
{% if p.is_matched %}
<span style="color:#00ff88;font-weight:600">{{ p.prod_hostname }}</span>
{% else %}
<select class="prod-select" data-hprod="{{ p.hprod_id }}" style="width:100%;font-size:0.8rem;padding:3px 6px">
<option value="">-- Choisir serveur prod --</option>
{% for a in available %}
<option value="{{ a.id }}">{{ a.hostname }}{% if a.domaine %} ({{ a.domaine }}){% endif %}</option>
{% endfor %}
</select>
{% endif %}
</td>
<td class="px-2 py-2 text-xs text-gray-400">
{% if p.is_matched %}{{ p.prod_domaine }}{% endif %}
</td>
<td class="px-2 py-2 text-center">
{% if p.is_matched %}
<button class="btn-sm" style="background:#ff336622;color:#ff3366;padding:2px 8px"
onclick="clearPair({{ p.hprod_id }})">X</button>
{% else %}
<button class="btn-sm" style="background:#00ff8822;color:#00ff88;padding:2px 8px"
onclick="setPairFromSelect({{ p.hprod_id }})">OK</button>
{% endif %}
</td>
</tr>
{% endfor %}
{% if not pairs %}
<tr><td colspan="9" class="px-2 py-8 text-center text-gray-500">Aucun r&eacute;sultat{% if filters.search or filters.pair_filter %} pour ces filtres{% endif %}</td></tr>
{% endif %}
</tbody>
</table>
</div>
</div>
<!-- Pagination -->
{% if total_pages > 1 %}
<div style="display:flex;justify-content:center;gap:6px;margin-top:12px">
{% if page > 1 %}
<a href="/quickwin/{{ run.id }}/correspondance{{ qs(page - 1) }}" class="btn-sm" style="background:#1e3a5f;color:#94a3b8;padding:4px 10px">&larr;</a>
{% endif %}
{% for pg in range(1, total_pages + 1) %}
{% if pg == page %}
<span class="btn-sm" style="background:#00d4ff;color:#0a0e17;padding:4px 10px;font-weight:bold">{{ pg }}</span>
{% elif pg <= 3 or pg >= total_pages - 1 or (pg >= page - 1 and pg <= page + 1) %}
<a href="/quickwin/{{ run.id }}/correspondance{{ qs(pg) }}" class="btn-sm" style="background:#1e3a5f;color:#94a3b8;padding:4px 10px">{{ pg }}</a>
{% elif pg == 4 or pg == total_pages - 2 %}
<span class="text-gray-500" style="padding:4px 4px">&hellip;</span>
{% endif %}
{% endfor %}
{% if page < total_pages %}
<a href="/quickwin/{{ run.id }}/correspondance{{ qs(page + 1) }}" class="btn-sm" style="background:#1e3a5f;color:#94a3b8;padding:4px 10px">&rarr;</a>
{% endif %}
</div>
{% endif %}
<script>
/* ---- Select all / count ---- */
const checkAll = document.getElementById('check-all');
if (checkAll) {
checkAll.addEventListener('change', function() {
document.querySelectorAll('.row-check').forEach(cb => cb.checked = this.checked);
updateSelCount();
});
}
document.querySelectorAll('.row-check').forEach(cb => cb.addEventListener('change', updateSelCount));
function updateSelCount() {
document.getElementById('sel-count').textContent = document.querySelectorAll('.row-check:checked').length;
}
/* ---- Single actions ---- */
function setPairFromSelect(hprodId) {
const sel = document.querySelector('select[data-hprod="' + hprodId + '"]');
if (!sel) return;
const prodId = parseInt(sel.value);
if (!prodId) { alert('Choisissez un serveur prod'); return; }
apiSetPair(hprodId, prodId).then(() => location.reload());
}
function clearPair(hprodId) {
if (!confirm('Dissocier cet appariement ?')) return;
apiSetPair(hprodId, 0).then(() => location.reload());
}
/* ---- Bulk actions ---- */
function getSelected() {
return [...document.querySelectorAll('.row-check:checked')].map(cb => parseInt(cb.value));
}
function bulkClear() {
const ids = getSelected();
if (!ids.length) { alert('Aucune ligne sélectionnée'); return; }
if (!confirm('Dissocier ' + ids.length + ' appariement(s) ?')) return;
Promise.all(ids.map(id => apiSetPair(id, 0))).then(() => {
location.href = '/quickwin/{{ run.id }}/correspondance?msg=bulk&bc=' + ids.length;
});
}
function bulkAssign() {
const ids = getSelected();
if (!ids.length) { alert('Aucune ligne sélectionnée'); return; }
const prodId = parseInt(document.getElementById('bulk-prod').value);
if (!prodId) { alert('Choisissez un serveur prod'); return; }
if (!confirm('Associer ' + ids.length + ' serveur(s) au même prod ?')) return;
Promise.all(ids.map(id => apiSetPair(id, prodId))).then(() => {
location.href = '/quickwin/{{ run.id }}/correspondance?msg=bulk&bc=' + ids.length;
});
}
/* ---- API call ---- */
function apiSetPair(hprodId, prodId) {
return fetch('/api/quickwin/correspondance/set-pair', {
method: 'POST',
headers: {'Content-Type': 'application/json'},
body: JSON.stringify({hprod_id: hprodId, prod_id: prodId})
}).then(r => r.json());
}
</script>
{% endblock %}

View File

@ -25,7 +25,7 @@
<p class="text-xs text-gray-500">S{{ '%02d'|format(run.week_number) }} {{ run.year }} &mdash; Cr&eacute;&eacute; par {{ run.created_by_name or '?' }}</p> <p class="text-xs text-gray-500">S{{ '%02d'|format(run.week_number) }} {{ run.year }} &mdash; Cr&eacute;&eacute; par {{ run.created_by_name or '?' }}</p>
</div> </div>
<div class="flex gap-2 items-center"> <div class="flex gap-2 items-center">
<a href="/quickwin/{{ run.id }}/correspondance" class="btn-sm" style="background:#1e3a5f;color:#a78bfa;padding:4px 14px;text-decoration:none">Correspondance</a> <a href="/patching/correspondance" class="btn-sm" style="background:#1e3a5f;color:#a78bfa;padding:4px 14px;text-decoration:none">Correspondance</a>
<a href="/patching/validations?campaign_id={{ run.id }}" class="btn-sm" style="background:#1e3a5f;color:#00ff88;padding:4px 14px;text-decoration:none">Validations</a> <a href="/patching/validations?campaign_id={{ run.id }}" class="btn-sm" style="background:#1e3a5f;color:#00ff88;padding:4px 14px;text-decoration:none">Validations</a>
<a href="/quickwin/{{ run.id }}/logs" class="btn-sm" style="background:#1e3a5f;color:#94a3b8;padding:4px 14px;text-decoration:none">Logs</a> <a href="/quickwin/{{ run.id }}/logs" class="btn-sm" style="background:#1e3a5f;color:#94a3b8;padding:4px 14px;text-decoration:none">Logs</a>
<form method="post" action="/quickwin/{{ run.id }}/delete" onsubmit="return confirm('Supprimer cette campagne ?')"> <form method="post" action="/quickwin/{{ run.id }}/delete" onsubmit="return confirm('Supprimer cette campagne ?')">

View File

@ -36,7 +36,7 @@
{% for t in ['tier0','tier1','tier2','tier3'] %}<option value="{{ t }}" {% if filters.tier == t %}selected{% endif %}>{{ t }}</option>{% endfor %} {% for t in ['tier0','tier1','tier2','tier3'] %}<option value="{{ t }}" {% if filters.tier == t %}selected{% endif %}>{{ t }}</option>{% endfor %}
</select> </select>
<select name="etat" onchange="this.form.submit()"><option value="">Etat</option> <select name="etat" onchange="this.form.submit()"><option value="">Etat</option>
{% for e,l in [('production','Production'),('implementation','Implementation'),('stock','Stock'),('obsolete','Obsolete')] %}<option value="{{ e }}" {% if filters.etat == e %}selected{% endif %}>{{ l }}</option>{% endfor %} {% for e,l in [('production','Production'),('implementation','Implementation'),('stock','Stock'),('obsolete','Décommissionné'),('eol','EOL')] %}<option value="{{ e }}" {% if filters.etat == e %}selected{% endif %}>{{ l }}</option>{% endfor %}
</select> </select>
<select name="os" onchange="this.form.submit()"><option value="">OS</option> <select name="os" onchange="this.form.submit()"><option value="">OS</option>
<option value="linux" {% if filters.os == 'linux' %}selected{% endif %}>Linux</option> <option value="linux" {% if filters.os == 'linux' %}selected{% endif %}>Linux</option>
@ -82,7 +82,7 @@ const bulkValues = {
domain_code: [{% for d in domains_list %}{v:"{{ d.code }}", l:"{{ d.name }}"},{% endfor %}], domain_code: [{% for d in domains_list %}{v:"{{ d.code }}", l:"{{ d.name }}"},{% endfor %}],
env_code: [{% for e in envs_list %}{v:"{{ e.code }}", l:"{{ e.name }}"},{% endfor %}], env_code: [{% for e in envs_list %}{v:"{{ e.code }}", l:"{{ e.name }}"},{% endfor %}],
tier: [{v:"tier0",l:"tier0"},{v:"tier1",l:"tier1"},{v:"tier2",l:"tier2"},{v:"tier3",l:"tier3"}], tier: [{v:"tier0",l:"tier0"},{v:"tier1",l:"tier1"},{v:"tier2",l:"tier2"},{v:"tier3",l:"tier3"}],
etat: [{v:"production",l:"Production"},{v:"implementation",l:"Implementation"},{v:"stock",l:"Stock"},{v:"obsolete",l:"Obsolete"}], etat: [{v:"production",l:"Production"},{v:"implementation",l:"Implementation"},{v:"stock",l:"Stock"},{v:"obsolete",l:"Décommissionné"},{v:"eol",l:"EOL"}],
patch_os_owner: [{v:"secops",l:"secops"},{v:"ipop",l:"ipop"},{v:"na",l:"na"}], patch_os_owner: [{v:"secops",l:"secops"},{v:"ipop",l:"ipop"},{v:"na",l:"na"}],
licence_support: [{v:"active",l:"active"},{v:"obsolete",l:"obsolete"},{v:"els",l:"els"}], licence_support: [{v:"active",l:"active"},{v:"obsolete",l:"obsolete"},{v:"els",l:"els"}],
}; };
@ -135,7 +135,7 @@ function updateBulk() {
<td class="p-2 text-center text-xs text-gray-400" title="{{ s.os_version or '' }}">{{ s.os_short or '-' }}</td> <td class="p-2 text-center text-xs text-gray-400" title="{{ s.os_version or '' }}">{{ s.os_short or '-' }}</td>
<td class="p-2 text-center"><span class="badge {% if s.licence_support == 'active' %}badge-green{% elif s.licence_support == 'obsolete' %}badge-red{% elif s.licence_support == 'els' %}badge-yellow{% else %}badge-gray{% endif %}">{{ s.licence_support }}</span></td> <td class="p-2 text-center"><span class="badge {% if s.licence_support == 'active' %}badge-green{% elif s.licence_support == 'obsolete' %}badge-red{% elif s.licence_support == 'els' %}badge-yellow{% else %}badge-gray{% endif %}">{{ s.licence_support }}</span></td>
<td class="p-2 text-center"><span class="badge {% if s.tier == 'tier0' %}badge-red{% elif s.tier == 'tier1' %}badge-yellow{% elif s.tier == 'tier2' %}badge-blue{% else %}badge-green{% endif %}">{{ s.tier }}</span></td> <td class="p-2 text-center"><span class="badge {% if s.tier == 'tier0' %}badge-red{% elif s.tier == 'tier1' %}badge-yellow{% elif s.tier == 'tier2' %}badge-blue{% else %}badge-green{% endif %}">{{ s.tier }}</span></td>
<td class="p-2 text-center"><span class="badge {% if s.etat == 'production' %}badge-green{% elif s.etat == 'obsolete' %}badge-red{% else %}badge-yellow{% endif %}"title="{{ s.etat or '' }}">{{ (s.etat or '')[:8] }}</span></td> <td class="p-2 text-center"><span class="badge {% if s.etat == 'production' %}badge-green{% elif s.etat in ('obsolete','eol') %}badge-red{% else %}badge-yellow{% endif %}"title="{{ s.etat or '' }}">{% if s.etat == 'obsolete' %}Décom.{% elif s.etat == 'eol' %}EOL{% elif s.etat == 'production' %}Prod{% elif s.etat == 'implementation' %}Implm{% elif s.etat == 'stock' %}Stock{% else %}{{ (s.etat or '')[:8] }}{% endif %}</span></td>
<td class="p-2 text-center text-xs">{{ s.patch_os_owner or '-' }}</td> <td class="p-2 text-center text-xs">{{ s.patch_os_owner or '-' }}</td>
<td class="p-2 text-xs text-gray-300" title="{{ s.application_name or '' }}">{{ (s.application_name or '-')[:35] }}</td> <td class="p-2 text-xs text-gray-300" title="{{ s.application_name or '' }}">{{ (s.application_name or '-')[:35] }}</td>
<td class="p-2 text-xs" onclick="event.stopPropagation()" style="max-width:220px"> <td class="p-2 text-xs" onclick="event.stopPropagation()" style="max-width:220px">

834
tools/generate_ppt.py Normal file
View File

@ -0,0 +1,834 @@
"""Generate SANEF Patching presentation (PPTX) from the PDF source material."""
from pptx import Presentation
from pptx.util import Inches, Pt, Emu
from pptx.dml.color import RGBColor
from pptx.enum.shapes import MSO_SHAPE
from pptx.enum.text import PP_ALIGN, MSO_ANCHOR
from pptx.oxml.ns import qn
from copy import deepcopy
# --- Couleurs SANEF / cyber ---
ACCENT = RGBColor(0x00, 0xA3, 0xC4) # cyan principal
ACCENT_DARK = RGBColor(0x00, 0x6F, 0x8C)
DARK = RGBColor(0x1A, 0x1A, 0x2E) # noir profond
LIGHT_BG = RGBColor(0xF5, 0xF9, 0xFC)
RED_ALERT = RGBColor(0xC0, 0x39, 0x2B)
ORANGE = RGBColor(0xE0, 0x8A, 0x0A)
GREEN = RGBColor(0x28, 0xA7, 0x45)
GRAY = RGBColor(0x66, 0x66, 0x66)
WHITE = RGBColor(0xFF, 0xFF, 0xFF)
LIGHT_GRAY = RGBColor(0xEE, 0xEE, 0xEE)
prs = Presentation()
prs.slide_width = Inches(13.333)
prs.slide_height = Inches(7.5)
SW, SH = prs.slide_width, prs.slide_height
blank_layout = prs.slide_layouts[6]
def set_bg(slide, color):
bg = slide.shapes.add_shape(MSO_SHAPE.RECTANGLE, 0, 0, SW, SH)
bg.fill.solid()
bg.fill.fore_color.rgb = color
bg.line.fill.background()
bg.shadow.inherit = False
slide.shapes._spTree.remove(bg._element)
slide.shapes._spTree.insert(2, bg._element)
return bg
def add_rect(slide, x, y, w, h, fill=None, line=None, line_w=None):
shape = slide.shapes.add_shape(MSO_SHAPE.RECTANGLE, x, y, w, h)
if fill is None:
shape.fill.background()
else:
shape.fill.solid()
shape.fill.fore_color.rgb = fill
if line is None:
shape.line.fill.background()
else:
shape.line.color.rgb = line
if line_w:
shape.line.width = line_w
shape.shadow.inherit = False
return shape
def add_text(slide, x, y, w, h, text, *, size=18, bold=False, color=DARK,
align=PP_ALIGN.LEFT, anchor=MSO_ANCHOR.TOP, font="Calibri"):
tb = slide.shapes.add_textbox(x, y, w, h)
tf = tb.text_frame
tf.word_wrap = True
tf.margin_left = Emu(50000)
tf.margin_right = Emu(50000)
tf.margin_top = Emu(30000)
tf.margin_bottom = Emu(30000)
tf.vertical_anchor = anchor
lines = text.split("\n") if isinstance(text, str) else text
for i, ln in enumerate(lines):
p = tf.paragraphs[0] if i == 0 else tf.add_paragraph()
p.alignment = align
r = p.add_run()
r.text = ln
r.font.name = font
r.font.size = Pt(size)
r.font.bold = bold
r.font.color.rgb = color
return tb
def add_title_bar(slide, title, subtitle=None):
# top accent bar
add_rect(slide, 0, 0, SW, Inches(0.15), fill=ACCENT)
# title
add_text(slide, Inches(0.5), Inches(0.3), Inches(12), Inches(0.7), title,
size=28, bold=True, color=DARK)
if subtitle:
add_text(slide, Inches(0.5), Inches(0.95), Inches(12), Inches(0.45), subtitle,
size=14, color=GRAY)
# divider
add_rect(slide, Inches(0.5), Inches(1.45), Inches(1.5), Emu(30000), fill=ACCENT)
def add_footer(slide, page_num):
add_text(slide, Inches(0.5), Inches(7.05), Inches(7), Inches(0.3),
"SANEF DSI / SECOPS — Processus Patching & Automatisation",
size=9, color=GRAY)
add_text(slide, Inches(11.5), Inches(7.05), Inches(1.3), Inches(0.3),
f"{page_num}", size=9, color=GRAY, align=PP_ALIGN.RIGHT)
def add_bullets(slide, x, y, w, h, items, *, size=16, color=DARK, line_spacing=1.2):
tb = slide.shapes.add_textbox(x, y, w, h)
tf = tb.text_frame
tf.word_wrap = True
for i, item in enumerate(items):
if isinstance(item, tuple):
bullet_color, text = item
else:
bullet_color, text = ACCENT, item
p = tf.paragraphs[0] if i == 0 else tf.add_paragraph()
p.alignment = PP_ALIGN.LEFT
p.line_spacing = line_spacing
r1 = p.add_run()
r1.text = ""
r1.font.size = Pt(size)
r1.font.bold = True
r1.font.color.rgb = bullet_color
r1.font.name = "Calibri"
r2 = p.add_run()
r2.text = text
r2.font.size = Pt(size)
r2.font.color.rgb = color
r2.font.name = "Calibri"
return tb
def add_table(slide, x, y, w, h, header, rows, *,
header_color=ACCENT, alt_row=RGBColor(0xF5, 0xF9, 0xFC),
header_text=WHITE, font_size=12, header_size=13):
ncols = len(header)
nrows = len(rows) + 1
tbl = slide.shapes.add_table(nrows, ncols, x, y, w, h).table
# Header row
for ci, htxt in enumerate(header):
c = tbl.cell(0, ci)
c.fill.solid()
c.fill.fore_color.rgb = header_color
c.text = ""
tf = c.text_frame
tf.margin_left = Emu(50000); tf.margin_right = Emu(50000)
tf.margin_top = Emu(40000); tf.margin_bottom = Emu(40000)
p = tf.paragraphs[0]
p.alignment = PP_ALIGN.CENTER
r = p.add_run(); r.text = htxt
r.font.size = Pt(header_size); r.font.bold = True
r.font.color.rgb = header_text; r.font.name = "Calibri"
# Data rows
for ri, row in enumerate(rows, start=1):
for ci, cell_data in enumerate(row):
c = tbl.cell(ri, ci)
c.fill.solid()
c.fill.fore_color.rgb = alt_row if ri % 2 == 0 else WHITE
if isinstance(cell_data, tuple):
text, color = cell_data
else:
text, color = cell_data, DARK
c.text = ""
tf = c.text_frame
tf.margin_left = Emu(50000); tf.margin_right = Emu(50000)
tf.margin_top = Emu(30000); tf.margin_bottom = Emu(30000)
tf.word_wrap = True
p = tf.paragraphs[0]
p.alignment = PP_ALIGN.LEFT if ci == 0 else PP_ALIGN.CENTER
r = p.add_run(); r.text = text
r.font.size = Pt(font_size)
r.font.color.rgb = color
r.font.name = "Calibri"
if ci == 0:
r.font.bold = True
return tbl
def add_callout(slide, x, y, w, h, title, body, *, bg=LIGHT_BG, border=ACCENT,
title_color=ACCENT_DARK):
# left accent bar
add_rect(slide, x, y, Emu(70000), h, fill=border)
# box
box = add_rect(slide, x + Emu(70000), y, w - Emu(70000), h, fill=bg)
# title
add_text(slide, x + Inches(0.25), y + Inches(0.1), w - Inches(0.4), Inches(0.4),
title, size=14, bold=True, color=title_color)
# body
add_text(slide, x + Inches(0.25), y + Inches(0.5), w - Inches(0.4), h - Inches(0.6),
body, size=12, color=DARK)
def add_kpi(slide, x, y, w, h, value, label, color=ACCENT):
add_rect(slide, x, y, w, h, fill=WHITE, line=color, line_w=Pt(1.5))
add_text(slide, x, y + Inches(0.2), w, Inches(0.8), value,
size=36, bold=True, color=color, align=PP_ALIGN.CENTER)
add_text(slide, x, y + Inches(1.0), w, Inches(0.4), label,
size=12, color=GRAY, align=PP_ALIGN.CENTER)
# ============================================================
# SLIDE 1 — Title
# ============================================================
slide = prs.slides.add_slide(blank_layout)
set_bg(slide, DARK)
# Accent graphic
add_rect(slide, 0, Inches(2.8), SW, Inches(0.05), fill=ACCENT)
add_rect(slide, Inches(5.5), Inches(2.3), Inches(2.3), Inches(0.05), fill=ACCENT)
add_text(slide, Inches(0.8), Inches(1.5), Inches(11.5), Inches(1),
"PROCESSUS DE PATCHING", size=46, bold=True, color=WHITE)
add_text(slide, Inches(0.8), Inches(2.4), Inches(11.5), Inches(0.7),
"Manuel vs Automatisation", size=26, color=ACCENT, bold=False)
add_text(slide, Inches(0.8), Inches(3.2), Inches(11.5), Inches(0.5),
"État des lieux, charge réelle du patcheur & apport de PatchCenter Web",
size=18, color=LIGHT_GRAY)
# Bottom info
add_rect(slide, 0, Inches(6.5), SW, Inches(1), fill=RGBColor(0x0a, 0x14, 0x24))
add_text(slide, Inches(0.8), Inches(6.65), Inches(6), Inches(0.4),
"SANEF DSI / Sécurité Opérationnelle", size=14, bold=True, color=WHITE)
add_text(slide, Inches(0.8), Inches(7.0), Inches(6), Inches(0.3),
"Présentation DSI — Avril 2026", size=11, color=LIGHT_GRAY)
add_text(slide, Inches(9), Inches(6.65), Inches(3.8), Inches(0.4),
"PatchCenter Web v1", size=14, bold=True, color=ACCENT, align=PP_ALIGN.RIGHT)
add_text(slide, Inches(9), Inches(7.0), Inches(3.8), Inches(0.3),
"Équipe SECOPS", size=11, color=LIGHT_GRAY, align=PP_ALIGN.RIGHT)
# ============================================================
# SLIDE 2 — Sommaire
# ============================================================
slide = prs.slides.add_slide(blank_layout)
add_title_bar(slide, "Sommaire", "Déroulé de la présentation")
items = [
("1.", "Vue d'ensemble du processus de patching"),
("2.", "Phase 1 — Affectation (COMEP jeudi)"),
("3.", "Phase 2 — Préparation (jeudi PM + vendredi)"),
("4.", "Phase 3 — Exécution Jour J (lundi → jeudi)"),
("5.", "Phase 4 — Nettoyage (vendredi)"),
("6.", "Synthèse — Temps par serveur"),
("7.", "La réalité du patcheur — budget 35 h / semaine"),
("8.", "Équation insoluble en mode manuel"),
("9.", "Conséquence sécurité — alertes S1 & Defender"),
("10.", "Apport des outils — SQATM .exe & PatchCenter Web"),
("11.", "Comparaison chiffrée et impact hebdomadaire"),
("12.", "Feuille de route & conclusion"),
]
for i, (num, txt) in enumerate(items):
y = Inches(1.8 + 0.38 * i)
add_text(slide, Inches(0.8), y, Inches(0.6), Inches(0.4), num,
size=15, bold=True, color=ACCENT)
add_text(slide, Inches(1.4), y, Inches(11), Inches(0.4), txt,
size=15, color=DARK)
add_footer(slide, 2)
# ============================================================
# SLIDE 3 — Vue d'ensemble
# ============================================================
slide = prs.slides.add_slide(blank_layout)
add_title_bar(slide, "Vue d'ensemble", "4 phases étalées sur la semaine")
phases = [
("1. Affectation", "Jeudi 14h00\nCOMEP SECOPS", ACCENT),
("2. Préparation", "Jeudi PM + Vendredi\nQualification + validation", ORANGE),
("3. Exécution", "Lundi → Jeudi\nPatching + vérifications", RED_ALERT),
("4. Nettoyage", "Vendredi (J+3)\nSnapshots + kernels", GREEN),
]
x0 = Inches(0.6)
card_w = Inches(3.0)
card_h = Inches(3.5)
gap = Inches(0.15)
for i, (title, body, col) in enumerate(phases):
x = x0 + (card_w + gap) * i
y = Inches(2.2)
# Card with colored top
add_rect(slide, x, y, card_w, Inches(0.6), fill=col)
add_rect(slide, x, y + Inches(0.6), card_w, card_h - Inches(0.6), fill=WHITE, line=LIGHT_GRAY)
add_text(slide, x, y + Inches(0.1), card_w, Inches(0.5), title,
size=18, bold=True, color=WHITE, align=PP_ALIGN.CENTER)
# Number big
add_text(slide, x, y + Inches(0.9), card_w, Inches(1.2), str(i + 1),
size=72, bold=True, color=col, align=PP_ALIGN.CENTER)
# Body
add_text(slide, x + Inches(0.2), y + Inches(2.2), card_w - Inches(0.4), Inches(1.2),
body, size=13, color=DARK, align=PP_ALIGN.CENTER)
add_callout(slide, Inches(0.6), Inches(5.9), Inches(12.1), Inches(0.8),
"Règle de cadrage",
"Pas de patching le vendredi — évite un incident qui courrait tout le weekend. "
"Le vendredi = finalisation pré-patching + nettoyage snapshots + iTop / météo.")
add_footer(slide, 3)
# ============================================================
# SLIDE 4 — Affectation COMEP
# ============================================================
slide = prs.slides.add_slide(blank_layout)
add_title_bar(slide, "Phase 1 — Affectation", "Jeudi 14h00 — COMEP SECOPS")
add_bullets(slide, Inches(0.7), Inches(1.9), Inches(12), Inches(2.5), [
"COMEP du jeudi après-midi : répartition des serveurs à patcher entre les intervenants",
"Chaque intervenant reçoit sa liste pour la semaine suivante",
"Objectif cible par intervenant : 20 serveurs patchés sur la semaine",
"La phase de qualification démarre immédiatement après le COMEP (jeudi PM) puis toute la journée de vendredi",
], size=16)
add_callout(slide, Inches(0.7), Inches(5.0), Inches(12), Inches(1.5),
"Livrable attendu",
"Liste de 20 serveurs par intervenant, avec domaine, environnement, "
"OS, responsable applicatif et criticité — prête pour la qualification.")
add_footer(slide, 4)
# ============================================================
# SLIDE 5 — Préparation (pré-patching)
# ============================================================
slide = prs.slides.add_slide(blank_layout)
add_title_bar(slide, "Phase 2 — Préparation (pré-patching)",
"Jeudi après-midi + Vendredi — 13 étapes par serveur")
header = ["Étape", "Action", "Durée"]
rows = [
("1", "Recherche serveur dans console Qualys", "1 min"),
("2-3", "Lecture + analyse des vulnérabilités", "5 min"),
("4", "Connexion SSH (PuTTY)", "1 min"),
("5", "Vérification espace disque (df -h)", "1 min"),
("6", "Vérification connexion satellite", "1 min"),
("7", "Dry-run yum check-update", "2-3 min"),
("8", "Vérification accès vCenter", "2 min"),
("9", "Vérification backup Commvault (si physique)", "2 min"),
("10", "Vérification Centreon (si production)", "2 min"),
("11-12", "Contact responsable + proposition créneaux", "5-10 min"),
("13", "Attente validation du créneau (asynchrone)", "variable"),
]
add_table(slide, Inches(0.5), Inches(1.8), Inches(7.5), Inches(4.6),
header, rows, font_size=11, header_size=12)
# KPI right
add_kpi(slide, Inches(8.3), Inches(2.0), Inches(4.5), Inches(1.6),
"20-30 min", "Par serveur (travail actif)", color=ACCENT)
add_kpi(slide, Inches(8.3), Inches(3.8), Inches(4.5), Inches(1.6),
"6h40 à 10h", "Sur 20 serveurs / semaine", color=ORANGE)
add_callout(slide, Inches(0.5), Inches(6.55), Inches(12.3), Inches(0.45),
"Hors attente validation",
"La validation asynchrone peut prendre 1 h à 48 h — non comptabilisée ici.")
add_footer(slide, 5)
# ============================================================
# SLIDE 6 — Exécution Jour J
# ============================================================
slide = prs.slides.add_slide(blank_layout)
add_title_bar(slide, "Phase 3 — Exécution (Jour J)",
"Lundi → Jeudi — 13 étapes par serveur")
header = ["Étape", "Action", "Durée"]
rows = [
("1", "Information début d'intervention (Teams)", "1 min"),
("2", "Connexion vCenter + prise de snapshot", "3-5 min"),
("3", "Mise en maintenance Centreon (si prod)", "2 min"),
("4-5", "SSH + snap pré-patch (services/process/ports)", "6 min"),
("6", "Lancement de la mise à jour (yum update)", "5-15 min"),
("7-8", "Info Teams + reboot du serveur", "3-6 min"),
("9", "Attente + reconnexion", "3-5 min"),
("10", "Check post-patch + Centreon", "5-10 min"),
("11", "Demande validation responsable applicatif", "5-15 min"),
("12-13", "Marquage serveur patché + Teams fin", "3 min"),
]
add_table(slide, Inches(0.5), Inches(1.8), Inches(7.5), Inches(4.3),
header, rows, font_size=11, header_size=12)
add_kpi(slide, Inches(8.3), Inches(2.0), Inches(4.5), Inches(1.6),
"35-65 min", "Par serveur (travail actif)", color=RED_ALERT)
add_kpi(slide, Inches(8.3), Inches(3.8), Inches(4.5), Inches(1.6),
"11h40 à 21h40", "Sur 20 serveurs / semaine", color=ORANGE)
add_callout(slide, Inches(0.5), Inches(6.25), Inches(12.3), Inches(0.75),
"Pourquoi pas le vendredi ?",
"Le Jour J est strictement limité à lundi → jeudi : au moins 24 h de recul "
"avant le weekend pour détecter une régression post-patch.")
add_footer(slide, 6)
# ============================================================
# SLIDE 7 — Nettoyage Vendredi
# ============================================================
slide = prs.slides.add_slide(blank_layout)
add_title_bar(slide, "Phase 4 — Nettoyage", "Vendredi — traitement groupé de la semaine")
add_bullets(slide, Inches(0.7), Inches(1.9), Inches(12), Inches(2.5), [
"Suppression des anciens snapshots vCenter de tous les serveurs patchés la semaine (~ 2 min/serveur)",
"Suppression des anciens kernels (package-cleanup --oldkernels) (~ 3 min/serveur)",
"Au total : ~ 1h40 par semaine pour le lot de 20 serveurs",
], size=16)
add_callout(slide, Inches(0.7), Inches(4.5), Inches(12), Inches(2.3),
"Pourquoi regrouper le nettoyage le vendredi ?",
"• Recul suffisant — le snapshot reste disponible au moins 24 h pour permettre un rollback "
"si une anomalie est détectée après coup.\n\n"
"• Contexte mental unique — le patcheur traite tous ses snapshots d'un bloc au lieu "
"d'y revenir serveur par serveur, gain de concentration.")
add_footer(slide, 7)
# ============================================================
# SLIDE 8 — Synthèse temps par serveur
# ============================================================
slide = prs.slides.add_slide(blank_layout)
add_title_bar(slide, "Synthèse — Temps par serveur (manuel)",
"Cumul des 3 phases actives + nettoyage")
header = ["Phase", "Temps actif", "Attente asynchrone"]
rows = [
("Préparation (qualif + validation)", "20-30 min", "1 à 48 h (async)"),
("Exécution Jour J", "35-65 min", ""),
("Nettoyage J+3", "5 min", ""),
(("TOTAL PAR SERVEUR", ACCENT_DARK), ("60-100 min", ACCENT_DARK),
("≈ 1h15 moyenne", ACCENT_DARK)),
]
add_table(slide, Inches(0.7), Inches(2.0), Inches(11.9), Inches(2.6),
header, rows, font_size=14, header_size=14)
# Big KPI row
add_kpi(slide, Inches(0.7), Inches(5.0), Inches(3.9), Inches(1.8),
"1h15", "Par serveur (moyenne)", color=ACCENT)
add_kpi(slide, Inches(4.7), Inches(5.0), Inches(3.9), Inches(1.8),
"20", "Serveurs / semaine / patcheur", color=ORANGE)
add_kpi(slide, Inches(8.7), Inches(5.0), Inches(3.9), Inches(1.8),
"25h", "Charge hebdo patching seul", color=RED_ALERT)
add_footer(slide, 8)
# ============================================================
# SLIDE 9 — Réalité du patcheur
# ============================================================
slide = prs.slides.add_slide(blank_layout)
add_title_bar(slide, "La réalité du patcheur", "Budget horaire hebdomadaire : 35 h")
# Budget visual
add_text(slide, Inches(0.7), Inches(1.8), Inches(12), Inches(0.4),
"Répartition de la semaine (Lundi → Vendredi, 7h / jour)",
size=14, bold=True, color=DARK)
# Stack bar
bar_y = Inches(2.3)
bar_h = Inches(0.9)
bar_x = Inches(0.7)
bar_w_total = Inches(11.9)
# Lun-Jeu
w1 = Inches(11.9 * 28 / 35)
add_rect(slide, bar_x, bar_y, w1, bar_h, fill=ACCENT)
add_text(slide, bar_x, bar_y, w1, bar_h, "Lundi → Jeudi — 28 h (Jour J + fin de prépa)",
size=12, bold=True, color=WHITE, align=PP_ALIGN.CENTER, anchor=MSO_ANCHOR.MIDDLE)
# Jeu PM
w2 = Inches(11.9 * 3.5 / 35)
add_rect(slide, bar_x + w1 - Inches(11.9 * 3.5 / 35), bar_y, w2, bar_h, fill=ORANGE)
add_text(slide, bar_x + w1 - Inches(11.9 * 3.5 / 35), bar_y, w2, bar_h, "Jeu PM 3.5h",
size=10, bold=True, color=WHITE, align=PP_ALIGN.CENTER, anchor=MSO_ANCHOR.MIDDLE)
# Vendredi
w3 = Inches(11.9 * 7 / 35)
add_rect(slide, bar_x + w1, bar_y, w3, bar_h, fill=GREEN)
add_text(slide, bar_x + w1, bar_y, w3, bar_h, "Vendredi — 7 h (prépa + cleanup)",
size=12, bold=True, color=WHITE, align=PP_ALIGN.CENTER, anchor=MSO_ANCHOR.MIDDLE)
# Charge théorique table
add_text(slide, Inches(0.7), Inches(3.5), Inches(12), Inches(0.4),
"Charge théorique patching — 20 serveurs",
size=14, bold=True, color=DARK)
header = ["Poste", "Volume", "Temps unitaire", "Total hebdo"]
rows = [
("Préparation (Jeu PM + Vendredi)", "20", "20-30 min", "6h40 10h"),
("Exécution Jour J (Lun-Jeu)", "20", "35-65 min", "11h40 21h40"),
("Nettoyage snapshots (Vendredi)", "20", "5 min", "1h40"),
(("TOTAL patching seul", ACCENT_DARK), "", "", ("20h 33h", ACCENT_DARK)),
(("Budget hebdo disponible", GREEN), "", "", ("35h (Lun-Ven)", GREEN)),
]
add_table(slide, Inches(0.7), Inches(3.95), Inches(11.9), Inches(2.8),
header, rows, font_size=12, header_size=13)
add_text(slide, Inches(0.7), Inches(6.85), Inches(12), Inches(0.3),
"Le patching SEUL consomme 57 à 95% du temps disponible.",
size=13, bold=True, color=RED_ALERT, align=PP_ALIGN.CENTER)
add_footer(slide, 9)
# ============================================================
# SLIDE 10 — Missions annexes
# ============================================================
slide = prs.slides.add_slide(blank_layout)
add_title_bar(slide, "Mais le patcheur n'est pas que patcheur",
"Autres missions SECOPS obligatoires")
header = ["Mission", "Fréquence", "Charge hebdo"]
rows = [
("Tickets iTop — sécurisation serveur", "Permanent", "2 à 5 h"),
("Mise à jour agent Qualys (ponctuelle)", "Hebdomadaire", "1 à 3 h"),
("Mise à jour agent SentinelOne", "Hebdomadaire", "1 à 3 h"),
("Tour de garde — alertes SentinelOne / Defender", "Rotation", "3 à 6 h (sur garde)"),
("Météo sécurité — veille + reporting hebdo", "Rotation", "2 à 4 h (sur météo)"),
(("TOTAL missions annexes", ACCENT_DARK), "", ("9 à 21 h / semaine", ACCENT_DARK)),
]
add_table(slide, Inches(0.7), Inches(2.0), Inches(11.9), Inches(3.3),
header, rows, font_size=13, header_size=14)
add_callout(slide, Inches(0.7), Inches(5.6), Inches(11.9), Inches(1.2),
"Équation insoluble",
"Budget disponible : 35 h | Patching : 20-33 h | Missions annexes : 9-21 h "
"➜ Charge totale : 29 à 54 h / semaine.\n"
"Surcharge structurelle dès que patching + garde + iTop se cumulent.",
bg=RGBColor(0xFD, 0xEB, 0xEB), border=RED_ALERT,
title_color=RED_ALERT)
add_footer(slide, 10)
# ============================================================
# SLIDE 11 — Conséquence sécurité
# ============================================================
slide = prs.slides.add_slide(blank_layout)
add_title_bar(slide, "Conséquence sécurité directe",
"Le patcheur n'a plus le temps d'analyser les alertes S1 & Defender")
add_rect(slide, Inches(0.7), Inches(1.9), Inches(12), Inches(0.1), fill=RED_ALERT)
add_text(slide, Inches(0.7), Inches(2.1), Inches(12), Inches(0.5),
"⚠ Impact opérationnel sur le tour de garde EDR",
size=18, bold=True, color=RED_ALERT)
add_bullets(slide, Inches(0.7), Inches(2.7), Inches(12), Inches(3), [
(RED_ALERT, "Alertes SentinelOne / Defender triées en surface — on coche la criticité sans investiguer"),
(RED_ALERT, "Faux positifs non requalifiés — le bruit de fond masque les vrais incidents"),
(RED_ALERT, "Vrais incidents détectés tardivement — ransomware, exfiltration, lateral movement"),
(RED_ALERT, "Pas d'enrichissement de contexte (corrélation process / réseau / utilisateur)"),
(RED_ALERT, "Les règles de détection ne sont pas affinées en retour d'expérience"),
], size=14)
add_callout(slide, Inches(0.7), Inches(5.7), Inches(12), Inches(1.4),
"Coût potentiel",
"Une alerte EDR ratée = incident de sécurité majeur, fuite de données, "
"indisponibilité.\n"
"Sans commune mesure avec le coût de l'automatisation du patching.",
bg=RGBColor(0xFD, 0xEB, 0xEB), border=RED_ALERT, title_color=RED_ALERT)
add_footer(slide, 11)
# ============================================================
# SLIDE 12 — SQATM .exe
# ============================================================
slide = prs.slides.add_slide(blank_layout)
add_title_bar(slide, "Apport SQATM .exe", "Déjà en production — équipe SECOPS")
add_text(slide, Inches(0.7), Inches(1.9), Inches(12), Inches(0.5),
"L'outil SANEF Qualys API Tags Management automatise la phase QUALIFICATION :",
size=14, color=DARK)
header = ["Étape manuelle", "Automatisé par SQATM", "Gain"]
rows = [
("Recherche Qualys + lecture vulnérabilités", "Décodeur + API Qualys", "5 min → 30 s"),
("Check SSH disque / satellite / dry-run", "Audit global multi-serveurs", "5 min → 10 s"),
("Tagging ENV / OS / POS / EQT", "Tag Rules + décodeur nomenclature", "15 min → instantané"),
("Identification exposition internet", "Plan de patching + règles", "3 min → instantané"),
]
add_table(slide, Inches(0.7), Inches(2.5), Inches(11.9), Inches(2.5),
header, rows, font_size=12, header_size=13)
add_kpi(slide, Inches(3.2), Inches(5.3), Inches(7), Inches(1.5),
"60-70%", "Gain sur la phase préparation", color=ACCENT)
add_footer(slide, 12)
# ============================================================
# SLIDE 13 — PatchCenter Web capabilities
# ============================================================
slide = prs.slides.add_slide(blank_layout)
add_title_bar(slide, "Apport PatchCenter Web",
"Orchestration end-to-end — en cours de déploiement")
# 3 colonnes
col_w = Inches(4.2)
col_y = Inches(2.0)
col_h = Inches(4.5)
titles = ["Préparation", "Jour J", "Gouvernance"]
colors = [ACCENT, ORANGE, GREEN]
contents = [
[
"Vue unifiée serveurs (Qualys + OS + resp.)",
"Audit SSH parallélisé 100+ serveurs",
"Correspondance prod ↔ hors-prod auto",
"Workflow de validation créneau tracé",
"Tags Qualys dynamiques auto-appliqués",
],
[
"Snapshot vCenter via API",
"Maintenance Centreon via API",
"Notifications Teams automatiques",
"Snap pré/post archivé (services/ports)",
"yum update en job asynchrone",
"Reboot + reconnexion orchestrés",
"Détection régression post-reboot",
],
[
"Validation prod ↔ hors-prod bloquante",
"Historique complet par serveur",
"Audit log exportable (conformité)",
"Tableau de suivi temps réel",
"Nettoyage snapshots + kernels auto",
"Multi-profils (admin/coord/op/viewer)",
"Authentification LDAP AD SANEF",
],
]
for i, (t, col, items) in enumerate(zip(titles, colors, contents)):
x = Inches(0.5 + i * 4.3)
add_rect(slide, x, col_y, col_w, Inches(0.6), fill=col)
add_text(slide, x, col_y + Inches(0.1), col_w, Inches(0.45), t,
size=18, bold=True, color=WHITE, align=PP_ALIGN.CENTER)
add_rect(slide, x, col_y + Inches(0.6), col_w, col_h - Inches(0.6),
fill=WHITE, line=LIGHT_GRAY)
tb = slide.shapes.add_textbox(x + Inches(0.15), col_y + Inches(0.75),
col_w - Inches(0.3), col_h - Inches(0.85))
tf = tb.text_frame
tf.word_wrap = True
for j, it in enumerate(items):
p = tf.paragraphs[0] if j == 0 else tf.add_paragraph()
p.space_after = Pt(6)
r1 = p.add_run(); r1.text = ""
r1.font.size = Pt(12); r1.font.bold = True; r1.font.color.rgb = col
r2 = p.add_run(); r2.text = it
r2.font.size = Pt(12); r2.font.color.rgb = DARK
r2.font.name = "Calibri"
add_footer(slide, 13)
# ============================================================
# SLIDE 14 — Comparaison chiffrée par serveur
# ============================================================
slide = prs.slides.add_slide(blank_layout)
add_title_bar(slide, "Comparaison chiffrée", "Temps par serveur — manuel vs automatisé")
header = ["Étape", "Manuel", "SQATM .exe", "PatchCenter Web"]
rows = [
("Qualification (prep)", "20-30 min", "6-10 min", ("2-3 min", GREEN)),
("Snapshot + maintenance", "5-7 min", "5-7 min", ("30 s (auto)", GREEN)),
("Snap pré/post-patch", "10-15 min", "10-15 min", ("1 min (auto)", GREEN)),
("Update + reboot + reconnexion", "10-25 min", "10-25 min", "10-25 min"),
("Notifications + suivi", "5 min", "5 min", ("0 (auto)", GREEN)),
("Nettoyage J+3", "5 min", "5 min", ("1 min (auto)", GREEN)),
(("TOTAL / SERVEUR", ACCENT_DARK),
("60-100 min", RED_ALERT),
("40-70 min", ORANGE),
("15-30 min", GREEN)),
(("GAIN vs manuel", ACCENT_DARK), "", ("-30%", ORANGE), ("-70 à -75%", GREEN)),
]
add_table(slide, Inches(0.5), Inches(1.9), Inches(12.3), Inches(4.8),
header, rows, font_size=12, header_size=13)
add_text(slide, Inches(0.5), Inches(6.85), Inches(12.3), Inches(0.3),
"Le gain vient surtout de la suppression des gestes répétitifs (snapshot, Centreon, notifs, suivi).",
size=12, bold=True, color=ACCENT_DARK, align=PP_ALIGN.CENTER)
add_footer(slide, 14)
# ============================================================
# SLIDE 15 — Impact hebdo (20 serveurs)
# ============================================================
slide = prs.slides.add_slide(blank_layout)
add_title_bar(slide, "Impact hebdomadaire", "Objectif 20 serveurs/semaine — budget 35 h")
header = ["Scénario", "Patching seul", "Budget restant", "Missions annexes ?"]
rows = [
(("Manuel", RED_ALERT), ("20h à 33h", RED_ALERT), ("+2h à +15h", ORANGE),
("Non — surcharge garantie en garde ou météo", RED_ALERT)),
(("SQATM .exe", ORANGE), ("14h à 23h", ORANGE), ("+12h à +21h", ORANGE),
("Partiellement", ORANGE)),
(("PatchCenter Web", GREEN), ("5h à 10h", GREEN), ("+25h à +30h", GREEN),
("Oui — marge pour tout absorber", GREEN)),
]
add_table(slide, Inches(0.5), Inches(2.0), Inches(12.3), Inches(2.7),
header, rows, font_size=13, header_size=14)
# KPI summary
add_kpi(slide, Inches(0.7), Inches(5.0), Inches(3.9), Inches(1.8),
"25-30h", "Libérées/semaine avec PC Web", color=GREEN)
add_kpi(slide, Inches(4.7), Inches(5.0), Inches(3.9), Inches(1.8),
"x3 à x4", "Réduction du temps / serveur", color=ACCENT)
add_kpi(slide, Inches(8.7), Inches(5.0), Inches(3.9), Inches(1.8),
"~0.5 ETP", "Équivalent sur 200 serv/mois", color=ACCENT_DARK)
add_footer(slide, 15)
# ============================================================
# SLIDE 16 — Bénéfices qualitatifs
# ============================================================
slide = prs.slides.add_slide(blank_layout)
add_title_bar(slide, "Bénéfices qualitatifs",
"Au-delà du temps gagné — valeur stratégique")
benefits = [
("Fiabilité", "Plus d'oubli de snapshot, de maintenance Centreon ou de marquage statut", ACCENT),
("Traçabilité", "Historique complet par serveur, exportable pour audit", ACCENT),
("Gouvernance", "Workflow de validation prod ↔ hors-prod bloquant", ORANGE),
("Scalabilité", "1 intervenant pilote 20+ serveurs en parallèle", ORANGE),
("Onboarding", "Nouveau patcheur opérationnel en quelques jours", GREEN),
("Qualité", "Snap pré/post standardisé → détection fiable des régressions", GREEN),
("Conformité", "Tags Qualys automatiques → reporting KPI sans retraitement", ACCENT_DARK),
("Communication", "Notifications Teams standardisées → meilleure visibilité projet", ACCENT_DARK),
]
# Grid 2x4
cw = Inches(6.0); ch = Inches(1.1); gap = Inches(0.15)
for i, (t, b, col) in enumerate(benefits):
row = i // 2
colpos = i % 2
x = Inches(0.5) + (cw + gap) * colpos
y = Inches(1.9) + (ch + gap) * row
add_rect(slide, x, y, Emu(80000), ch, fill=col)
add_rect(slide, x + Emu(80000), y, cw - Emu(80000), ch, fill=WHITE, line=LIGHT_GRAY)
add_text(slide, x + Inches(0.25), y + Inches(0.1), cw - Inches(0.35), Inches(0.4),
t, size=14, bold=True, color=col)
add_text(slide, x + Inches(0.25), y + Inches(0.45), cw - Inches(0.35), Inches(0.65),
b, size=11, color=DARK)
add_footer(slide, 16)
# ============================================================
# SLIDE 17 — Feuille de route
# ============================================================
slide = prs.slides.add_slide(blank_layout)
add_title_bar(slide, "Feuille de route", "Avancement actuel & prochaines étapes")
# Two columns: done + to do
add_rect(slide, Inches(0.5), Inches(1.9), Inches(6.1), Inches(5), fill=LIGHT_BG, line=LIGHT_GRAY)
add_rect(slide, Inches(0.5), Inches(1.9), Inches(6.1), Inches(0.5), fill=GREEN)
add_text(slide, Inches(0.5), Inches(1.95), Inches(6.1), Inches(0.4),
"✓ Déjà livré", size=16, bold=True, color=WHITE, align=PP_ALIGN.CENTER)
done_items = [
"SQATM .exe v2.0.0 — en production (tagging + audit)",
"PatchCenter Web : catalogue serveurs complet (1165)",
"PatchCenter Web : correspondance prod ↔ hors-prod",
"PatchCenter Web : exclusions patch par serveur",
"PatchCenter Web : workflow validations",
"PatchCenter Web : agents Qualys + déploiement",
"Intégration iTop bidirectionnelle (serveurs/apps/statuts)",
"Authentification LDAP AD SANEF multi-profils",
]
tb = slide.shapes.add_textbox(Inches(0.7), Inches(2.55), Inches(5.8), Inches(4.2))
tf = tb.text_frame; tf.word_wrap = True
for j, it in enumerate(done_items):
p = tf.paragraphs[0] if j == 0 else tf.add_paragraph()
p.space_after = Pt(8)
r1 = p.add_run(); r1.text = ""
r1.font.size = Pt(12); r1.font.bold = True; r1.font.color.rgb = GREEN
r2 = p.add_run(); r2.text = it
r2.font.size = Pt(12); r2.font.color.rgb = DARK
# À venir
add_rect(slide, Inches(6.8), Inches(1.9), Inches(6.1), Inches(5), fill=RGBColor(0xFF, 0xFA, 0xEE), line=LIGHT_GRAY)
add_rect(slide, Inches(6.8), Inches(1.9), Inches(6.1), Inches(0.5), fill=ORANGE)
add_text(slide, Inches(6.8), Inches(1.95), Inches(6.1), Inches(0.4),
"▶ À venir", size=16, bold=True, color=WHITE, align=PP_ALIGN.CENTER)
todo_items = [
"Orchestration vCenter (snapshot automatique)",
"Orchestration Centreon (maintenance automatique)",
"Intégration Teams native (notifications)",
"Exécution patching end-to-end en un clic",
"Module reporting KPI & tableau de bord DSI",
"Extension aux serveurs Windows (WSUS/MECM)",
]
tb = slide.shapes.add_textbox(Inches(7.0), Inches(2.55), Inches(5.8), Inches(4.2))
tf = tb.text_frame; tf.word_wrap = True
for j, it in enumerate(todo_items):
p = tf.paragraphs[0] if j == 0 else tf.add_paragraph()
p.space_after = Pt(8)
r1 = p.add_run(); r1.text = ""
r1.font.size = Pt(12); r1.font.bold = True; r1.font.color.rgb = ORANGE
r2 = p.add_run(); r2.text = it
r2.font.size = Pt(12); r2.font.color.rgb = DARK
add_footer(slide, 17)
# ============================================================
# SLIDE 18 — Conclusion
# ============================================================
slide = prs.slides.add_slide(blank_layout)
add_title_bar(slide, "Conclusion", "Un investissement à double retour : opérationnel & sécurité")
add_text(slide, Inches(0.7), Inches(1.9), Inches(12), Inches(0.5),
"Trois constats à retenir",
size=18, bold=True, color=ACCENT)
bullets_data = [
("1.", ACCENT, "Le patching manuel est un coût caché — 60 à 100 min/serveur, 20-33 h/semaine/patcheur"),
("2.", ORANGE, "Le patcheur est structurellement en surcharge dès qu'il cumule patching + garde + iTop"),
("3.", RED_ALERT, "L'automatisation libère 25-30 h/semaine — du temps pour analyser correctement les alertes EDR"),
]
for i, (num, col, txt) in enumerate(bullets_data):
y = Inches(2.7 + 0.9 * i)
add_rect(slide, Inches(0.7), y, Inches(0.9), Inches(0.7), fill=col)
add_text(slide, Inches(0.7), y + Inches(0.13), Inches(0.9), Inches(0.5),
num, size=24, bold=True, color=WHITE, align=PP_ALIGN.CENTER)
add_text(slide, Inches(1.8), y + Inches(0.15), Inches(11), Inches(0.5),
txt, size=14, color=DARK)
add_callout(slide, Inches(0.7), Inches(5.8), Inches(12), Inches(1.1),
"Message clé DSI",
"PatchCenter Web n'est pas qu'un gain de productivité — c'est un levier de sécurité stratégique. "
"Le temps libéré redonne au patcheur sa capacité d'analyste EDR. Automatiser, "
"c'est renforcer la détection et la réponse aux incidents de SANEF.",
bg=RGBColor(0xE6, 0xF6, 0xFB), border=ACCENT, title_color=ACCENT_DARK)
add_footer(slide, 18)
# ============================================================
# SLIDE 19 — Merci / Q&A
# ============================================================
slide = prs.slides.add_slide(blank_layout)
set_bg(slide, DARK)
add_rect(slide, 0, Inches(3.3), SW, Inches(0.08), fill=ACCENT)
add_text(slide, 0, Inches(2.2), SW, Inches(1.2),
"Merci", size=96, bold=True, color=WHITE, align=PP_ALIGN.CENTER)
add_text(slide, 0, Inches(3.5), SW, Inches(0.6),
"Questions & Discussion", size=26, color=ACCENT, align=PP_ALIGN.CENTER)
add_text(slide, 0, Inches(4.3), SW, Inches(0.5),
"SANEF DSI — Équipe SECOPS", size=16, color=LIGHT_GRAY, align=PP_ALIGN.CENTER)
add_text(slide, 0, Inches(4.8), SW, Inches(0.4),
"pc.mpcz.fr — PatchCenter Web", size=14, color=GRAY, align=PP_ALIGN.CENTER)
# Save
out = r"C:\Users\netadmin\Desktop\SANEF_Processus_Patching.pptx"
prs.save(out)
print(f"Generated: {out}")

View File

@ -0,0 +1,335 @@
====== Processus de Patching SANEF — Manuel vs Automatisé ======
<WRAP round info>
**Référence :** Processus SECOPS — Équipe Patching SANEF\\
**Objectif :** Documenter le workflow de patching actuel (manuel) et quantifier le gain apporté par les outils d'automatisation (SQATM .exe + PatchCenter Web).
</WRAP>
----
===== 1. Vue d'ensemble du processus =====
Le patching d'un serveur se déroule en **deux phases principales** étalées sur plusieurs jours :
^ Phase ^ Moment ^ Objectif ^ Livrable ^
| **1. Affectation** | Jeudi COMEP 14h00 | Répartition des serveurs | Liste par intervenant |
| **2. Préparation (pré-patching)** | Jeudi après-midi + Vendredi | Éligibilité + validation créneau | Créneau validé avec responsable |
| **3. Exécution (Jour J)** | Lundi → Jeudi (selon créneau) | Patching + vérifications | Serveur patché et validé |
| **4. Nettoyage** | Vendredi (J+3 typique) | Suppression snapshots + kernels | Serveurs propres |
<WRAP round info>
**Règle de cadrage :** pas de patching le vendredi pour éviter un incident qui courrait tout le weekend.\\
Le vendredi est consacré aux **opérations hors patching** : finalisation pré-patching de la semaine suivante, suppression des anciens snapshots (des serveurs patchés en début de semaine), ménage kernels, iTop, météo.
</WRAP>
----
===== 2. Phase 1 — Préparation (Jeudi après-midi + Vendredi) =====
==== 2.1 Affectation des serveurs ====
* **Jeudi 14h00 — COMEP** : répartition des serveurs à patcher entre les intervenants SECOPS
* Chaque intervenant reçoit sa liste pour la semaine suivante
* La phase de qualification démarre **immédiatement après le COMEP** (jeudi après-midi) et se poursuit **toute la journée de vendredi**
==== 2.2 Qualification de chaque serveur ====
Pour **chaque serveur** de la liste, l'intervenant effectue la séquence suivante (répartie sur jeudi après-midi et vendredi) :
^ # ^ Étape ^ Outil ^ Durée estimée ^
| 1 | Copier/coller nom serveur dans console Qualys | Qualys VMDR | 1 min |
| 2 | Cliquer sur le serveur → onglet Vulnérabilités | Qualys VMDR | 2 min |
| 3 | Visualiser et analyser les vulnérabilités | Qualys VMDR | 3-5 min |
| 4 | Lancer PuTTY, se connecter en SSH (clé) | PuTTY + clé | 1 min |
| 5 | Vérifier espace disque (''df -h'') | SSH | 1 min |
| 6 | Vérifier connexion satellite (''subscription-manager status'') | SSH | 1 min |
| 7 | Dry run check update (''yum check-update'') | SSH | 2-3 min |
| 8 | Vérifier accès vCenter (pour snapshot) | vSphere Web | 2 min |
| 9 | Vérifier backup Commvault (si physique) | Commvault | 2 min |
| 10 | Vérification Centreon (si production) | Centreon | 2 min |
| 11 | Si OK → marquer serveur éligible | - | 1 min |
| 12 | Contacter responsable + proposer créneaux (mail/Teams) | Mail / Teams | 5-10 min |
| 13 | Attendre validation du créneau (asynchrone) | - | variable |
<WRAP round important>
**Durée cumulée par serveur : 20 à 30 minutes** de travail actif (hors attente validation asynchrone).\\
Pour un intervenant avec 20 serveurs à qualifier : **6h40 à 10h** par semaine sur la seule phase préparation.
</WRAP>
----
===== 3. Phase 2 — Exécution (Jour J — Lundi à Jeudi) =====
<WRAP round info>
Le **Jour J** d'un serveur tombe nécessairement entre **lundi et jeudi** (jamais le vendredi).\\
Cela laisse au moins 24h de recul avant le weekend pour détecter une régression post-patch.
</WRAP>
==== 3.1 Séquence par serveur ====
^ # ^ Étape ^ Outil ^ Durée estimée ^
| 1 | Information début d'intervention (Teams) | Teams | 1 min |
| 2 | Connexion vCenter + prise de snapshot | vSphere Web | 3-5 min |
| 3 | Mise en maintenance Centreon (si prod) | Centreon | 2 min |
| 4 | Connexion SSH sur le serveur | PuTTY | 1 min |
| 5 | Snap pré-patch : services running + process + ports (''systemctl'', ''ss'') | SSH | 5 min |
| 6 | Lancer la mise à jour (''yum update -y'') | SSH | 5-15 min |
| 7 | Informer du reboot imminent (Teams) | Teams | 1 min |
| 8 | Reboot du serveur | SSH | 2-5 min |
| 9 | Attente + reconnexion | PuTTY | 3-5 min |
| 10 | Check post-patch : services + process + ports + Centreon | SSH + Centreon | 5-10 min |
| 11 | Demander validation au responsable applicatif | Mail / Teams | 5-15 min (attente) |
| 12 | Marquer le serveur comme patché (tableau de suivi) | Excel / outil | 2 min |
| 13 | Message fin d'intervention (Teams) | Teams | 1 min |
==== 3.2 Phase de nettoyage (le vendredi) ====
Le **vendredi** est consacré au nettoyage groupé de tous les serveurs patchés pendant la semaine (J+3 typique pour ceux patchés lundi/mardi) :
^ # ^ Étape ^ Outil ^ Durée estimée ^
| 14 | Suppression des **anciens snapshots vCenter** de tous les serveurs patchés la semaine | vSphere Web | 2 min/serveur |
| 15 | Suppression ancien kernel (''package-cleanup --oldkernels'') | SSH | 3 min/serveur |
<WRAP round tip>
Grouper le nettoyage le vendredi a deux avantages :
* **Recul suffisant** : on garde le snapshot au moins jusqu'au lendemain pour pouvoir rollback en cas d'anomalie détectée après coup
* **Contexte mental unique** : le patcheur traite tous ses snapshots d'un bloc au lieu d'y revenir serveur par serveur
</WRAP>
<WRAP round important>
**Durée cumulée par serveur (Jour J + J+3) : 40 à 70 minutes** de travail actif.\\
Pour 20 serveurs patchés dans la semaine : **13h à 23h** de charge intervenant sur l'exécution seule.
</WRAP>
----
===== 4. Synthèse — Temps total par serveur (processus manuel) =====
^ Phase ^ Temps actif ^ Temps d'attente ^ Total ^
| Préparation (qualification + validation) | 20-30 min | 1-48h (async validation) | 20-30 min actif |
| Exécution Jour J | 35-65 min | - | 35-65 min |
| Nettoyage J+3 | 5 min | - | 5 min |
| **Total par serveur** | **60-100 min** | | **~1h15 en moyenne** |
----
===== 4.1 Réalité du temps patcheur =====
<WRAP round important>
**Contraintes horaires du patcheur SECOPS :**
* Semaine de travail **Lundi → Vendredi**, **7 heures par jour** → **35 heures/semaine** au total
* **Pas de patching le vendredi** (pour éviter un weekend sous incident)
* **Lundi → Jeudi matin (28h)** : fenêtre d'exécution Jour J + fin de pré-patching entamé la semaine précédente
* **Jeudi après-midi (3,5h)** : COMEP + démarrage pré-patching de la semaine suivante
* **Vendredi (7h)** : finalisation pré-patching + suppression des snapshots des serveurs patchés la semaine + iTop / météo
* **Objectif par patcheur : 20 serveurs patchés par semaine**
</WRAP>
=== Calcul de la charge théorique ===
^ Poste ^ Volume ^ Temps unitaire ^ Total hebdo ^
| Préparation 20 serveurs (jeudi PM + vendredi) | 20 | 20-30 min | **6h40 à 10h** |
| Exécution Jour J 20 serveurs (Lun-Jeu) | 20 | 35-65 min | **11h40 à 21h40** |
| Nettoyage snapshots/kernels (vendredi) | 20 | 5 min | **1h40** |
| **Total patching seul** | - | - | **20h à 33h** |
| **Budget hebdo total** | - | - | **35h** (dont 28h Lun-Jeu et 7h vendredi) |
<WRAP round important>
**Répartition par fenêtre :**
^ Fenêtre ^ Capacité ^ Charge patching ^ Marge ^
| Lun → Jeu midi (28h) | Jour J des 20 serveurs | 12 à 22h | 6 à 16h |
| Jeu après-midi (3,5h) | COMEP + début pré-patching | 1,5 à 3h | 0,5 à 2h |
| Vendredi (7h) | Pré-patching + nettoyage + missions annexes | 4 à 7h (pré-patch + cleanup) | 0 à 3h pour iTop/météo |
Autrement dit, **le patching seul consomme 57 à 95% du temps disponible** d'un patcheur qui ne ferait que ça.
</WRAP>
Or le patcheur doit **également** assurer d'autres missions :
=== Autres missions du patcheur ===
^ Mission ^ Fréquence ^ Charge estimée ^
| **Tickets iTop sécurisation serveur** | Permanent | 2-5 h/semaine |
| **Mise à jour agent Qualys** (ponctuelle) | Hebdomadaire | 1-3 h/semaine |
| **Mise à jour agent SentinelOne** | Hebdomadaire | 1-3 h/semaine |
| **Tour de garde — alertes SentinelOne / Defender** | Rotation | 3-6 h/semaine (sur garde) |
| **Météo sécurité** (veille + reporting hebdo) | Rotation | 2-4 h/semaine (sur météo) |
| **Total missions annexes** | - | **9 à 21 h/semaine** |
<WRAP round important>
**Équation insoluble en mode manuel :**
* Temps disponible : **35h** (Lun-Ven x 7h)
* Temps patching (20 serveurs — prep + exec + cleanup) : **20-33h**
* Temps missions annexes : **9-21h**
* **Total charge : 29 à 54h/semaine**
Le patcheur est **structurellement en surcharge** dès qu'il cumule patching + garde + iTop.\\
Résultat : décalages de créneaux, vérifications raccourcies, dette sur les tickets iTop, épuisement.
</WRAP>
<WRAP round important>
**Conséquence sécurité directe — analyse des alertes SentinelOne & Defender**
Quand le patcheur est saturé par le patching manuel, il **n'a pas le temps d'analyser correctement** les alertes SentinelOne et Microsoft Defender pendant son tour de garde :
* Les alertes sont **triées en surface** (criticité apparente) au lieu d'être investiguées en profondeur
* Les **faux positifs** ne sont pas requalifiés → bruit qui cache les vrais incidents
* Les **vrais incidents** (ransomware, exfiltration, lateral movement) risquent d'être détectés **tardivement**
* L'**enrichissement de contexte** (corrélation process/réseau/user) n'est pas fait
* Les **règles de détection** ne sont pas affinées en retour d'expérience
**Coût potentiel d'une alerte ratée :** incident de sécurité majeur, fuite de données, indisponibilité — sans commune mesure avec le coût du patching manuel.
L'automatisation du patching par PatchCenter Web n'est donc pas seulement un gain de productivité : c'est une **mesure de sécurité directe** qui redonne au patcheur le temps d'exercer correctement son rôle d'analyste EDR pendant ses gardes.
</WRAP>
=== Projection annuelle ===
Sur un parc de **~1165 serveurs** avec un cycle mensuel ciblant environ **200 serveurs/mois** (prod + prépro prioritaires) :
* **200 serveurs x 1h15 = 250 heures/mois**
* Soit l'équivalent de **1,5 à 2 ETP** consacrés aux gestes répétitifs de patching
* **Risque humain** : oubli de snapshot, oubli de Centreon, check post-patch incomplet, tableau de suivi non à jour, créneau non respecté
----
===== 5. Apport des outils d'automatisation =====
==== 5.1 SQATM .exe (déjà en production) ====
L'outil SANEF Qualys API Tags Management automatise la phase **qualification** :
^ Étape manuelle ^ Automatisé par SQATM ^ Gain ^
| Recherche Qualys + lecture des vulnérabilités | Décodeur + API Qualys | 5 min → 30 sec |
| Check SSH disque / satellite / dry-run | Audit global multi-serveurs | 5 min → 10 sec |
| Tagging automatique (ENV, OS, POS, EQT) | Tag Rules + décodeur nomenclature | 15 min → instantané |
| Identification exposition internet | Plan de patching + règles | 3 min → instantané |
**Gain estimé sur la phase préparation : 60-70%** (20 min → 6-7 min par serveur).
==== 5.2 PatchCenter Web (en cours de déploiement) ====
La plateforme web centralisera l'ensemble du workflow avec orchestration complète :
=== Automatisation de la préparation ===
* **Vue unifiée** des serveurs avec statut Qualys, OS, environnement, responsable, dernière patch
* **Audit global en arrière-plan** : SSH sur 100+ serveurs en parallèle, disque + satellite + dry-run en une passe
* **Identification correspondance prod ↔ hors-prod** automatique (signature hostname)
* **Workflow de validation** intégré : proposition de créneau + accord responsable tracé en base
* **Tags Qualys dynamiques** auto-appliqués (Tag Rules basées sur la nomenclature SANEF)
=== Automatisation du Jour J ===
* **Prise de snapshot vCenter** automatisée via API
* **Mise en maintenance Centreon** automatisée via API
* **Notifications Teams** générées automatiquement (début, reboot, fin)
* **Snap pré-patch / post-patch** scripté et archivé (services + process + ports)
* **Exécution yum update** en job asynchrone avec exclusions configurables par serveur
* **Reboot + attente reconnexion** gérés automatiquement
* **Détection des régressions** (service manquant post-reboot → alerte)
* **Tableau de suivi en temps réel** (plus de saisie Excel)
=== Automatisation du nettoyage ===
* **Liste des snapshots à supprimer J+3** avec rappel automatique
* **Nettoyage kernels obsolètes** en fin de job
=== Gouvernance ===
* **Validations prod ↔ hors-prod** bloquantes : impossibilité de patcher la prod sans validation du hors-prod correspondant
* **Historique complet** par serveur : qui a patché, quand, résultat, validation responsable
* **Audit log** exportable pour conformité
==== 5.3 Comparaison chiffrée ====
^ Étape ^ Manuel ^ SQATM .exe ^ PatchCenter Web ^
| Qualification serveur (prep) | 20-30 min | 6-10 min | 2-3 min |
| Snapshot + maintenance | 5-7 min | 5-7 min | 30 sec (automatique) |
| Snap pré/post-patch | 10-15 min | 10-15 min | 1 min (automatique) |
| Update + reboot + reconnexion | 10-25 min | 10-25 min | 10-25 min (compressible en parallèle) |
| Notifications + suivi | 5 min | 5 min | 0 (automatique) |
| Nettoyage J+3 | 5 min | 5 min | 1 min (automatique) |
| **Total par serveur** | **60-100 min** | **40-70 min** | **15-30 min** |
| **Gain vs manuel** | - | **30%** | **70-75%** |
<WRAP round tip>
**Projection sur 200 serveurs/mois :**
* **Manuel** : 250 h/mois (~1,5 à 2 ETP)
* **SQATM .exe** : 170 h/mois (~1 ETP, gain = 80h/mois)
* **PatchCenter Web** : 75 h/mois (~0,5 ETP, gain = 175h/mois)
</WRAP>
----
==== 5.4 Impact hebdomadaire sur le patcheur (objectif 20 serveurs/semaine) ====
^ Scénario ^ Patching seul ^ Budget restant (sur 35h) ^ Missions annexes couvertes ? ^
| **Manuel** | 20h à 33h | +2h à +15h | Non — surcharge garantie en période de garde ou météo |
| **SQATM .exe** | 14h à 23h | +12h à +21h | Partiellement — tient si pas de garde ou pas de météo |
| **PatchCenter Web** | 5h à 10h | +25h à +30h | **Oui** — le patcheur peut assurer patching + garde + météo + iTop sans surcharge |
<WRAP round tip>
**Lecture :**\\
Avec PatchCenter Web, un patcheur qui traite ses 20 serveurs hebdomadaires libère **18 à 23 heures** par semaine pour :
* Absorber sereinement les tickets iTop de sécurisation
* Assurer le tour de garde SentinelOne / Defender sans décaler le patching
* Produire la météo sécurité hebdomadaire
* Traiter les MAJ d'agents Qualys / SentinelOne en masse
* Ou **augmenter la cadence** (25-30 serveurs/semaine) si besoin opérationnel
</WRAP>
----
===== 6. Bénéfices qualitatifs (au-delà du temps) =====
^ Axe ^ Bénéfice PatchCenter Web ^
| **Fiabilité** | Plus d'oubli de snapshot, de maintenance Centreon ou de marquage de statut |
| **Traçabilité** | Historique complet par serveur, exportable pour audit |
| **Gouvernance** | Workflow de validation prod ↔ hors-prod bloquant |
| **Scalabilité** | 1 intervenant peut piloter 20+ serveurs en parallèle |
| **Onboarding** | Nouveau patcheur opérationnel en quelques jours (processus guidé) |
| **Qualité** | Snap pré/post standardisé → détection fiable des régressions |
| **Conformité** | Tags Qualys automatiques → reporting KPI sans retraitement |
| **Communication** | Notifications Teams standardisées → meilleure visibilité projet |
----
===== 7. Feuille de route =====
<WRAP round important>
**Avancement actuel :**
* **SQATM .exe v2.0.0** : en production, utilisé pour le tagging et l'audit
* **PatchCenter Web** : développement actif
* Modules livrés : catalogue serveurs, correspondance prod/hors-prod, exclusions patchs, workflow validations, agents Qualys, déploiement agents
* Modules à finaliser : orchestration vCenter, orchestration Centreon, intégration Teams native, exécution patching end-to-end
* **Intégration iTop bidirectionnelle** : opérationnelle (synchronisation serveurs / applications / statuts)
* **Authentification LDAP AD SANEF** : prête (multi-profils : admin, coordinateur, opérateur, viewer)
</WRAP>
----
===== 8. Conclusion =====
Le processus de patching manuel actuel est **efficace mais coûteux** : 60 à 100 minutes par serveur, avec un risque humain non négligeable sur les gestes répétitifs (snapshot, maintenance, suivi). La montée en charge progressive du parc et les exigences de conformité (Qualys, audit) rendent l'automatisation **incontournable**.
Avec un objectif de **20 serveurs patchés par patcheur et par semaine**, un budget horaire de **35h (Lun-Ven, 7h/jour — mais pas de patching le vendredi, dédié au pré-patching + nettoyage snapshots + missions annexes)**, et des missions annexes obligatoires (tickets iTop, MAJ agents Qualys/SentinelOne, tour de garde, météo) qui représentent **9 à 21 heures/semaine**, le mode manuel place structurellement le patcheur en surcharge.
Les outils en place et à venir apportent un gain **mesurable** :
* **SQATM .exe** divise par 1,5 le temps de préparation
* **PatchCenter Web** divise par 3 à 4 le temps global par serveur
* **PatchCenter Web** libère **25 à 30 heures/semaine** par patcheur, de quoi absorber sereinement les missions annexes **et** augmenter la cadence si nécessaire
L'investissement dans l'automatisation ne se résume pas à un gain de temps : il garantit la **fiabilité**, la **traçabilité**, la **scalabilité** du processus, et surtout la **soutenabilité** du métier de patcheur SECOPS à SANEF.
**Enjeu sécurité stratégique :** le temps libéré par l'automatisation permet au patcheur d'exercer correctement son rôle d'**analyste EDR** sur les alertes SentinelOne et Defender pendant ses gardes. En mode manuel, ces alertes sont traitées en surface faute de temps — un ransomware, une exfiltration ou un lateral movement peut passer inaperçu. Automatiser le patching, c'est donc aussi **renforcer directement la capacité de détection et de réponse aux incidents** de SANEF.
----
//— SANEF DSI / Sécurité Opérationnelle — Processus Patching & Automatisation — {date}//

View File

@ -0,0 +1,391 @@
====== Script de présentation — Processus Patching SANEF ======
<WRAP round info>
**Format :** présentation DSI, ~18 minutes de parole + Q&A\\
**Ton :** oral, direct, factuel. Préférer des phrases courtes. Regarder l'auditoire, pas le slide.\\
**Astuce :** les phrases en //italique// sont des respirations / silences volontaires, souvent plus efficaces que les arguments.
</WRAP>
----
===== Slide 1 — Titre (30 s) =====
<code>
[entrée en scène, attendre quelques secondes]
</code>
Bonjour à tous, merci d'être là.
On va parler ce matin du **patching des serveurs SANEF** — de comment ça se passe aujourd'hui, de ce que ça coûte réellement à l'équipe SECOPS, et de ce qu'on est en train de mettre en place pour changer la donne.
L'idée, c'est de vous montrer pourquoi l'automatisation qu'on développe — **PatchCenter Web** — n'est pas juste un outil de confort : c'est un vrai levier de sécurité pour SANEF.
//[pause — transition slide]//
----
===== Slide 2 — Sommaire (30 s) =====
Voilà rapidement le plan.
On va commencer par **dérouler le processus** étape par étape — pour qu'on ait tous la même image en tête de ce que fait concrètement un patcheur dans sa semaine.
Ensuite on va regarder **la réalité du terrain** : combien d'heures ça prend, et surtout ce qui se passe quand le patcheur doit aussi faire autre chose — les tickets iTop, les tours de garde, la météo sécurité.
Et on finira par **l'apport de l'automatisation** : ce qu'on a déjà — SQATM — et ce qu'on est en train de livrer — PatchCenter Web.
----
===== Slide 3 — Vue d'ensemble (1 min) =====
Le patching d'un serveur, ce n'est pas un geste unique. C'est **quatre phases étalées sur la semaine**.
Première phase, le **jeudi à 14 heures** : le COMEP SECOPS. C'est là qu'on répartit les serveurs à patcher entre les intervenants.
Deuxième phase, **jeudi après-midi et vendredi** : la préparation — ce qu'on appelle aussi le pré-patching. C'est toute la qualification serveur avant de toucher à quoi que ce soit.
Troisième phase, **du lundi au jeudi** de la semaine suivante : l'exécution — le fameux **Jour J**.
Et quatrième phase, **le vendredi** : le nettoyage. On supprime les snapshots des serveurs patchés dans la semaine, et on fait le ménage sur les vieux kernels.
//[pointer le callout en bas]//
Un point important : **on ne patche jamais le vendredi**. Pourquoi ? Parce que si un incident survient, on ne veut pas le traîner tout le weekend. Donc le vendredi est dédié à tout le reste — prépa de la semaine suivante, nettoyage, iTop, météo.
----
===== Slide 4 — Affectation (45 s) =====
Donc on démarre le jeudi au COMEP. 14 heures.
Chaque intervenant repart avec **sa liste de serveurs pour la semaine**. En cible on est à **20 serveurs par intervenant**. C'est un chiffre important à garder en tête, on va y revenir.
Le livrable du COMEP, c'est une liste enrichie : domaine, environnement, OS, responsable applicatif, criticité. Avec ça, le patcheur peut démarrer la qualification immédiatement.
----
===== Slide 5 — Préparation (1 min 30) =====
La préparation — c'est là que le gros du temps invisible se joue.
Pour **chaque serveur**, il faut faire **13 étapes** :
//[parcourir la liste à l'écran, ne pas la lire]//
Ça va de la recherche dans la console Qualys, à l'analyse des vulnérabilités, au SSH pour vérifier l'espace disque, le satellite, le dry-run yum... et ensuite seulement la partie "humaine" : **contacter le responsable applicatif** et lui proposer des créneaux.
//[pointer le KPI à droite]//
Résultat : **20 à 30 minutes par serveur** de travail actif. Pour 20 serveurs par patcheur par semaine, ça fait **6h40 à 10 heures** — rien que sur la préparation.
Et encore — je ne compte pas le temps d'attente de la validation du responsable. Qui peut prendre 1 heure, ou 48 heures. Donc le patcheur jongle avec une dizaine de serveurs en parallèle, tous à des états différents.
----
===== Slide 6 — Jour J (1 min 30) =====
On arrive au Jour J — forcément entre lundi et jeudi.
Là encore, **13 étapes par serveur**.
Ça commence par un message Teams pour prévenir, on prend le snapshot vCenter, on met le serveur en maintenance dans Centreon si c'est de la prod, on fait un snap pré-patch des services et des ports — parce qu'on veut pouvoir comparer après reboot. Ensuite seulement, le yum update. Le reboot. La reconnexion. Le check post-patch. Et enfin la **validation par le responsable applicatif** — qui peut demander 15 minutes ou plus si le service n'est pas immédiatement disponible.
//[pointer les KPIs]//
Le temps actif : **35 à 65 minutes par serveur**. Sur 20 serveurs, on est entre **11h40 et 21h40** par semaine rien que sur l'exécution.
//[petit silence]//
Pourquoi pas le vendredi ? On l'a dit : **au moins 24 heures de recul avant le weekend**. Si un service tombe dimanche, on veut pouvoir le détecter le vendredi.
----
===== Slide 7 — Nettoyage (45 s) =====
Le vendredi, on **regroupe le nettoyage** de toute la semaine.
Suppression des anciens snapshots, suppression des vieux kernels. Environ 1 heure 40 pour le lot de 20 serveurs.
Pourquoi on fait ça en un bloc le vendredi ? Pour deux raisons.
D'abord : on veut **garder le snapshot pendant au moins 24 heures** après le patch, au cas où une anomalie apparaîtrait en production.
Ensuite : c'est un **contexte mental unique**. Traiter les 20 snapshots d'un bloc, c'est beaucoup plus efficace que d'y revenir serveur par serveur.
----
===== Slide 8 — Synthèse temps (1 min) =====
Donc si on additionne tout ça pour **un seul serveur** :
* Préparation : 20-30 minutes
* Jour J : 35-65 minutes
* Nettoyage : 5 minutes
Total : **60 à 100 minutes par serveur**. En moyenne, **1 heure 15**.
//[pointer les KPIs du bas]//
**1 heure 15 par serveur. 20 serveurs par semaine. 25 heures de patching pur.**
//[silence — laisser le chiffre s'installer]//
----
===== Slide 9 — La réalité du patcheur (1 min 30) =====
Maintenant, la vraie question : **est-ce que le patcheur a 25 heures disponibles ?**
Regardons son budget. Le patcheur travaille **5 jours sur 7**, **7 heures par jour** — donc **35 heures par semaine**.
//[pointer la barre]//
Sur ces 35 heures :
* **28 heures** du lundi au jeudi sont la fenêtre d'exécution des Jours J
* **3,5 heures** le jeudi après-midi pour le COMEP et le début du pré-patching
* **7 heures** le vendredi pour finir le pré-patching et nettoyer
Et dans ces 35 heures, le patching seul consomme **entre 20 et 33 heures**.
Autrement dit : **entre 57 % et 95 % du temps disponible**.
//[insister]//
Dans le meilleur des cas — si tous les patches passent sans accroc, si personne ne traîne sur la validation, si aucun incident — il reste 2 heures de marge par semaine.
Et encore. **On n'a pas parlé des autres missions.**
----
===== Slide 10 — Missions annexes (1 min) =====
Parce qu'un patcheur SECOPS n'est pas que patcheur.
//[lister lentement]//
Il doit aussi :
* Traiter les **tickets iTop** de sécurisation serveur — permanent, 2 à 5 heures par semaine
* Faire les **mises à jour agents Qualys** et **SentinelOne** — 2 à 6 heures par semaine
* Prendre le **tour de garde** sur les alertes SentinelOne et Defender — 3 à 6 heures quand c'est son tour
* Produire la **météo sécurité** — 2 à 4 heures quand c'est son tour
Au total : **9 à 21 heures de missions annexes**.
//[pointer le callout rouge]//
Faites le calcul avec moi :
* Budget disponible : **35 heures**
* Patching seul : **20 à 33 heures**
* Missions annexes : **9 à 21 heures**
* **Charge totale réelle : 29 à 54 heures par semaine**
//[silence]//
Le patcheur est **structurellement en surcharge**. Pas occasionnellement. Structurellement.
----
===== Slide 11 — Conséquence sécurité (1 min 30) =====
Et c'est là que ça devient **un sujet de sécurité**, pas juste un sujet d'organisation.
//[ton plus grave]//
Quand un patcheur est en surcharge et qu'il prend son tour de garde sur les alertes SentinelOne et Defender, qu'est-ce qui se passe ?
//[lire les points avec poids]//
* Les alertes sont **triées en surface** — on regarde la criticité apparente, on coche, on passe
* Les **faux positifs ne sont pas requalifiés** — le bruit de fond s'accumule et masque les vrais incidents
* Les **vrais incidents sont détectés tardivement** — un ransomware, une exfiltration, un lateral movement
* L'**enrichissement de contexte** — corrélation process, réseau, utilisateur — n'est pas fait
* Les **règles de détection** ne sont jamais affinées, parce qu'on n'a pas le temps de faire ce retour d'expérience
//[pointer le callout rouge]//
Et là, on parle d'**incidents de sécurité majeurs**. Ransomware, fuite de données, indisponibilité.
**Le coût d'une seule alerte EDR ratée — c'est sans commune mesure avec le coût de l'automatisation du patching.**
//[silence]//
Voilà pourquoi automatiser le patching, ce n'est pas du confort. C'est de la sécurité.
----
===== Slide 12 — SQATM .exe (45 s) =====
On a déjà fait un pas dans cette direction : **SQATM .exe** est en production depuis un moment.
Cet outil automatise la **phase qualification** — la fameuse préparation des 20-30 minutes par serveur.
//[parcourir le tableau]//
La recherche Qualys plus l'analyse des vulnérabilités passe de 5 minutes à 30 secondes.\\
Le check SSH — disque, satellite, dry-run — passe de 5 minutes à 10 secondes, et surtout on le fait sur 100 serveurs en parallèle.\\
Le tagging Qualys est instantané.
**Gain estimé : 60 à 70 % sur la phase préparation.**
//[transition]//
Mais SQATM ne couvre que la partie qualification. Pour automatiser le Jour J, il fallait passer au niveau au-dessus.
----
===== Slide 13 — PatchCenter Web (1 min 30) =====
C'est l'objet de **PatchCenter Web**.
//[présenter les 3 colonnes]//
Trois grands volets.
**Préparation** — on centralise tout : vue unifiée des 1165 serveurs, audit SSH parallélisé, détection automatique des correspondances prod / hors-prod, workflow de validation intégré, et les tags Qualys auto-appliqués via les Tag Rules.
**Jour J** — on orchestre tout : snapshot vCenter via API, maintenance Centreon via API, notifications Teams automatiques, snap pré et post archivé, yum update en job asynchrone, reboot et reconnexion orchestrés, et détection automatique des régressions.
**Gouvernance** — c'est ce qui manquait le plus : la validation prod / hors-prod **bloquante**, l'historique complet par serveur, l'audit log exportable pour la conformité, et l'authentification LDAP AD SANEF avec 4 profils — admin, coordinateur, opérateur, viewer.
//[conclure]//
Et surtout : le tableau de suivi est **en temps réel**. Fini les Excel.
----
===== Slide 14 — Comparaison chiffrée (1 min) =====
Si on met les trois scénarios côte à côte, serveur par serveur :
//[parcourir le tableau rapidement]//
En mode manuel, on est à **60-100 minutes par serveur**.\\
Avec SQATM, on descend à **40-70 minutes** — **gain de 30 %**.\\
Avec PatchCenter Web, on descend à **15-30 minutes** — **gain de 70 à 75 %**.
//[pointer la dernière ligne]//
**On divise le temps par 3 à 4.**
Et ce qui est intéressant, c'est que **le gain ne vient pas du yum update lui-même** — ça reste le même temps à l'OS de mettre à jour les paquets. Le gain vient de **la suppression des gestes répétitifs** : snapshot, Centreon, notifs, suivi. C'est là que se cache tout le temps perdu.
----
===== Slide 15 — Impact hebdomadaire (1 min) =====
Traduisons ça au niveau hebdomadaire, pour les **20 serveurs par patcheur**.
//[parcourir les 3 lignes]//
En manuel : le patching occupe 20 à 33 heures. Reste 2 à 15 heures. **Insuffisant pour absorber les missions annexes.**
Avec SQATM : on libère 10 à 12 heures supplémentaires. Ça tient si le patcheur n'est pas en tour de garde.
**Avec PatchCenter Web : 5 à 10 heures de patching. Il reste 25 à 30 heures.**
//[laisser respirer]//
**25 à 30 heures par semaine libérées — par patcheur.**
De quoi assurer sereinement les tickets iTop, la garde EDR, la météo. Et même **monter la cadence** si le besoin opérationnel le demande — passer de 20 à 25, 30 serveurs par semaine, sans embaucher.
----
===== Slide 16 — Bénéfices qualitatifs (45 s) =====
Au-delà du temps, il y a tout ce qui est plus difficile à chiffrer mais tout aussi important.
//[balayer rapidement]//
* **Fiabilité** : on ne peut plus oublier un snapshot ou une maintenance Centreon
* **Traçabilité** : tout est historisé, exportable pour audit
* **Gouvernance** : les validations prod / hors-prod deviennent bloquantes
* **Scalabilité** : un intervenant peut piloter 20 serveurs en parallèle
* **Onboarding** : un nouveau patcheur est opérationnel en quelques jours
* **Qualité** : le snap pré/post standardisé détecte les régressions de façon fiable
* **Conformité** : les tags Qualys automatiques alimentent directement les KPI
* **Communication** : les notifications Teams standardisées donnent de la visibilité projet
----
===== Slide 17 — Feuille de route (45 s) =====
Où on en est concrètement aujourd'hui.
//[colonne verte]//
**Déjà livré :** SQATM en production. PatchCenter Web avec le catalogue des 1165 serveurs, la correspondance prod / hors-prod, les exclusions patch, le workflow de validation, la gestion des agents Qualys et le déploiement, l'intégration bidirectionnelle iTop, et l'authentification LDAP multi-profils.
//[colonne orange]//
**À venir :** l'orchestration vCenter et Centreon automatique, l'intégration Teams native, l'exécution patching end-to-end en un clic, le module reporting pour la DSI, et l'extension aux serveurs Windows via WSUS et MECM.
On est sur une livraison progressive — on n'attend pas que tout soit fini pour en profiter. Chaque brique livrée apporte déjà du gain.
----
===== Slide 18 — Conclusion (1 min) =====
Je conclus avec **trois messages** à retenir.
//[pointer le premier carré]//
**Un.** Le patching manuel est un **coût caché**. On le voit peu parce qu'il est étalé sur la semaine, mais c'est **20 à 33 heures par semaine par patcheur**.
**Deux.** Le patcheur est **structurellement en surcharge**. Dès qu'il cumule patching, garde et iTop, il dépasse son budget horaire. Ce n'est pas une question de motivation — c'est de l'arithmétique.
**Trois.** L'automatisation **libère 25 à 30 heures par semaine**. Du temps pour **analyser correctement les alertes EDR**.
//[pointer le callout final]//
Le message clé, pour vous, DSI :
//[lire posément]//
**PatchCenter Web n'est pas qu'un gain de productivité.** C'est un **levier de sécurité stratégique**.
Le temps qu'on libère sur le patching, c'est du temps qu'on rend au patcheur pour faire **son vrai métier d'analyste EDR**.
Automatiser le patching, c'est **renforcer directement la capacité de détection et de réponse aux incidents de SANEF**.
Merci.
----
===== Slide 19 — Q&A =====
//[rester en place, poser le clicker, regarder l'auditoire]//
Je prends vos questions.
----
===== Annexe — Questions fréquentes anticipées =====
==== "Pourquoi pas un outil du marché ?" ====
On a évalué. Aucun outil marché ne couvre à la fois : la nomenclature SANEF (décodage hostname), l'intégration bidirectionnelle iTop, la correspondance prod / hors-prod, et l'orchestration Centreon / vCenter spécifique SANEF. PatchCenter Web est **conçu pour le contexte**, et ça se voit dans le taux d'adoption de l'équipe.
==== "Combien ça coûte à développer vs la licence d'un outil marché ?" ====
Le développement est internalisé — pas de licence récurrente, pas de dépendance vendor. Le coût est largement en-dessous de ce qu'aurait coûté un déploiement commercial type Red Hat Satellite Insights ou BigFix sur 1165 serveurs.
==== "Et la sécurité de l'outil lui-même ?" ====
PatchCenter Web est derrière HAProxy avec TLS, authentification LDAP AD SANEF, séparation des rôles (4 profils), audit log complet. Tout transite par la VRF d'administration SANEF. Aucune exposition internet.
==== "Que devient SQATM une fois PatchCenter Web complet ?" ====
SQATM reste pertinent pour les opérations unitaires rapides et le tagging ponctuel. Les deux outils **coexistent** : SQATM = léger, rapide, unitaire. PatchCenter = orchestration lourde, campagnes, gouvernance.
==== "Risque de régression : si PatchCenter Web tombe, que fait-on ?" ====
Le patching manuel reste possible en fallback — tous les outils sous-jacents (SSH, vCenter, Centreon, Qualys) sont indépendants de PatchCenter. On ne crée pas de dépendance critique : PatchCenter Web **orchestre**, il ne **remplace** pas les outils existants.
==== "Planning de mise en service complète ?" ====
Livraison progressive en cours. Les modules catalogue, correspondance, validations et agents Qualys sont déjà en production. L'orchestration vCenter/Centreon/Teams est prévue sur les mois à venir. Le patching end-to-end en un clic est la dernière brique.
----
//— Script de présentation — SANEF DSI / Sécurité Opérationnelle — Avril 2026//

362
tools/wiki_to_pdf.py Normal file
View File

@ -0,0 +1,362 @@
#!/usr/bin/env python3
"""Convert DokuWiki pages to nicely-formatted PDFs (reportlab)."""
import re
import sys
from reportlab.lib import colors
from reportlab.lib.colors import HexColor
from reportlab.lib.pagesizes import A4
from reportlab.lib.styles import getSampleStyleSheet, ParagraphStyle
from reportlab.lib.units import cm, mm
from reportlab.platypus import (
SimpleDocTemplate, Paragraph, Spacer, Table, TableStyle,
PageBreak, Preformatted, KeepTogether, HRFlowable
)
from reportlab.lib.enums import TA_LEFT, TA_CENTER, TA_JUSTIFY
ACCENT = HexColor("#00a3c4")
ACCENT_LIGHT = HexColor("#e0f4f8")
DARK = HexColor("#1a1a2e")
GRAY = HexColor("#666666")
CODE_BG = HexColor("#f4f4f4")
INFO_BG = HexColor("#e8f4fd")
IMPORTANT_BG = HexColor("#fef3cd")
TIP_BG = HexColor("#d4edda")
def build_styles():
styles = getSampleStyleSheet()
styles.add(ParagraphStyle("H1c", parent=styles["Heading1"],
fontSize=20, textColor=ACCENT, spaceAfter=14, spaceBefore=10,
fontName="Helvetica-Bold"))
styles.add(ParagraphStyle("H2c", parent=styles["Heading2"],
fontSize=15, textColor=ACCENT, spaceAfter=10, spaceBefore=14,
fontName="Helvetica-Bold", borderPadding=(0, 0, 4, 0),
borderColor=ACCENT, borderWidth=0))
styles.add(ParagraphStyle("H3c", parent=styles["Heading3"],
fontSize=12.5, textColor=DARK, spaceAfter=8, spaceBefore=10,
fontName="Helvetica-Bold"))
styles.add(ParagraphStyle("H4c", parent=styles["Heading4"],
fontSize=11, textColor=DARK, spaceAfter=6, spaceBefore=8,
fontName="Helvetica-Bold"))
styles.add(ParagraphStyle("Bodyc", parent=styles["BodyText"],
fontSize=9.5, leading=13, spaceAfter=6, alignment=TA_JUSTIFY))
styles.add(ParagraphStyle("Bulletc", parent=styles["BodyText"],
fontSize=9.5, leading=13, leftIndent=16, bulletIndent=4, spaceAfter=3))
styles.add(ParagraphStyle("ItalicFooter", parent=styles["BodyText"],
fontSize=9, textColor=GRAY, alignment=TA_CENTER, spaceAfter=4))
return styles
def fmt_inline(text):
"""Convert DokuWiki inline markup to reportlab mini-HTML."""
# escape &, <, >
text = text.replace("&", "&amp;").replace("<", "&lt;").replace(">", "&gt;")
# bold **x**
text = re.sub(r"\*\*(.+?)\*\*", r"<b>\1</b>", text)
# inline code ''x''
text = re.sub(r"''(.+?)''", r'<font face="Courier" color="#b22222">\1</font>', text)
# italic //x// (avoid URLs)
text = re.sub(r"(?<!:)//(.+?)//", r"<i>\1</i>", text)
# doku links [[target|label]] -> label
text = re.sub(r"\[\[[^\]|]+\|([^\]]+)\]\]", r"\1", text)
text = re.sub(r"\[\[([^\]]+)\]\]", r"\1", text)
# line break \\
text = text.replace("\\\\", "<br/>")
return text
def parse_wiki(raw):
"""Return list of (kind, payload) blocks."""
lines = raw.splitlines()
blocks = []
i = 0
while i < len(lines):
ln = lines[i]
stripped = ln.strip()
# Separator
if stripped == "----":
blocks.append(("hr", None))
i += 1
continue
# Headings
m = re.match(r"^(=+)\s*(.+?)\s*=+\s*$", stripped)
if m:
level = 7 - len(m.group(1)) # ======(6) -> h1
level = max(1, min(4, level))
blocks.append((f"h{level}", m.group(2)))
i += 1
continue
# Code block
if stripped.startswith("<code"):
i += 1
code_lines = []
while i < len(lines) and "</code>" not in lines[i]:
code_lines.append(lines[i])
i += 1
i += 1 # skip </code>
blocks.append(("code", "\n".join(code_lines)))
continue
# WRAP box
m = re.match(r"^<WRAP\s+([^>]*)>\s*$", stripped)
if m:
kind = "info"
attrs = m.group(1).lower()
if "important" in attrs:
kind = "important"
elif "tip" in attrs:
kind = "tip"
elif "info" in attrs:
kind = "info"
i += 1
body_lines = []
while i < len(lines) and "</WRAP>" not in lines[i]:
body_lines.append(lines[i])
i += 1
i += 1
blocks.append(("wrap", (kind, "\n".join(body_lines))))
continue
# Table (starts with ^ or |)
if stripped.startswith("^") or stripped.startswith("|"):
tbl_lines = []
while i < len(lines) and (lines[i].strip().startswith("^") or lines[i].strip().startswith("|")):
tbl_lines.append(lines[i].strip())
i += 1
blocks.append(("table", tbl_lines))
continue
# Bullet list (item or sub-item)
if re.match(r"^\s*\*\s+", ln):
items = []
while i < len(lines) and re.match(r"^\s*\*\s+", lines[i]):
m2 = re.match(r"^(\s*)\*\s+(.+)$", lines[i])
indent = len(m2.group(1))
items.append((indent, m2.group(2)))
i += 1
blocks.append(("bullets", items))
continue
# Ordered list (DokuWiki uses " - item")
if re.match(r"^\s+-\s+", ln):
items = []
while i < len(lines) and re.match(r"^\s+-\s+", lines[i]):
m2 = re.match(r"^(\s*)-\s+(.+)$", lines[i])
indent = len(m2.group(1))
items.append((indent, m2.group(2)))
i += 1
blocks.append(("ordered", items))
continue
# Italic footer //...//
if stripped.startswith("//") and stripped.endswith("//") and len(stripped) > 4:
blocks.append(("italic", stripped.strip("/").strip()))
i += 1
continue
# Blank line -> spacer
if not stripped:
blocks.append(("space", None))
i += 1
continue
# Regular paragraph (gather until blank or special)
para_lines = [ln]
i += 1
while i < len(lines):
nxt = lines[i]
s = nxt.strip()
if not s or s.startswith("=") or s.startswith("^") or s.startswith("|") \
or s.startswith("<") or s == "----" or re.match(r"^\s*\*\s+", nxt) \
or re.match(r"^\s+-\s+", nxt):
break
para_lines.append(nxt)
i += 1
blocks.append(("para", " ".join(l.strip() for l in para_lines)))
return blocks
def parse_table_row(line):
"""Parse a DokuWiki table row. Returns (is_header_cells_list, cells)."""
# Row like: ^ h1 ^ h2 ^ or | c1 | c2 |
# A row may mix ^ and | (cell-level header)
sep_chars = "^|"
cells = []
is_header = []
# find separators
indices = [idx for idx, c in enumerate(line) if c in sep_chars]
for a, b in zip(indices[:-1], indices[1:]):
cell = line[a+1:b].strip()
header = line[a] == "^"
cells.append(cell)
is_header.append(header)
return is_header, cells
def build_table(tbl_lines, styles):
data = []
header_flags = []
for ln in tbl_lines:
is_h, cells = parse_table_row(ln)
if not cells:
continue
data.append(cells)
header_flags.append(is_h)
if not data:
return None
# Wrap each cell in a Paragraph for wrapping
wrapped = []
cell_style = ParagraphStyle("cell", fontSize=8.5, leading=11)
head_style = ParagraphStyle("head", fontSize=9, leading=11,
fontName="Helvetica-Bold", textColor=colors.white)
for row_idx, row in enumerate(data):
new_row = []
for col_idx, cell in enumerate(row):
is_h = header_flags[row_idx][col_idx] if col_idx < len(header_flags[row_idx]) else False
style = head_style if is_h else cell_style
new_row.append(Paragraph(fmt_inline(cell), style))
wrapped.append(new_row)
ncols = max(len(r) for r in wrapped)
# pad
for r in wrapped:
while len(r) < ncols:
r.append(Paragraph("", cell_style))
# column widths: spread evenly on 17cm available
total_w = 17 * cm
col_w = total_w / ncols
t = Table(wrapped, colWidths=[col_w] * ncols, repeatRows=1)
ts = TableStyle([
("GRID", (0, 0), (-1, -1), 0.3, colors.lightgrey),
("VALIGN", (0, 0), (-1, -1), "TOP"),
("LEFTPADDING", (0, 0), (-1, -1), 4),
("RIGHTPADDING", (0, 0), (-1, -1), 4),
("TOPPADDING", (0, 0), (-1, -1), 3),
("BOTTOMPADDING", (0, 0), (-1, -1), 3),
("ROWBACKGROUNDS", (0, 1), (-1, -1), [colors.white, HexColor("#f9f9f9")]),
])
# header row style (if row 0 is all headers)
if all(header_flags[0]):
ts.add("BACKGROUND", (0, 0), (-1, 0), ACCENT)
# color header cells individually otherwise
for ri, flags in enumerate(header_flags):
for ci, is_h in enumerate(flags):
if is_h and ri > 0:
ts.add("BACKGROUND", (ci, ri), (ci, ri), ACCENT_LIGHT)
t.setStyle(ts)
return t
def build_wrap(kind, text, styles):
bg = {"info": INFO_BG, "important": IMPORTANT_BG, "tip": TIP_BG}.get(kind, INFO_BG)
border = {"info": ACCENT, "important": HexColor("#e0a020"), "tip": HexColor("#28a745")}.get(kind, ACCENT)
label = {"info": "Info", "important": "Important", "tip": "Astuce"}.get(kind, "Note")
# Body can contain inline markup — render lines joined
text_fmt = fmt_inline(text.strip()).replace("\n", "<br/>")
inner = f'<b><font color="{border.hexval()}">{label}</font></b><br/>{text_fmt}'
p = Paragraph(inner, ParagraphStyle("wrap", fontSize=9.5, leading=13,
textColor=DARK, spaceAfter=4))
tbl = Table([[p]], colWidths=[17 * cm])
tbl.setStyle(TableStyle([
("BACKGROUND", (0, 0), (-1, -1), bg),
("BOX", (0, 0), (-1, -1), 1, border),
("LEFTPADDING", (0, 0), (-1, -1), 10),
("RIGHTPADDING", (0, 0), (-1, -1), 10),
("TOPPADDING", (0, 0), (-1, -1), 8),
("BOTTOMPADDING", (0, 0), (-1, -1), 8),
("LINEBEFORE", (0, 0), (0, -1), 4, border),
]))
return tbl
def render_blocks(blocks, styles, title):
story = [
Paragraph(title, styles["H1c"]),
HRFlowable(width="100%", thickness=2, color=ACCENT, spaceBefore=2, spaceAfter=10),
]
for kind, payload in blocks:
if kind == "h1":
story.append(Paragraph(fmt_inline(payload), styles["H1c"]))
elif kind == "h2":
story.append(Paragraph(fmt_inline(payload), styles["H2c"]))
story.append(HRFlowable(width="40%", thickness=1, color=ACCENT,
spaceBefore=0, spaceAfter=6))
elif kind == "h3":
story.append(Paragraph(fmt_inline(payload), styles["H3c"]))
elif kind == "h4":
story.append(Paragraph(fmt_inline(payload), styles["H4c"]))
elif kind == "hr":
story.append(HRFlowable(width="100%", thickness=0.5,
color=colors.lightgrey, spaceBefore=6, spaceAfter=6))
elif kind == "para":
story.append(Paragraph(fmt_inline(payload), styles["Bodyc"]))
elif kind == "italic":
story.append(Paragraph(f"<i>{fmt_inline(payload)}</i>", styles["ItalicFooter"]))
elif kind == "code":
story.append(Preformatted(payload, ParagraphStyle(
"code", fontName="Courier", fontSize=8, leading=10,
backColor=CODE_BG, borderColor=colors.lightgrey, borderWidth=0.5,
borderPadding=6, spaceAfter=8, spaceBefore=4, leftIndent=4)))
elif kind == "wrap":
w_kind, w_body = payload
story.append(build_wrap(w_kind, w_body, styles))
story.append(Spacer(1, 6))
elif kind == "table":
t = build_table(payload, styles)
if t:
story.append(t)
story.append(Spacer(1, 8))
elif kind == "bullets":
for indent, text in payload:
lvl = indent // 2
story.append(Paragraph(fmt_inline(text), ParagraphStyle(
"bl", fontSize=9.5, leading=13,
leftIndent=16 + lvl * 12, bulletIndent=4 + lvl * 12, spaceAfter=2),
bulletText=""))
elif kind == "ordered":
for n, (indent, text) in enumerate(payload, 1):
lvl = max(0, (indent - 2) // 2)
story.append(Paragraph(fmt_inline(text), ParagraphStyle(
"ol", fontSize=9.5, leading=13,
leftIndent=18 + lvl * 12, bulletIndent=4 + lvl * 12, spaceAfter=2),
bulletText=f"{n}."))
elif kind == "space":
story.append(Spacer(1, 4))
return story
def add_page_footer(canvas, doc):
canvas.saveState()
canvas.setFont("Helvetica", 8)
canvas.setFillColor(GRAY)
canvas.drawString(1.5 * cm, 1 * cm,
"SANEF DSI / Sécurité Opérationnelle — Plan d'action Qualys V3")
canvas.drawRightString(A4[0] - 1.5 * cm, 1 * cm, f"Page {canvas.getPageNumber()}")
canvas.restoreState()
def generate(src_path, dst_path, title):
with open(src_path, "r", encoding="utf-8") as f:
raw = f.read()
blocks = parse_wiki(raw)
styles = build_styles()
story = render_blocks(blocks, styles, title)
doc = SimpleDocTemplate(dst_path, pagesize=A4,
leftMargin=1.5 * cm, rightMargin=1.5 * cm,
topMargin=1.8 * cm, bottomMargin=1.8 * cm,
title=title)
doc.build(story, onFirstPage=add_page_footer, onLaterPages=add_page_footer)
print(f"Generated: {dst_path}")
if __name__ == "__main__":
# args: src title dst
src, title, dst = sys.argv[1], sys.argv[2], sys.argv[3]
generate(src, dst, title)