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:
parent
caa2be71a4
commit
677f621c81
@ -6,7 +6,7 @@ from starlette.middleware.base import BaseHTTPMiddleware
|
||||
from .config import APP_NAME, APP_VERSION
|
||||
from .dependencies import get_current_user, get_user_perms
|
||||
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):
|
||||
@ -63,6 +63,7 @@ app.include_router(qualys.router)
|
||||
app.include_router(quickwin.router)
|
||||
app.include_router(referentiel.router)
|
||||
app.include_router(patching.router)
|
||||
app.include_router(applications.router)
|
||||
|
||||
|
||||
@app.get("/")
|
||||
|
||||
481
app/routers/applications.py
Normal file
481
app/routers/applications.py
Normal 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)
|
||||
@ -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,
|
||||
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
|
||||
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.update({"app_name": APP_NAME, "servers": servers, "stats": stats,
|
||||
"server_links": server_links,
|
||||
"applications": applications,
|
||||
"envs": [e.name for e in envs],
|
||||
"domains": [d.name for d in domains],
|
||||
|
||||
@ -15,8 +15,6 @@ from ..services.quickwin_service import (
|
||||
build_yum_commands, get_available_servers, get_available_filters,
|
||||
add_entries_to_run, remove_entries_from_run,
|
||||
get_campaign_scope, apply_scope,
|
||||
get_correspondance, get_available_prod_entries,
|
||||
compute_correspondance, set_prod_pair, clear_all_pairs,
|
||||
DEFAULT_GENERAL_EXCLUDES,
|
||||
)
|
||||
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)
|
||||
async def quickwin_correspondance_redirect(request: Request, db=Depends(get_db)):
|
||||
"""Redirige vers la correspondance de la derniere campagne active"""
|
||||
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="/dashboard")
|
||||
runs = list_runs(db)
|
||||
if not runs:
|
||||
return RedirectResponse(url="/quickwin?msg=no_run")
|
||||
return RedirectResponse(url=f"/quickwin/{runs[0].id}/correspondance")
|
||||
"""Redirige vers la nouvelle correspondance globale."""
|
||||
return RedirectResponse(url="/patching/correspondance", status_code=303)
|
||||
|
||||
|
||||
@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})
|
||||
|
||||
|
||||
# ========== CORRESPONDANCE HPROD ↔ PROD ==========
|
||||
|
||||
# Correspondance par-run supprimée — utiliser /patching/correspondance (global)
|
||||
@router.get("/quickwin/{run_id}/correspondance")
|
||||
async def quickwin_correspondance_page(request: Request, run_id: int, db=Depends(get_db),
|
||||
search: str = Query(""), pair_filter: str = Query(""),
|
||||
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})
|
||||
async def quickwin_correspondance_deprecated(request: Request, run_id: int, db=Depends(get_db)):
|
||||
return RedirectResponse(url="/patching/correspondance", status_code=303)
|
||||
|
||||
@ -20,7 +20,7 @@ async def servers_list(request: Request, db=Depends(get_db),
|
||||
domain: str = Query(None), env: str = Query(None),
|
||||
tier: str = Query(None), etat: str = Query(None),
|
||||
os: str = Query(None), owner: str = Query(None),
|
||||
application: str = Query(None),
|
||||
application: str = Query(None), application_id: int = Query(None),
|
||||
search: str = Query(None), page: int = Query(1),
|
||||
sort: str = Query("hostname"), sort_dir: str = Query("asc")):
|
||||
user = get_current_user(request)
|
||||
@ -28,7 +28,8 @@ async def servers_list(request: Request, db=Depends(get_db),
|
||||
return RedirectResponse(url="/login")
|
||||
|
||||
filters = {"domain": domain, "env": env, "tier": tier, "etat": etat, "os": os,
|
||||
"owner": owner, "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)
|
||||
domains_list, envs_list = get_reference_data(db)
|
||||
|
||||
|
||||
@ -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)...")
|
||||
|
||||
sftp = client.open_sftp()
|
||||
sftp.put(package_path, remote_path=f"/tmp/{pkg_name}")
|
||||
sftp.put(package_path, f"/tmp/{pkg_name}")
|
||||
sftp.close()
|
||||
emit("Copie terminee")
|
||||
|
||||
|
||||
@ -53,7 +53,7 @@ def detect_correspondances(db, dry_run=False):
|
||||
|
||||
# Tous les serveurs actifs (exclut stock/obsolete)
|
||||
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)]
|
||||
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=""):
|
||||
"""Retourne tous les serveurs matchant les filtres, avec leurs correspondances existantes.
|
||||
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 = {}
|
||||
if 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=""):
|
||||
"""Vue hiérarchique des correspondances groupées par application.
|
||||
Exclut les serveurs en stock/obsolete."""
|
||||
where = ["s.etat NOT IN ('stock','obsolete')"]
|
||||
where = ["s.etat NOT IN ('stock','obsolete','eol')"]
|
||||
params = {}
|
||||
if 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 domains d ON de.domain_id = d.id
|
||||
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)
|
||||
ORDER BY s.application_name, s.hostname
|
||||
LIMIT 500
|
||||
|
||||
@ -337,9 +337,10 @@ def sync_from_itop(db, itop_url, itop_user, itop_pass):
|
||||
"patch_excludes,domain_ldap_name,last_patch_date,"
|
||||
"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",
|
||||
"implementation": "implementation", "obsolete": "obsolete"}
|
||||
"implementation": "implementation", "obsolete": "obsolete",
|
||||
"eol": "eol"}
|
||||
|
||||
for v in vms:
|
||||
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
|
||||
|
||||
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"}
|
||||
|
||||
# Build OSVersion cache: name.lower() → itop_id
|
||||
|
||||
@ -680,151 +680,8 @@ def inject_yum_history(db, data):
|
||||
return updated, inserted
|
||||
|
||||
|
||||
# ========== CORRESPONDANCE HPROD ↔ PROD ==========
|
||||
|
||||
def compute_correspondance(db, run_id, user=None):
|
||||
"""Auto-apparie chaque serveur hprod avec son homologue prod (2e lettre → p).
|
||||
Retourne (matched, unmatched, anomalies)."""
|
||||
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()
|
||||
# Correspondance HPROD ↔ PROD : logique déplacée vers server_correspondance (global)
|
||||
# Les fonctions obsolètes ont été supprimées : compute_correspondance, get_correspondance,
|
||||
# get_available_prod_entries, set_prod_pair, clear_all_pairs.
|
||||
# La colonne prod_pair_entry_id de quickwin_entries est laissée en place pour compatibilité
|
||||
# mais n'est plus utilisée. Les liens sont désormais dans server_correspondance.
|
||||
|
||||
@ -115,17 +115,20 @@ def list_servers(db, filters, page=1, per_page=50, sort="hostname", sort_dir="as
|
||||
if filters.get("tier"):
|
||||
where.append("s.tier = :tier"); params["tier"] = filters["tier"]
|
||||
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("COALESCE(s.licence_support, '') != 'obsolete'")
|
||||
if filters.get("os"):
|
||||
where.append("s.os_family = :os"); params["os"] = filters["os"]
|
||||
if filters.get("owner"):
|
||||
where.append("s.patch_os_owner = :owner"); params["owner"] = filters["owner"]
|
||||
if filters.get("application"):
|
||||
where.append("s.application_name = :application"); params["application"] = filters["application"]
|
||||
if filters.get("application_id"):
|
||||
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"):
|
||||
where.append("s.hostname ILIKE :search"); params["search"] = f"%{filters['search']}%"
|
||||
|
||||
|
||||
184
app/templates/admin_applications.html
Normal file
184
app/templates/admin_applications.html
Normal 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 %}
|
||||
126
app/templates/admin_applications_assign.html
Normal file
126
app/templates/admin_applications_assign.html
Normal 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">← 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 %}
|
||||
79
app/templates/admin_applications_multi.html
Normal file
79
app/templates/admin_applications_multi.html
Normal 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">← 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 %}
|
||||
@ -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>
|
||||
{% 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/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 %}
|
||||
</div>
|
||||
</div>
|
||||
@ -149,7 +148,8 @@
|
||||
</button>
|
||||
<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.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 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>
|
||||
|
||||
@ -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">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">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>
|
||||
|
||||
|
||||
@ -58,7 +58,7 @@
|
||||
<div>
|
||||
<label class="text-xs text-gray-500">Etat</label>
|
||||
<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>
|
||||
</div>
|
||||
<div>
|
||||
|
||||
@ -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-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">
|
||||
{% 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 %}
|
||||
{% 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 not s.n_as_prod and not s.n_as_nonprod %}<span class="text-gray-600">-</span>{% endif %}
|
||||
<td class="p-2 text-xs" style="max-width:260px">
|
||||
{% set link = server_links.get(s.id, {}) %}
|
||||
{% if link and link.as_prod %}
|
||||
<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>
|
||||
{% if can_edit %}
|
||||
<td class="p-2 text-center">
|
||||
|
||||
@ -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">{{ 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" 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>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
|
||||
@ -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">← Retour campagne</a>
|
||||
<h1 class="text-xl font-bold" style="color:#a78bfa">Correspondance H-Prod ↔ Prod</h1>
|
||||
<p class="text-xs text-gray-500">{{ run.label }} — 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é : {{ am }} apparié(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 été supprimé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é(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é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é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é-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é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é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électionné(s)</span>
|
||||
<button class="btn-sm" style="background:#ff336622;color:#ff3366;padding:3px 12px" onclick="bulkClear()">Dissocier la sélection</button>
|
||||
<span style="color:#1e3a5f">|</span>
|
||||
<span class="text-xs text-gray-400">Associer la sélection à :</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é</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é 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é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">←</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">…</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">→</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 %}
|
||||
@ -25,7 +25,7 @@
|
||||
<p class="text-xs text-gray-500">S{{ '%02d'|format(run.week_number) }} {{ run.year }} — Créé par {{ run.created_by_name or '?' }}</p>
|
||||
</div>
|
||||
<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="/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 ?')">
|
||||
|
||||
@ -36,7 +36,7 @@
|
||||
{% for t in ['tier0','tier1','tier2','tier3'] %}<option value="{{ t }}" {% if filters.tier == t %}selected{% endif %}>{{ t }}</option>{% endfor %}
|
||||
</select>
|
||||
<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 name="os" onchange="this.form.submit()"><option value="">OS</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 %}],
|
||||
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"}],
|
||||
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"}],
|
||||
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"><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.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-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">
|
||||
|
||||
834
tools/generate_ppt.py
Normal file
834
tools/generate_ppt.py
Normal 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}")
|
||||
335
tools/processus_patching.txt
Normal file
335
tools/processus_patching.txt
Normal 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}//
|
||||
391
tools/script_presentation.txt
Normal file
391
tools/script_presentation.txt
Normal 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
362
tools/wiki_to_pdf.py
Normal 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("&", "&").replace("<", "<").replace(">", ">")
|
||||
# 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)
|
||||
Loading…
Reference in New Issue
Block a user