Compare commits

...

6 Commits

Author SHA1 Message Date
caa2be71a4 Misc: servers page (application + equivalent), campagne tweaks
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-12 18:51:36 +02:00
a706e240ca Patching: exclusions + correspondance prod<->hors-prod + validations
- /patching/config-exclusions: exclusions iTop par serveur + bulk + push iTop
- /quickwin/config: liste globale reboot packages (au lieu de per-server)
- /patching/correspondance: builder mark PROD/NON-PROD + bulk change env/app
  + auto-detect par nomenclature + exclut stock/obsolete
- /patching/validations: workflow post-patching (en_attente/OK/KO/force)
  validator obligatoire depuis contacts iTop
- /patching/validations/history/{id}: historique par serveur
- Auto creation patch_validation apres status='patched' dans QuickWin
- check_prod_validations: banniere rouge sur quickwin detail si non-prod non valides
- Menu: Correspondance sous Serveurs, Config exclusions+Validations sous Patching
- Colonne Equivalent(s) sur /servers + section Correspondance sur detail

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-12 18:51:30 +02:00
ba0bff0f6e Remove: safe-patching (remplace par QuickWin) + audit-full
- Safe Patching v1 redondant avec QuickWin, supprime
- audit-full: page supprimee, tables DB preservees
- menu + main.py nettoyes

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-12 18:51:17 +02:00
3f47fea8e6 Audit: jobs background paralleles + progression live
- Audit global/realtime: threads paralleles, job_id retourne immediat
- /audit/realtime/progress/{job_id}: KPIs + barre progression + tableau live
- Polling AJAX toutes les 2s, etapes animees (DNS/SSH/Audit/OK)
- PRETTY_NAME correction: extraction via grep -E 'PRETTY_NAME' + cut
- OS version: normalisation lors de save_audit_to_db (Debian GNU/Linux -> Debian X (Bookworm))
- Mise a jour base: itop sync bidirectionnel avec push OS version

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-12 18:51:05 +02:00
5ea4100f4c Qualys: deploy agent background jobs + upgrade/downgrade + AJAX overlays
- Background job system pour deploiement (threads paralleles, progression live)
- Upgrade/downgrade: compare versions installee vs package, rpm -Uvh --oldpackage
- Checkbox "Forcer le downgrade" dans UI
- Choix auto DEB/RPM base sur os_version (centos/rhel/rocky/oracle -> RPM)
- Check agent: rpm -q / dpkg -s (evite faux positifs "agent installe mais inactif")
- Bouton "Rafraichir depuis Qualys" AJAX avec timer
- Agents page: colonne version installee + statut

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-12 18:50:56 +02:00
8479d7280e Users/Contacts: workflow profils + LDAP + sync iTop + etat aligne
- Users: 4 profils (admin/coordinator/operator/viewer) remplacent la matrix
- /users/add: picker contacts iTop (plus de creation libre)
- /me/change-password: flow force_password_change
- LDAP: service + section settings + option login
- Sync iTop contacts: filtre par teams (SecOps/iPOP/Externe/DSI/Admin DSI)
- Auto-desactivation users si contact inactif
- etat: alignement sur enum iTop (production/implementation/stock/obsolete)
- Menu: Contacts dans Administration, Serveurs en groupe repliable
- Audit bases: demo/prod via JWT mode

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-12 18:50:43 +02:00
62 changed files with 4462 additions and 3810 deletions

View File

@ -39,17 +39,30 @@ def get_current_user(request: Request):
def get_user_perms(db, user):
"""Charge les permissions depuis la base pour un user.
"""Charge les permissions : profil (role) + overrides de user_permissions.
Retourne un dict {module: level} ex: {'servers': 'admin', 'campaigns': 'edit'}"""
if not user:
return {}
uid = user.get("uid")
if not uid:
return {}
rows = db.execute(text(
# Récupère le rôle
row = db.execute(text("SELECT role FROM users WHERE id = :uid"), {"uid": uid}).fetchone()
if not row:
return {}
# Base : permissions du profil
from .services.profile_service import get_profile_perms
perms = get_profile_perms(row.role)
# Overrides éventuels depuis user_permissions (niveau plus élevé uniquement)
rank = {"view": 1, "edit": 2, "admin": 3}
overrides = db.execute(text(
"SELECT module, level FROM user_permissions WHERE user_id = :uid"
), {"uid": uid}).fetchall()
return {r.module: r.level for r in rows}
for r in overrides:
cur = perms.get(r.module)
if not cur or rank.get(r.level, 0) > rank.get(cur, 0):
perms[r.module] = r.level
return perms
def can_view(perms, module):

View File

@ -5,23 +5,42 @@ from fastapi.staticfiles import StaticFiles
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
from .routers import auth, dashboard, servers, settings, users, campaigns, planning, specifics, audit, contacts, qualys, safe_patching, audit_full, quickwin, referentiel
from .database import SessionLocal, SessionLocalDemo
from .routers import auth, dashboard, servers, settings, users, campaigns, planning, specifics, audit, contacts, qualys, quickwin, referentiel, patching
class PermissionsMiddleware(BaseHTTPMiddleware):
"""Injecte user + perms dans request.state pour tous les templates"""
"""Injecte user + perms dans request.state pour tous les templates.
Gère aussi la redirection si force_password_change est activé."""
async def dispatch(self, request: Request, call_next):
user = get_current_user(request)
perms = {}
must_change_pwd = False
if user:
db = SessionLocal()
# Sélectionner la base selon le mode JWT (prod/demo)
factory = SessionLocalDemo if user.get("mode") == "demo" else SessionLocal
db = factory()
try:
perms = get_user_perms(db, user)
# Check force_password_change
from sqlalchemy import text
row = db.execute(text("SELECT force_password_change FROM users WHERE id=:uid"),
{"uid": user.get("uid")}).fetchone()
if row and row.force_password_change:
must_change_pwd = True
finally:
db.close()
request.state.user = user
request.state.perms = perms
request.state.must_change_pwd = must_change_pwd
# Redirect vers change-password si forcé (sauf pour les routes de changement/logout/static)
if must_change_pwd and user:
allowed = ("/me/change-password", "/logout", "/static/")
if not any(request.url.path.startswith(p) for p in allowed):
from fastapi.responses import RedirectResponse
return RedirectResponse(url="/me/change-password", status_code=303)
response = await call_next(request)
return response
@ -41,10 +60,9 @@ app.include_router(specifics.router)
app.include_router(audit.router)
app.include_router(contacts.router)
app.include_router(qualys.router)
app.include_router(safe_patching.router)
app.include_router(audit_full.router)
app.include_router(quickwin.router)
app.include_router(referentiel.router)
app.include_router(patching.router)
@app.get("/")

View File

@ -5,7 +5,7 @@ from fastapi.responses import HTMLResponse, RedirectResponse, StreamingResponse
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 ..services.realtime_audit_service import audit_servers_list, save_audit_to_db
from ..services.realtime_audit_service import audit_servers_list, save_audit_to_db, start_audit_job, get_audit_job
from ..config import APP_NAME
router = APIRouter()
@ -134,7 +134,7 @@ async def audit_global(request: Request, db=Depends(get_db)):
parallel = int(form.get("parallel", "5"))
# Construire la requete
where = ["s.os_family = 'linux'", "s.etat = 'en_production'"]
where = ["s.os_family = 'linux'", "s.etat = 'production'"]
params = {}
if exclude_domains:
where.append("d.code NOT IN :ed")
@ -173,18 +173,9 @@ async def audit_global(request: Request, db=Depends(get_db)):
if not hostnames:
return RedirectResponse(url="/audit?msg=no_hosts", status_code=303)
# Lancer l'audit
results = audit_servers_list(hostnames)
request.app.state.last_audit_results = results
ctx = base_context(request, db, user)
ctx.update({
"app_name": APP_NAME, "results": results,
"total": len(results),
"ok": sum(1 for r in results if r.get("status") == "OK"),
"failed": sum(1 for r in results if r.get("status") != "OK"),
})
return templates.TemplateResponse("audit_realtime_results.html", ctx)
# Lancer en arrière-plan
job_id = start_audit_job(hostnames)
return RedirectResponse(url=f"/audit/realtime/progress/{job_id}", status_code=303)
@router.post("/audit/realtime", response_class=HTMLResponse)
@ -209,23 +200,57 @@ async def audit_realtime(request: Request, db=Depends(get_db),
if not hostnames:
return RedirectResponse(url="/audit?msg=no_hosts", status_code=303)
# Lancer l'audit
results = audit_servers_list(hostnames)
# Lancer en arrière-plan
job_id = start_audit_job(hostnames)
# Stocker en session (request.state) pour export/save
request.app.state.last_audit_results = results
return RedirectResponse(url=f"/audit/realtime/progress/{job_id}", status_code=303)
@router.get("/audit/realtime/progress/{job_id}", response_class=HTMLResponse)
async def audit_realtime_progress(request: Request, job_id: str, 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_view(perms, "audit"):
return RedirectResponse(url="/audit")
job = get_audit_job(job_id)
if not job:
return RedirectResponse(url="/audit?msg=job_not_found", status_code=303)
ctx = base_context(request, db, user)
ctx.update({
"app_name": APP_NAME, "results": results,
"total": len(results),
"ok": sum(1 for r in results if r.get("status") == "OK"),
"failed": sum(1 for r in results if r.get("status") != "OK"),
ctx.update({"app_name": APP_NAME, "job_id": job_id, "total": job["total"]})
return templates.TemplateResponse("audit_realtime_progress.html", ctx)
@router.get("/audit/realtime/status/{job_id}")
async def audit_realtime_status(request: Request, job_id: str, db=Depends(get_db)):
from fastapi.responses import JSONResponse
user = get_current_user(request)
if not user:
return JSONResponse({"ok": False}, status_code=401)
job = get_audit_job(job_id)
if not job:
return JSONResponse({"ok": False, "msg": "Job introuvable"}, status_code=404)
import time
elapsed = int(time.time() - job["started_at"])
# When finished, store results in app state for save/export
if job["finished"]:
request.app.state.last_audit_results = job["results"]
return JSONResponse({
"ok": True,
"job_id": job_id,
"total": job["total"],
"done": job["done"],
"finished": job["finished"],
"elapsed": elapsed,
"servers": job["servers"],
})
response = templates.TemplateResponse("audit_realtime_results.html", ctx)
response.headers["Cache-Control"] = "no-store, no-cache, must-revalidate, max-age=0"
response.headers["Pragma"] = "no-cache"
return response
@router.post("/audit/realtime/save")

View File

@ -1,703 +0,0 @@
"""Router Audit Complet — import JSON, liste, detail, carte flux, carte applicative"""
import json
from fastapi import APIRouter, Request, Depends, UploadFile, File
from fastapi.responses import HTMLResponse, RedirectResponse, StreamingResponse
from fastapi.templating import Jinja2Templates
from sqlalchemy import text
from ..dependencies import get_db, get_current_user, get_user_perms, can_view, can_edit, base_context
from ..services.server_audit_full_service import (
import_json_report, get_latest_audits, get_audit_detail,
get_flow_map, get_flow_map_for_server, get_app_map,
)
from ..config import APP_NAME
router = APIRouter()
templates = Jinja2Templates(directory="app/templates")
@router.get("/audit-full", response_class=HTMLResponse)
async def audit_full_list(request: Request, db=Depends(get_db)):
user = get_current_user(request)
if not user:
return RedirectResponse(url="/login")
perms = get_user_perms(db, user)
if not can_view(perms, "audit"):
return RedirectResponse(url="/dashboard")
filtre = request.query_params.get("filter", "")
search = request.query_params.get("q", "").strip()
domain = request.query_params.get("domain", "")
page = int(request.query_params.get("page", "1"))
per_page = 20
# KPIs (toujours sur tout le jeu)
kpis = db.execute(text("""
SELECT
COUNT(*) as total,
COUNT(*) FILTER (WHERE reboot_required = true) as needs_reboot,
COUNT(*) FILTER (WHERE EXISTS (
SELECT 1 FROM jsonb_array_elements(disk_usage) d
WHERE (d->>'pct')::int >= 90
)) as disk_critical,
COUNT(*) FILTER (WHERE EXISTS (
SELECT 1 FROM jsonb_array_elements(disk_usage) d
WHERE (d->>'pct')::int >= 80 AND (d->>'pct')::int < 90
)) as disk_warning,
COUNT(*) FILTER (WHERE
uptime LIKE '%month%' OR uptime LIKE '%year%'
OR (uptime LIKE '%week%' AND (
CASE WHEN uptime ~ '(\d+) week' THEN (substring(uptime from '(\d+) week'))::int ELSE 0 END >= 17
))
) as uptime_long,
COUNT(*) FILTER (WHERE services::text ~* 'postgres') as app_postgres,
COUNT(*) FILTER (WHERE services::text ~* 'mariadb|mysqld') as app_mariadb,
COUNT(*) FILTER (WHERE services::text ~* 'hdb|sapstart|HANA') as app_hana,
COUNT(*) FILTER (WHERE services::text ~* 'oracle|ora_pmon' OR processes::text ~* 'ora_pmon|oracle') as app_oracle,
COUNT(*) FILTER (WHERE services::text ~* '"httpd"' OR listen_ports::text ~* '"httpd"') as app_httpd,
COUNT(*) FILTER (WHERE services::text ~* '"nginx"' OR listen_ports::text ~* '"nginx"') as app_nginx,
COUNT(*) FILTER (WHERE services::text ~* 'haproxy') as app_haproxy,
COUNT(*) FILTER (WHERE services::text ~* 'tomcat' OR processes::text ~* 'tomcat|catalina') as app_tomcat,
COUNT(*) FILTER (WHERE listen_ports::text ~* '"node"' OR processes::text ~* '/applis.*node') as app_nodejs,
COUNT(*) FILTER (WHERE services::text ~* 'redis') as app_redis,
COUNT(*) FILTER (WHERE services::text ~* 'mongod') as app_mongodb,
COUNT(*) FILTER (WHERE services::text ~* 'elasticsearch|opensearch') as app_elastic,
COUNT(*) FILTER (WHERE services::text ~* 'docker|podman' OR processes::text ~* 'dockerd|podman') as app_container,
COUNT(*) FILTER (WHERE listen_ports::text ~* '"java"' OR processes::text ~* '\.jar') as app_java
FROM server_audit_full
WHERE status IN ('ok','partial')
AND id IN (SELECT DISTINCT ON (hostname) id FROM server_audit_full WHERE status IN ('ok','partial') ORDER BY hostname, audit_date DESC)
""")).fetchone()
# Domaines + zones pour le filtre
all_domains = db.execute(text(
"SELECT code, name, 'domain' as type FROM domains ORDER BY name"
)).fetchall()
all_zones = db.execute(text(
"SELECT name as code, name, 'zone' as type FROM zones ORDER BY name"
)).fetchall()
# Requete avec filtres
audits = get_latest_audits(db, limit=9999)
# Filtre KPI
if filtre == "reboot":
audits = [a for a in audits if a.reboot_required]
elif filtre == "disk_critical":
ids = {r.id for r in db.execute(text("""
SELECT saf.id FROM server_audit_full saf
WHERE saf.status IN ('ok','partial') AND EXISTS (
SELECT 1 FROM jsonb_array_elements(saf.disk_usage) d WHERE (d->>'pct')::int >= 90
) AND saf.id IN (SELECT DISTINCT ON (hostname) id FROM server_audit_full WHERE status IN ('ok','partial') ORDER BY hostname, audit_date DESC)
""")).fetchall()}
audits = [a for a in audits if a.id in ids]
elif filtre == "disk_warning":
ids = {r.id for r in db.execute(text("""
SELECT saf.id FROM server_audit_full saf
WHERE saf.status IN ('ok','partial') AND EXISTS (
SELECT 1 FROM jsonb_array_elements(saf.disk_usage) d WHERE (d->>'pct')::int >= 80
) AND saf.id IN (SELECT DISTINCT ON (hostname) id FROM server_audit_full WHERE status IN ('ok','partial') ORDER BY hostname, audit_date DESC)
""")).fetchall()}
audits = [a for a in audits if a.id in ids]
elif filtre == "uptime":
audits = [a for a in audits if a.uptime and ("month" in a.uptime or "year" in a.uptime)]
elif filtre and filtre.startswith("app_"):
# Filtre applicatif generique
app_patterns = {
"app_postgres": "postgres",
"app_mariadb": "mariadb|mysqld",
"app_hana": "hdb|sapstart|HANA",
"app_oracle": "ora_pmon|oracle",
"app_httpd": "httpd",
"app_nginx": "nginx",
"app_haproxy": "haproxy",
"app_tomcat": "tomcat|catalina",
"app_nodejs": "node",
"app_redis": "redis",
"app_mongodb": "mongod",
"app_elastic": "elasticsearch|opensearch",
"app_container": "docker|podman",
"app_java": "java|\\.jar",
}
pattern = app_patterns.get(filtre, "")
if pattern:
ids = {r.id for r in db.execute(text("""
SELECT id FROM server_audit_full
WHERE status IN ('ok','partial')
AND (services::text ~* :pat OR listen_ports::text ~* :pat OR processes::text ~* :pat)
AND id IN (SELECT DISTINCT ON (hostname) id FROM server_audit_full WHERE status IN ('ok','partial') ORDER BY hostname, audit_date DESC)
"""), {"pat": pattern}).fetchall()}
audits = [a for a in audits if a.id in ids]
# Filtre domaine ou zone
if domain:
# D'abord chercher comme zone
zone_servers = {r.hostname for r in db.execute(text("""
SELECT s.hostname FROM servers s
JOIN zones z ON s.zone_id = z.id
WHERE z.name = :name
"""), {"name": domain}).fetchall()}
if zone_servers:
audits = [a for a in audits if a.hostname in zone_servers]
else:
# Sinon chercher comme domaine
domain_servers = {r.hostname for r in db.execute(text("""
SELECT s.hostname FROM servers s
JOIN domain_environments de ON s.domain_env_id = de.id
JOIN domains d ON de.domain_id = d.id
WHERE d.code = :dc
"""), {"dc": domain}).fetchall()}
audits = [a for a in audits if a.hostname in domain_servers]
# Recherche hostname
if search:
q = search.lower()
audits = [a for a in audits if q in a.hostname.lower()]
# Tri
sort = request.query_params.get("sort", "hostname")
sort_dir = request.query_params.get("dir", "asc")
if sort == "hostname":
audits.sort(key=lambda a: a.hostname.lower(), reverse=(sort_dir == "desc"))
elif sort == "uptime":
def uptime_days(a):
u = a.uptime or ""
d = 0
import re as _re
m = _re.search(r"(\d+) year", u)
if m: d += int(m.group(1)) * 365
m = _re.search(r"(\d+) month", u)
if m: d += int(m.group(1)) * 30
m = _re.search(r"(\d+) week", u)
if m: d += int(m.group(1)) * 7
m = _re.search(r"(\d+) day", u)
if m: d += int(m.group(1))
return d
audits.sort(key=uptime_days, reverse=(sort_dir == "desc"))
elif sort == "reboot":
audits.sort(key=lambda a: (1 if a.reboot_required else 0), reverse=(sort_dir == "desc"))
elif sort == "patch":
def patch_sort_key(a):
if a.last_patch_date:
return a.last_patch_date
elif a.last_patch_year and a.last_patch_week:
return f"{a.last_patch_year}-{a.last_patch_week}"
return ""
audits.sort(key=patch_sort_key, reverse=(sort_dir == "desc"))
# Pagination
total_filtered = len(audits)
total_pages = max(1, (total_filtered + per_page - 1) // per_page)
page = max(1, min(page, total_pages))
audits_page = audits[(page - 1) * per_page : page * per_page]
ctx = base_context(request, db, user)
ctx.update({
"app_name": APP_NAME, "audits": audits_page, "kpis": kpis,
"filter": filtre, "search": search, "domain": domain,
"all_domains": all_domains, "all_zones": all_zones,
"sort": sort, "sort_dir": sort_dir,
"page": page, "total_pages": total_pages, "total_filtered": total_filtered,
"msg": request.query_params.get("msg"),
})
return templates.TemplateResponse("audit_full_list.html", ctx)
@router.post("/audit-full/import")
async def audit_full_import(request: Request, db=Depends(get_db),
file: UploadFile = File(...)):
user = get_current_user(request)
if not user:
return RedirectResponse(url="/login")
perms = get_user_perms(db, user)
if not can_edit(perms, "audit"):
return RedirectResponse(url="/audit-full")
try:
content = await file.read()
json_data = json.loads(content.decode("utf-8-sig"))
imported, errors = import_json_report(db, json_data)
return RedirectResponse(
url=f"/audit-full?msg=imported_{imported}_{errors}",
status_code=303,
)
except Exception as e:
return RedirectResponse(
url=f"/audit-full?msg=error_{str(e)[:50]}",
status_code=303,
)
@router.get("/audit-full/patching", response_class=HTMLResponse)
async def audit_full_patching(request: Request, db=Depends(get_db)):
user = get_current_user(request)
if not user:
return RedirectResponse(url="/login")
perms = get_user_perms(db, user)
if not can_view(perms, "audit"):
return RedirectResponse(url="/dashboard")
year = int(request.query_params.get("year", "2026"))
search = request.query_params.get("q", "").strip()
domain = request.query_params.get("domain", "")
scope = request.query_params.get("scope", "") # secops, other, ou vide=tout
page = int(request.query_params.get("page", "1"))
sort = request.query_params.get("sort", "hostname")
sort_dir = request.query_params.get("dir", "asc")
per_page = 30
yr_count = "patch_count_2026" if year == 2026 else "patch_count_2025"
yr_weeks = "patch_weeks_2026" if year == 2026 else "patch_weeks_2025"
# KPIs globaux + secops/autre
_latest = "id IN (SELECT DISTINCT ON (hostname) id FROM server_audit_full WHERE status IN ('ok','partial') ORDER BY hostname, audit_date DESC)"
kpis = db.execute(text(
f"SELECT COUNT(*) as total,"
f" COUNT(*) FILTER (WHERE {yr_count} >= 1) as patched,"
f" COUNT(*) FILTER (WHERE {yr_count} = 1) as once,"
f" COUNT(*) FILTER (WHERE {yr_count} >= 2) as twice,"
f" COUNT(*) FILTER (WHERE {yr_count} >= 3) as thrice,"
f" COUNT(*) FILTER (WHERE {yr_count} = 0 OR {yr_count} IS NULL) as never"
f" FROM server_audit_full WHERE status IN ('ok','partial') AND {_latest}"
)).fetchone()
kpis_secops = db.execute(text(
f"SELECT COUNT(*) as total,"
f" COUNT(*) FILTER (WHERE saf.{yr_count} >= 1) as patched,"
f" COUNT(*) FILTER (WHERE saf.{yr_count} = 0 OR saf.{yr_count} IS NULL) as never"
f" FROM server_audit_full saf JOIN servers s ON saf.server_id = s.id"
f" WHERE saf.status IN ('ok','partial') AND s.patch_os_owner = 'secops'"
f" AND saf.{_latest}"
)).fetchone()
kpis_other = db.execute(text(
f"SELECT COUNT(*) as total,"
f" COUNT(*) FILTER (WHERE saf.{yr_count} >= 1) as patched,"
f" COUNT(*) FILTER (WHERE saf.{yr_count} = 0 OR saf.{yr_count} IS NULL) as never"
f" FROM server_audit_full saf JOIN servers s ON saf.server_id = s.id"
f" WHERE saf.status IN ('ok','partial') AND (s.patch_os_owner != 'secops' OR s.patch_os_owner IS NULL)"
f" AND saf.{_latest}"
)).fetchone()
# Comparaison Y-1 a meme semaine
compare = None
from datetime import datetime as _dt
current_week = _dt.now().isocalendar()[1]
if year == 2026:
# Cumulatif 2025 a la meme semaine (pre-calcule)
import json as _json, os as _os
cumul_2025_path = _os.path.join(_os.path.dirname(_os.path.abspath(__file__)), "..", "data", "cumul_2025_by_week.json")
prev_at_same_week = 0
prev_total = 1045
prev_data_ok = False
try:
with open(cumul_2025_path) as f:
cumul_2025 = _json.load(f)
prev_at_same_week = cumul_2025.get(str(current_week - 1), cumul_2025.get(str(current_week), 0))
prev_data_ok = True
except Exception:
pass
compare = db.execute(text(
f"SELECT"
f" COUNT(*) FILTER (WHERE patch_count_2026 >= 1) as current_patched,"
f" COUNT(*) FILTER (WHERE patch_count_2025 >= 1) as prev_year_total,"
f" COUNT(*) as total"
f" FROM server_audit_full WHERE status IN ('ok','partial') AND {_latest}"
)).fetchone()
compare = {
"current_patched": compare.current_patched,
"current_total": compare.total,
"prev_year_total": compare.prev_year_total,
"prev_at_same_week": prev_at_same_week,
"prev_total": prev_total,
"prev_data_ok": prev_data_ok,
"compare_week": current_week - 1,
}
patch_by_domain = db.execute(text(
f"SELECT d.name as domain, d.code,"
f" COUNT(DISTINCT saf.hostname) as total,"
f" COUNT(DISTINCT saf.hostname) FILTER (WHERE saf.{yr_count} >= 1) as patched,"
f" COUNT(DISTINCT saf.hostname) FILTER (WHERE saf.{yr_count} >= 2) as twice,"
f" COUNT(DISTINCT saf.hostname) FILTER (WHERE saf.{yr_count} = 0 OR saf.{yr_count} IS NULL) as never"
f" FROM server_audit_full saf JOIN servers s ON saf.server_id = s.id"
f" JOIN domain_environments de ON s.domain_env_id = de.id JOIN domains d ON de.domain_id = d.id"
f" WHERE saf.status IN ('ok','partial')"
f" AND saf.id IN (SELECT DISTINCT ON (hostname) id FROM server_audit_full WHERE status IN ('ok','partial') ORDER BY hostname, audit_date DESC)"
f" GROUP BY d.name, d.code, d.display_order ORDER BY d.display_order"
)).fetchall()
patch_weekly = []
if year == 2026:
patch_weekly = db.execute(text("""
SELECT week, SUM(patched)::int as patched, SUM(cancelled)::int as cancelled FROM (
SELECT unnest(string_to_array(patch_weeks_2026, ',')) as week, 1 as patched, 0 as cancelled
FROM server_audit_full
WHERE status IN ('ok','partial') AND patch_weeks_2026 IS NOT NULL AND patch_weeks_2026 != ''
AND id IN (SELECT DISTINCT ON (hostname) id FROM server_audit_full WHERE status IN ('ok','partial') ORDER BY hostname, audit_date DESC)
UNION ALL
SELECT unnest(string_to_array(cancelled_weeks_2026, ',')) as week, 0 as patched, 1 as cancelled
FROM server_audit_full
WHERE status IN ('ok','partial') AND cancelled_weeks_2026 IS NOT NULL AND cancelled_weeks_2026 != ''
AND id IN (SELECT DISTINCT ON (hostname) id FROM server_audit_full WHERE status IN ('ok','partial') ORDER BY hostname, audit_date DESC)
) combined WHERE week != '' GROUP BY week ORDER BY week
""")).fetchall()
all_domains = db.execute(text("SELECT code, name, 'domain' as type FROM domains ORDER BY name")).fetchall()
all_zones = db.execute(text("SELECT name as code, name, 'zone' as type FROM zones ORDER BY name")).fetchall()
servers = db.execute(text(
f"SELECT DISTINCT ON (saf.hostname) saf.id, saf.hostname, saf.os_release,"
f" saf.last_patch_date, saf.last_patch_week, saf.last_patch_year,"
f" saf.{yr_count} as patch_count, saf.{yr_weeks} as patch_weeks,"
f" d.name as domain, e.name as env, z.name as zone"
f" FROM server_audit_full saf"
f" LEFT JOIN servers s ON saf.server_id = s.id"
f" LEFT JOIN domain_environments de ON s.domain_env_id = de.id"
f" LEFT JOIN domains d ON de.domain_id = d.id"
f" LEFT JOIN environments e ON de.environment_id = e.id"
f" LEFT JOIN zones z ON s.zone_id = z.id"
f" WHERE saf.status IN ('ok','partial')"
f" ORDER BY saf.hostname, saf.audit_date DESC"
)).fetchall()
if domain:
zone_hosts = {r.hostname for r in db.execute(text(
"SELECT s.hostname FROM servers s JOIN zones z ON s.zone_id = z.id WHERE z.name = :n"
), {"n": domain}).fetchall()}
if zone_hosts:
servers = [s for s in servers if s.hostname in zone_hosts]
else:
dom_hosts = {r.hostname for r in db.execute(text(
"SELECT s.hostname FROM servers s JOIN domain_environments de ON s.domain_env_id = de.id"
" JOIN domains d ON de.domain_id = d.id WHERE d.code = :dc"
), {"dc": domain}).fetchall()}
servers = [s for s in servers if s.hostname in dom_hosts]
if search:
servers = [s for s in servers if search.lower() in s.hostname.lower()]
# Filtre scope secops / autre
if scope == "secops":
secops_hosts = {r.hostname for r in db.execute(text(
"SELECT hostname FROM servers WHERE patch_os_owner = 'secops'"
)).fetchall()}
servers = [s for s in servers if s.hostname in secops_hosts]
elif scope == "other":
secops_hosts = {r.hostname for r in db.execute(text(
"SELECT hostname FROM servers WHERE patch_os_owner = 'secops'"
)).fetchall()}
servers = [s for s in servers if s.hostname not in secops_hosts]
if sort == "hostname":
servers.sort(key=lambda s: s.hostname.lower(), reverse=(sort_dir == "desc"))
elif sort == "count":
servers.sort(key=lambda s: s.patch_count or 0, reverse=(sort_dir == "desc"))
elif sort == "last":
servers.sort(key=lambda s: s.last_patch_week or "", reverse=(sort_dir == "desc"))
total_filtered = len(servers)
total_pages = max(1, (total_filtered + per_page - 1) // per_page)
page = max(1, min(page, total_pages))
servers_page = servers[(page - 1) * per_page : page * per_page]
ctx = base_context(request, db, user)
ctx.update({
"app_name": APP_NAME, "year": year, "kpis": kpis,
"kpis_secops": kpis_secops, "kpis_other": kpis_other,
"compare": compare,
"patch_by_domain": patch_by_domain, "patch_weekly": patch_weekly,
"servers": servers_page, "all_domains": all_domains, "all_zones": all_zones,
"search": search, "domain": domain, "scope": scope,
"sort": sort, "sort_dir": sort_dir,
"page": page, "total_pages": total_pages, "total_filtered": total_filtered,
})
return templates.TemplateResponse("audit_full_patching.html", ctx)
@router.get("/audit-full/patching/export-csv")
async def patching_export_csv(request: Request, db=Depends(get_db)):
user = get_current_user(request)
if not user:
return RedirectResponse(url="/login")
perms = get_user_perms(db, user)
if not can_view(perms, "audit"):
return RedirectResponse(url="/audit-full")
import io, csv
year = int(request.query_params.get("year", "2026"))
search = request.query_params.get("q", "").strip()
domain = request.query_params.get("domain", "")
scope = request.query_params.get("scope", "")
yr_count = "patch_count_2026" if year == 2026 else "patch_count_2025"
yr_weeks = "patch_weeks_2026" if year == 2026 else "patch_weeks_2025"
servers = db.execute(text(
f"SELECT DISTINCT ON (saf.hostname) saf.hostname,"
f" saf.{yr_count} as patch_count, saf.{yr_weeks} as patch_weeks,"
f" saf.last_patch_date, saf.last_patch_week, saf.last_patch_year,"
f" saf.patch_status_2026,"
f" d.name as domain, e.name as env, z.name as zone, s.os_family, s.etat"
f" FROM server_audit_full saf"
f" LEFT JOIN servers s ON saf.server_id = s.id"
f" LEFT JOIN domain_environments de ON s.domain_env_id = de.id"
f" LEFT JOIN domains d ON de.domain_id = d.id"
f" LEFT JOIN environments e ON de.environment_id = e.id"
f" LEFT JOIN zones z ON s.zone_id = z.id"
f" WHERE saf.status IN ('ok','partial')"
f" ORDER BY saf.hostname, saf.audit_date DESC"
)).fetchall()
if scope == "secops":
secops = {r.hostname for r in db.execute(text("SELECT hostname FROM servers WHERE patch_os_owner = 'secops'")).fetchall()}
servers = [s for s in servers if s.hostname in secops]
elif scope == "other":
secops = {r.hostname for r in db.execute(text("SELECT hostname FROM servers WHERE patch_os_owner = 'secops'")).fetchall()}
servers = [s for s in servers if s.hostname not in secops]
if domain:
zone_hosts = {r.hostname for r in db.execute(text(
"SELECT s.hostname FROM servers s JOIN zones z ON s.zone_id = z.id WHERE z.name = :n"
), {"n": domain}).fetchall()}
if zone_hosts:
servers = [s for s in servers if s.hostname in zone_hosts]
else:
dom_hosts = {r.hostname for r in db.execute(text(
"SELECT s.hostname FROM servers s JOIN domain_environments de ON s.domain_env_id = de.id"
" JOIN domains d ON de.domain_id = d.id WHERE d.code = :dc"
), {"dc": domain}).fetchall()}
servers = [s for s in servers if s.hostname in dom_hosts]
if search:
servers = [s for s in servers if search.lower() in s.hostname.lower()]
output = io.StringIO()
w = csv.writer(output, delimiter=";")
w.writerow(["Hostname", "OS", "Domaine", "Environnement", "Zone", "Etat",
"Nb patches", "Semaines", "Dernier patch", "Statut"])
for s in servers:
w.writerow([
s.hostname, s.os_family or "", s.domain or "", s.env or "",
s.zone or "", s.etat or "", s.patch_count or 0,
s.patch_weeks or "", s.last_patch_date or s.last_patch_week or "",
s.patch_status_2026 or "",
])
output.seek(0)
return StreamingResponse(
iter(["\ufeff" + output.getvalue()]),
media_type="text/csv",
headers={"Content-Disposition": f"attachment; filename=patching_{year}.csv"})
@router.get("/audit-full/export-csv")
async def audit_full_export_csv(request: Request, db=Depends(get_db)):
user = get_current_user(request)
if not user:
return RedirectResponse(url="/login")
perms = get_user_perms(db, user)
if not can_view(perms, "audit"):
return RedirectResponse(url="/audit-full")
import io, csv
filtre = request.query_params.get("filter", "")
search = request.query_params.get("q", "").strip()
domain = request.query_params.get("domain", "")
audits = get_latest_audits(db, limit=9999)
# Memes filtres que la page liste
if filtre == "reboot":
audits = [a for a in audits if a.reboot_required]
elif filtre == "uptime":
audits = [a for a in audits if a.uptime and ("month" in a.uptime or "year" in a.uptime)]
elif filtre and filtre.startswith("app_"):
app_patterns = {
"app_postgres": "postgres", "app_mariadb": "mariadb|mysqld",
"app_hana": "hdb|sapstart|HANA", "app_oracle": "ora_pmon|oracle",
"app_httpd": "httpd", "app_nginx": "nginx", "app_haproxy": "haproxy",
"app_tomcat": "tomcat|catalina", "app_nodejs": "node",
"app_redis": "redis", "app_mongodb": "mongod",
"app_elastic": "elasticsearch|opensearch", "app_container": "docker|podman",
"app_java": "java|\\.jar",
}
pattern = app_patterns.get(filtre, "")
if pattern:
ids = {r.id for r in db.execute(text("""
SELECT id FROM server_audit_full
WHERE status IN ('ok','partial')
AND (services::text ~* :pat OR listen_ports::text ~* :pat OR processes::text ~* :pat)
AND id IN (SELECT DISTINCT ON (hostname) id FROM server_audit_full WHERE status IN ('ok','partial') ORDER BY hostname, audit_date DESC)
"""), {"pat": pattern}).fetchall()}
audits = [a for a in audits if a.id in ids]
if domain:
zone_servers = {r.hostname for r in db.execute(text(
"SELECT s.hostname FROM servers s JOIN zones z ON s.zone_id = z.id WHERE z.name = :name"
), {"name": domain}).fetchall()}
if zone_servers:
audits = [a for a in audits if a.hostname in zone_servers]
else:
domain_servers = {r.hostname for r in db.execute(text("""
SELECT s.hostname FROM servers s JOIN domain_environments de ON s.domain_env_id = de.id
JOIN domains d ON de.domain_id = d.id WHERE d.code = :dc
"""), {"dc": domain}).fetchall()}
audits = [a for a in audits if a.hostname in domain_servers]
if search:
q = search.lower()
audits = [a for a in audits if q in a.hostname.lower()]
# Generer CSV
output = io.StringIO()
writer = csv.writer(output, delimiter=";")
writer.writerow(["Hostname", "OS", "Kernel", "Uptime", "Services", "Processus",
"Ports", "Connexions", "Reboot requis", "Date audit"])
for a in audits:
writer.writerow([
a.hostname, a.os_release or "", a.kernel or "", a.uptime or "",
a.svc_count, a.proc_count, a.port_count, a.conn_count,
"Oui" if a.reboot_required else "Non",
a.audit_date.strftime("%Y-%m-%d %H:%M") if a.audit_date else "",
])
output.seek(0)
return StreamingResponse(
iter(["\ufeff" + output.getvalue()]),
media_type="text/csv",
headers={"Content-Disposition": "attachment; filename=audit_serveurs.csv"},
)
@router.get("/audit-full/flow-map", response_class=HTMLResponse)
async def audit_full_flow_map(request: Request, db=Depends(get_db)):
user = get_current_user(request)
if not user:
return RedirectResponse(url="/login")
perms = get_user_perms(db, user)
if not can_view(perms, "audit"):
return RedirectResponse(url="/audit-full")
domain_filter = request.query_params.get("domain", "")
server_filter = request.query_params.get("server", "").strip()
# Domaines + zones pour le dropdown
all_domains = db.execute(text(
"SELECT code, name, 'domain' as type FROM domains ORDER BY name"
)).fetchall()
all_zones = db.execute(text(
"SELECT name as code, name, 'zone' as type FROM zones ORDER BY name"
)).fetchall()
# Serveurs audites pour l'autocompletion
audited_servers = db.execute(text("""
SELECT DISTINCT hostname FROM server_audit_full WHERE status IN ('ok','partial') ORDER BY hostname
""")).fetchall()
if server_filter:
# Flux pour un serveur specifique (IN + OUT)
flows = db.execute(text("""
SELECT source_hostname, source_ip, dest_ip, dest_port,
dest_hostname, process_name, direction, state,
COUNT(*) as cnt
FROM network_flow_map nfm
JOIN server_audit_full saf ON nfm.audit_id = saf.id
WHERE saf.id IN (
SELECT DISTINCT ON (hostname) id FROM server_audit_full
WHERE status IN ('ok','partial') ORDER BY hostname, audit_date DESC
)
AND (nfm.source_hostname = :srv OR nfm.dest_hostname = :srv)
AND nfm.source_hostname != nfm.dest_hostname
AND nfm.dest_hostname IS NOT NULL
GROUP BY source_hostname, source_ip, dest_ip, dest_port,
dest_hostname, process_name, direction, state
ORDER BY source_hostname
"""), {"srv": server_filter}).fetchall()
elif domain_filter:
# Flux pour un domaine ou une zone
# D'abord chercher comme zone
hostnames = [r.hostname for r in db.execute(text("""
SELECT s.hostname FROM servers s
JOIN zones z ON s.zone_id = z.id WHERE z.name = :name
"""), {"name": domain_filter}).fetchall()]
if not hostnames:
# Sinon comme domaine
hostnames = [r.hostname for r in db.execute(text("""
SELECT s.hostname FROM servers s
JOIN domain_environments de ON s.domain_env_id = de.id
JOIN domains d ON de.domain_id = d.id WHERE d.code = :dc
"""), {"dc": domain_filter}).fetchall()]
if hostnames:
flows = db.execute(text("""
SELECT source_hostname, source_ip, dest_ip, dest_port,
dest_hostname, process_name, direction, state,
COUNT(*) as cnt
FROM network_flow_map nfm
JOIN server_audit_full saf ON nfm.audit_id = saf.id
WHERE saf.id IN (
SELECT DISTINCT ON (hostname) id FROM server_audit_full
WHERE status IN ('ok','partial') ORDER BY hostname, audit_date DESC
)
AND (nfm.source_hostname = ANY(:hosts) OR nfm.dest_hostname = ANY(:hosts))
AND nfm.source_hostname != COALESCE(nfm.dest_hostname, '')
AND nfm.dest_hostname IS NOT NULL
GROUP BY source_hostname, source_ip, dest_ip, dest_port,
dest_hostname, process_name, direction, state
ORDER BY source_hostname
"""), {"hosts": hostnames}).fetchall()
else:
flows = []
else:
flows = get_flow_map(db)
app_map = get_app_map(db)
ctx = base_context(request, db, user)
ctx.update({
"app_name": APP_NAME, "flows": flows, "app_map": app_map,
"all_domains": all_domains, "all_zones": all_zones,
"audited_servers": audited_servers,
"domain_filter": domain_filter, "server_filter": server_filter,
})
return templates.TemplateResponse("audit_full_flowmap.html", ctx)
@router.get("/audit-full/{audit_id}", response_class=HTMLResponse)
async def audit_full_detail(request: Request, audit_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_view(perms, "audit"):
return RedirectResponse(url="/audit-full")
audit = get_audit_detail(db, audit_id)
if not audit:
return RedirectResponse(url="/audit-full")
def _j(val, default):
if val is None: return default
if isinstance(val, (list, dict)): return val
try: return json.loads(val)
except: return default
# Serveur partial (pas encore audite via SSH)
is_partial = (audit.status == "partial")
flows = [] if is_partial else get_flow_map_for_server(db, audit.hostname)
ctx = base_context(request, db, user)
ctx.update({
"app_name": APP_NAME, "a": audit, "flows": flows,
"is_partial": is_partial,
"services": _j(audit.services, []),
"processes": _j(audit.processes, []),
"listen_ports": _j(audit.listen_ports, []),
"connections": _j(audit.connections, []),
"flux_in": _j(audit.flux_in, []),
"flux_out": _j(audit.flux_out, []),
"disk_usage": _j(audit.disk_usage, []),
"interfaces": _j(audit.interfaces, []),
"correlation": _j(audit.correlation_matrix, []),
"outbound": _j(audit.outbound_only, []),
"firewall": _j(audit.firewall, {}),
"conn_wait": _j(audit.conn_wait, []),
"traffic": _j(audit.traffic, []),
})
return templates.TemplateResponse("audit_full_detail.html", ctx)

View File

@ -6,6 +6,7 @@ from ..dependencies import get_current_user
from ..database import SessionLocal, SessionLocalDemo
from ..auth import verify_password, create_access_token, hash_password
from ..services.audit_service import log_login, log_logout, log_login_failed
from ..services.ldap_service import is_enabled as ldap_enabled, authenticate as ldap_authenticate
from ..config import APP_NAME, APP_VERSION
router = APIRouter()
@ -13,41 +14,62 @@ templates = Jinja2Templates(directory="app/templates")
@router.get("/login", response_class=HTMLResponse)
async def login_page(request: Request):
db = SessionLocal()
try:
ldap_ok = ldap_enabled(db)
finally:
db.close()
return templates.TemplateResponse("login.html", {
"request": request, "app_name": APP_NAME, "version": APP_VERSION, "error": None
"request": request, "app_name": APP_NAME, "version": APP_VERSION,
"error": None, "ldap_enabled": ldap_ok,
})
@router.post("/login")
async def login(request: Request, username: str = Form(...), password: str = Form(...),
mode: str = Form("reel")):
mode: str = Form("reel"), auth_method: str = Form("local")):
# Select DB based on mode
factory = SessionLocalDemo if mode == "demo" else SessionLocal
db = factory()
try:
row = db.execute(text("SELECT id, username, password_hash, role, is_active FROM users WHERE LOWER(username) = LOWER(:u)"),
row = db.execute(text("SELECT id, username, password_hash, role, is_active, auth_type FROM users WHERE LOWER(username) = LOWER(:u)"),
{"u": username}).fetchone()
ldap_is_on = ldap_enabled(db)
err_template = lambda msg: templates.TemplateResponse("login.html", {
"request": request, "app_name": APP_NAME, "version": APP_VERSION,
"error": msg, "ldap_enabled": ldap_is_on
})
if not row:
log_login_failed(db, request, username)
db.commit()
return templates.TemplateResponse("login.html", {
"request": request, "app_name": APP_NAME, "version": APP_VERSION, "error": "Utilisateur inconnu"
})
return err_template("Utilisateur inconnu")
if not row.is_active:
log_login_failed(db, request, username)
db.commit()
return templates.TemplateResponse("login.html", {
"request": request, "app_name": APP_NAME, "version": APP_VERSION, "error": "Compte desactive"
})
try:
ok = verify_password(password, row.password_hash)
except Exception:
ok = False
if not ok:
log_login_failed(db, request, username)
db.commit()
return templates.TemplateResponse("login.html", {
"request": request, "app_name": APP_NAME, "version": APP_VERSION, "error": "Mot de passe incorrect"
})
return err_template("Compte desactive")
# Choix de la methode d'auth
use_ldap = (auth_method == "ldap") or (row.auth_type == "ldap")
if use_ldap and not ldap_is_on:
return err_template("LDAP non active")
if use_ldap:
result = ldap_authenticate(db, username, password)
if not result.get("ok"):
log_login_failed(db, request, username)
db.commit()
return err_template(result.get("msg") or "Authentification LDAP echouee")
ok = True
else:
try:
ok = verify_password(password, row.password_hash or "")
except Exception:
ok = False
if not ok:
log_login_failed(db, request, username)
db.commit()
return err_template("Mot de passe incorrect")
# Include mode in JWT token
token = create_access_token({"sub": row.username, "role": row.role, "uid": row.id, "mode": mode})
user = {"sub": row.username, "role": row.role, "uid": row.id, "mode": mode}

View File

@ -27,7 +27,7 @@ router = APIRouter()
templates = Jinja2Templates(directory="app/templates")
EXCLUSION_REASONS = [
("eol", "Fin de vie (EOL)"),
("obsolete", "Fin de vie (EOL)"),
("creneau_inadequat", "Creneau non adequat"),
("intervention_non_secops", "Intervention non-SecOps prevue"),
("report_cycle", "Report au cycle suivant"),

View File

@ -107,6 +107,7 @@ async def contacts_page(request: Request, db=Depends(get_db),
"SELECT DISTINCT app_type FROM server_specifics WHERE app_type IS NOT NULL ORDER BY app_type"
)).fetchall()
perms = get_user_perms(db, user)
ctx = base_context(request, db, user)
ctx.update({
"app_name": APP_NAME, "contacts": contacts,
@ -115,6 +116,7 @@ async def contacts_page(request: Request, db=Depends(get_db),
"domains": domains, "app_types": [r.app_type for r in app_types],
"search": search, "role_filter": role, "server": server, "server_info": server_info,
"msg": request.query_params.get("msg"),
"can_edit_contacts": can_edit(perms, "servers") or can_edit(perms, "contacts"),
})
return templates.TemplateResponse("contacts.html", ctx)

View File

@ -18,21 +18,21 @@ async def dashboard(request: Request, db=Depends(get_db)):
# Stats generales
stats = {}
stats["total_servers"] = db.execute(text("SELECT COUNT(*) FROM servers")).scalar()
stats["patchable"] = db.execute(text("SELECT COUNT(*) FROM servers WHERE patch_os_owner='secops' AND etat='en_production'")).scalar()
stats["patchable"] = db.execute(text("SELECT COUNT(*) FROM servers WHERE patch_os_owner='secops' AND etat='production'")).scalar()
stats["linux"] = db.execute(text("SELECT COUNT(*) FROM servers WHERE os_family='linux'")).scalar()
stats["windows"] = db.execute(text("SELECT COUNT(*) FROM servers WHERE os_family='windows'")).scalar()
stats["decom"] = db.execute(text("SELECT COUNT(*) FROM servers WHERE etat='decommissionne'")).scalar()
stats["eol"] = db.execute(text("SELECT COUNT(*) FROM servers WHERE licence_support='eol'")).scalar()
stats["decom"] = db.execute(text("SELECT COUNT(*) FROM servers WHERE etat='obsolete'")).scalar()
stats["obsolete"] = db.execute(text("SELECT COUNT(*) FROM servers WHERE licence_support='obsolete'")).scalar()
stats["qualys_assets"] = db.execute(text("SELECT COUNT(*) FROM qualys_assets")).scalar()
stats["qualys_tags"] = db.execute(text("SELECT COUNT(*) FROM qualys_tags")).scalar()
stats["qualys_active"] = db.execute(text("SELECT COUNT(*) FROM qualys_assets WHERE agent_status ILIKE '%active%' AND agent_status NOT ILIKE '%inactive%'")).scalar()
stats["qualys_inactive"] = db.execute(text("SELECT COUNT(*) FROM qualys_assets WHERE agent_status ILIKE '%inactive%'")).scalar()
stats["qualys_no_agent"] = db.execute(text("SELECT COUNT(*) FROM servers WHERE etat='en_production' AND NOT EXISTS (SELECT 1 FROM qualys_assets qa WHERE LOWER(qa.hostname) = LOWER(servers.hostname))")).scalar()
stats["qualys_no_agent"] = db.execute(text("SELECT COUNT(*) FROM servers WHERE etat='production' AND NOT EXISTS (SELECT 1 FROM qualys_assets qa WHERE LOWER(qa.hostname) = LOWER(servers.hostname))")).scalar()
# Par domaine
domains = db.execute(text("""
SELECT d.name, d.code, COUNT(s.id) as total,
COUNT(*) FILTER (WHERE s.etat='en_production') as actifs,
COUNT(*) FILTER (WHERE s.etat='production') as actifs,
COUNT(*) FILTER (WHERE s.os_family='linux') as linux,
COUNT(*) FILTER (WHERE s.os_family='windows') as windows
FROM servers s

679
app/routers/patching.py Normal file
View File

@ -0,0 +1,679 @@
"""Router Patching — exclusions, correspondance prod↔hors-prod, validations."""
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, base_context
from ..services import correspondance_service as corr
from ..config import APP_NAME
router = APIRouter()
templates = Jinja2Templates(directory="app/templates")
def _can_edit_excludes(perms):
"""Peut éditer les exclusions : admin, coordinator, operator (pas viewer)."""
return can_edit(perms, "servers") or can_edit(perms, "campaigns") or can_edit(perms, "quickwin")
@router.get("/patching/config-exclusions", response_class=HTMLResponse)
async def config_exclusions_page(request: Request, db=Depends(get_db),
search: str = Query(""),
domain: str = Query(""),
env: str = Query(""),
zone: str = Query(""),
tier: str = Query(""),
os: str = Query(""),
application: str = Query(""),
has_excludes: str = Query(""),
page: int = Query(1),
per_page: int = Query(30)):
user = get_current_user(request)
if not user:
return RedirectResponse(url="/login")
perms = get_user_perms(db, user)
if not _can_edit_excludes(perms):
return RedirectResponse(url="/dashboard")
# Requête principale
where = ["1=1"]
params = {}
if search:
where.append("s.hostname ILIKE :s"); params["s"] = f"%{search}%"
if domain:
where.append("d.code = :d"); params["d"] = domain
if env:
where.append("e.code = :e"); params["e"] = env
if zone:
where.append("z.name = :z"); params["z"] = zone
if tier:
where.append("s.tier = :t"); params["t"] = tier
if os:
where.append("s.os_family = :o"); params["o"] = os
if application:
where.append("s.application_name = :app"); params["app"] = application
if has_excludes == "yes":
where.append("s.patch_excludes IS NOT NULL AND s.patch_excludes != ''")
elif has_excludes == "no":
where.append("(s.patch_excludes IS NULL OR s.patch_excludes = '')")
wc = " AND ".join(where)
# Count
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
LEFT JOIN zones z ON s.zone_id = z.id
WHERE {wc}
"""), params).scalar() or 0
per_page = max(10, 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.os_version, s.tier, s.etat,
s.patch_excludes, s.application_name,
d.name as domain_name, d.code as domain_code,
e.name as env_name, e.code as env_code,
z.name as zone_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
LEFT JOIN zones z ON s.zone_id = z.id
WHERE {wc}
ORDER BY s.hostname
LIMIT :limit OFFSET :offset
"""), {**params, "limit": per_page, "offset": offset}).fetchall()
# Listes pour filtres
domains = db.execute(text("SELECT code, name FROM domains ORDER BY name")).fetchall()
envs = db.execute(text("SELECT code, name FROM environments ORDER BY name")).fetchall()
zones = db.execute(text("SELECT DISTINCT name FROM zones ORDER BY name")).fetchall()
applications = db.execute(text("""SELECT application_name, COUNT(*) as c FROM servers
WHERE application_name IS NOT NULL AND application_name != ''
GROUP BY application_name ORDER BY application_name""")).fetchall()
all_apps = db.execute(text("""SELECT id, nom_court FROM applications
WHERE itop_id IS NOT NULL ORDER BY nom_court""")).fetchall()
# Stats globales
stats = {
"total_servers": db.execute(text("SELECT COUNT(*) FROM servers")).scalar(),
"with_excludes": db.execute(text("SELECT COUNT(*) FROM servers WHERE patch_excludes IS NOT NULL AND patch_excludes != ''")).scalar(),
}
ctx = base_context(request, db, user)
ctx.update({
"app_name": APP_NAME,
"servers": rows, "total": total,
"page": page, "per_page": per_page, "total_pages": total_pages,
"filters": {"search": search, "domain": domain, "env": env,
"zone": zone, "tier": tier, "os": os,
"application": application, "has_excludes": has_excludes},
"domains": domains, "envs": envs, "zones": [z.name for z in zones],
"applications": applications, "all_apps": all_apps,
"stats": stats,
"msg": request.query_params.get("msg"),
})
return templates.TemplateResponse("patching_config_exclusions.html", ctx)
@router.post("/patching/config-exclusions/{server_id}/save")
async def save_server_excludes(request: Request, server_id: int, db=Depends(get_db),
patch_excludes: str = Form("")):
"""Enregistre les exclusions d'un serveur + push iTop."""
from fastapi.responses import JSONResponse
user = get_current_user(request)
if not user:
return JSONResponse({"ok": False, "msg": "Non authentifié"}, status_code=401)
perms = get_user_perms(db, user)
if not _can_edit_excludes(perms):
return JSONResponse({"ok": False, "msg": "Permission refusée"}, status_code=403)
# Normalise : split sur espaces/newlines, dédoublonne, rejoint avec un espace
parts = [p.strip() for p in patch_excludes.replace("\n", " ").replace("\t", " ").split() if p.strip()]
seen = set()
cleaned = []
for p in parts:
if p not in seen:
seen.add(p)
cleaned.append(p)
new_val = " ".join(cleaned)
srv = db.execute(text("SELECT id, hostname FROM servers WHERE id=:id"), {"id": server_id}).fetchone()
if not srv:
return JSONResponse({"ok": False, "msg": "Serveur introuvable"}, status_code=404)
# 1. Maj base locale
db.execute(text("UPDATE servers SET patch_excludes=:pe, updated_at=NOW() WHERE id=:id"),
{"pe": new_val, "id": server_id})
db.commit()
# 2. Push iTop immédiat (best effort)
itop_result = {"pushed": False, "msg": ""}
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)
r = client._call("core/get", **{"class": "VirtualMachine",
"key": f'SELECT VirtualMachine WHERE name = "{srv.hostname}"', "output_fields": "name"})
if r.get("objects"):
vm_id = list(r["objects"].values())[0]["key"]
upd = client.update("VirtualMachine", vm_id, {"patch_excludes": new_val})
if upd.get("code") == 0:
itop_result = {"pushed": True, "msg": "Poussé vers iTop"}
else:
itop_result = {"pushed": False, "msg": f"iTop: {upd.get('message','')[:80]}"}
except Exception as e:
itop_result = {"pushed": False, "msg": f"iTop error: {str(e)[:80]}"}
return JSONResponse({"ok": True, "patch_excludes": new_val, "itop": itop_result})
@router.post("/patching/config-exclusions/bulk")
async def bulk_update_excludes(request: Request, db=Depends(get_db)):
"""Bulk add/remove pattern sur plusieurs serveurs."""
from fastapi.responses import JSONResponse
user = get_current_user(request)
if not user:
return JSONResponse({"ok": False, "msg": "Non authentifié"}, status_code=401)
perms = get_user_perms(db, user)
if not _can_edit_excludes(perms):
return JSONResponse({"ok": False, "msg": "Permission refusée"}, status_code=403)
body = await request.json()
server_ids = body.get("server_ids", [])
pattern = (body.get("pattern") or "").strip()
action = body.get("action", "add") # "add" | "remove" | "replace"
if not server_ids or not pattern and action != "replace":
return JSONResponse({"ok": False, "msg": "Paramètres manquants"})
ids = [int(x) for x in server_ids if str(x).isdigit()]
if not ids:
return JSONResponse({"ok": False, "msg": "Aucun serveur valide"})
placeholders = ",".join(str(i) for i in ids)
rows = db.execute(text(f"SELECT id, hostname, patch_excludes FROM servers WHERE id IN ({placeholders})")).fetchall()
updated = 0
for r in rows:
current = (r.patch_excludes or "").strip()
parts = current.split() if current else []
if action == "add":
if pattern not in parts:
parts.append(pattern)
elif action == "remove":
parts = [p for p in parts if p != pattern]
elif action == "replace":
parts = pattern.split()
new_val = " ".join(parts)
if new_val != current:
db.execute(text("UPDATE servers SET patch_excludes=:pe, updated_at=NOW() WHERE id=:id"),
{"pe": new_val, "id": r.id})
updated += 1
db.commit()
# Push iTop en batch (best effort, async conceptually)
itop_pushed = 0
itop_errors = 0
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)
# Refresh après update
rows2 = db.execute(text(f"SELECT hostname, patch_excludes FROM servers WHERE id IN ({placeholders})")).fetchall()
for r in rows2:
resp = client._call("core/get", **{"class": "VirtualMachine",
"key": f'SELECT VirtualMachine WHERE name = "{r.hostname}"', "output_fields": "name"})
if resp.get("objects"):
vm_id = list(resp["objects"].values())[0]["key"]
up = client.update("VirtualMachine", vm_id, {"patch_excludes": r.patch_excludes or ""})
if up.get("code") == 0:
itop_pushed += 1
else:
itop_errors += 1
except Exception:
pass
return JSONResponse({"ok": True, "updated": updated, "itop_pushed": itop_pushed, "itop_errors": itop_errors})
@router.post("/patching/config-exclusions/bulk-application")
async def bulk_update_application(request: Request, db=Depends(get_db)):
"""Bulk changement de solution applicative sur plusieurs serveurs."""
from fastapi.responses import JSONResponse
user = get_current_user(request)
if not user:
return JSONResponse({"ok": False, "msg": "Non authentifié"}, status_code=401)
perms = get_user_perms(db, user)
if not _can_edit_excludes(perms):
return JSONResponse({"ok": False, "msg": "Permission refusée"}, status_code=403)
body = await request.json()
server_ids = body.get("server_ids", [])
application_id = body.get("application_id") # int ou None/"" pour désassocier
ids = [int(x) for x in server_ids if str(x).isdigit()]
if not ids:
return JSONResponse({"ok": False, "msg": "Aucun serveur"})
app_id_val = None
app_itop_id = None
app_name = None
if application_id and str(application_id).strip().isdigit():
app_id_val = int(application_id)
row = db.execute(text("SELECT itop_id, nom_court FROM applications WHERE id=:id"),
{"id": app_id_val}).fetchone()
if row:
app_itop_id = row.itop_id
app_name = row.nom_court
else:
return JSONResponse({"ok": False, "msg": "Application introuvable"})
placeholders = ",".join(str(i) for i in ids)
db.execute(text(f"""UPDATE servers SET application_id=:aid, application_name=:an, updated_at=NOW()
WHERE id IN ({placeholders})"""), {"aid": app_id_val, "an": app_name})
db.commit()
updated = len(ids)
# Push iTop
itop_pushed = 0
itop_errors = 0
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)}] if app_itop_id else []
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"]
up = client.update("VirtualMachine", vm_id, {"applicationsolution_list": new_list})
if up.get("code") == 0:
itop_pushed += 1
else:
itop_errors += 1
except Exception:
itop_errors += 1
except Exception:
pass
return JSONResponse({"ok": True, "updated": updated, "itop_pushed": itop_pushed, "itop_errors": itop_errors,
"app_name": app_name or "(aucune)"})
# ═══════════════════════════════════════════════════════
# Correspondance prod ↔ hors-prod
# ═══════════════════════════════════════════════════════
@router.get("/patching/correspondance", response_class=HTMLResponse)
async def correspondance_page(request: Request, db=Depends(get_db),
search: str = Query(""), application: str = Query(""),
domain: str = Query(""), env: str = Query("")):
user = get_current_user(request)
if not user:
return RedirectResponse(url="/login")
perms = get_user_perms(db, user)
if not _can_edit_excludes(perms) and not can_view(perms, "campaigns"):
return RedirectResponse(url="/dashboard")
servers = corr.get_servers_for_builder(db, search=search, app=application,
domain=domain, env=env)
applications = db.execute(text("""SELECT DISTINCT application_name FROM servers
WHERE application_name IS NOT NULL AND application_name != ''
ORDER BY application_name""")).fetchall()
envs = db.execute(text("SELECT DISTINCT name FROM environments ORDER BY name")).fetchall()
domains = db.execute(text("SELECT DISTINCT name FROM domains ORDER BY name")).fetchall()
all_apps = db.execute(text("""SELECT id, nom_court FROM applications
WHERE itop_id IS NOT NULL ORDER BY nom_court""")).fetchall()
# Stats globales
stats = {
"total_links": db.execute(text("SELECT COUNT(*) FROM server_correspondance")).scalar() or 0,
"filtered": len(servers),
}
ctx = base_context(request, db, user)
ctx.update({"app_name": APP_NAME, "servers": servers, "stats": stats,
"applications": applications,
"envs": [e.name for e in envs],
"domains": [d.name for d in domains],
"all_apps": all_apps,
"search": search, "application": application,
"domain": domain, "env": env,
"can_edit": _can_edit_excludes(perms),
"msg": request.query_params.get("msg", "")})
return templates.TemplateResponse("patching_correspondance.html", ctx)
@router.post("/patching/correspondance/bulk-env")
async def correspondance_bulk_env(request: Request, db=Depends(get_db)):
"""Change l'environnement réel de N serveurs (PatchCenter + push iTop)."""
user = get_current_user(request)
if not user:
return JSONResponse({"ok": False}, status_code=401)
perms = get_user_perms(db, user)
if not _can_edit_excludes(perms):
return JSONResponse({"ok": False, "msg": "Permission refusée"}, status_code=403)
body = await request.json()
server_ids = [int(x) for x in body.get("server_ids", []) if str(x).isdigit()]
env_name = (body.get("env_name") or "").strip()
if not server_ids or not env_name:
return JSONResponse({"ok": False, "msg": "Paramètres manquants"})
# Trouver env_id
env_row = db.execute(text("SELECT id FROM environments WHERE name=:n"), {"n": env_name}).fetchone()
if not env_row:
return JSONResponse({"ok": False, "msg": f"Env '{env_name}' introuvable"})
env_id = env_row.id
placeholders = ",".join(str(i) for i in server_ids)
# Pour chaque serveur : trouver/créer le domain_env correspondant et l'affecter
updated = 0
srvs = db.execute(text(f"""SELECT s.id, s.hostname, s.domain_env_id, de.domain_id
FROM servers s LEFT JOIN domain_environments de ON s.domain_env_id = de.id
WHERE s.id IN ({placeholders})""")).fetchall()
for s in srvs:
if not s.domain_id:
continue # pas de domaine actuel, skip
# Trouver ou créer domain_environments(domain_id, env_id)
de = db.execute(text("""SELECT id FROM domain_environments
WHERE domain_id=:d AND environment_id=:e"""),
{"d": s.domain_id, "e": env_id}).fetchone()
if not de:
db.execute(text("""INSERT INTO domain_environments (domain_id, environment_id)
VALUES (:d, :e)"""), {"d": s.domain_id, "e": env_id})
db.commit()
de = db.execute(text("""SELECT id FROM domain_environments
WHERE domain_id=:d AND environment_id=:e"""),
{"d": s.domain_id, "e": env_id}).fetchone()
db.execute(text("UPDATE servers SET domain_env_id=:de_id, updated_at=NOW() WHERE id=:sid"),
{"de_id": de.id, "sid": s.id})
updated += 1
db.commit()
# Push iTop
itop_pushed = 0
itop_errors = 0
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 s in srvs:
try:
rr = client._call("core/get", **{"class": "VirtualMachine",
"key": f'SELECT VirtualMachine WHERE name = "{s.hostname}"', "output_fields": "name"})
if rr.get("objects"):
vm_id = list(rr["objects"].values())[0]["key"]
upd = client.update("VirtualMachine", vm_id, {
"environnement_id": f"SELECT Environnement WHERE name = '{env_name}'"
})
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": updated,
"itop_pushed": itop_pushed, "itop_errors": itop_errors,
"env_name": env_name})
@router.post("/patching/correspondance/bulk-application")
async def correspondance_bulk_app(request: Request, db=Depends(get_db)):
"""Change la solution applicative de N serveurs (PatchCenter + push iTop)."""
user = get_current_user(request)
if not user:
return JSONResponse({"ok": False}, status_code=401)
perms = get_user_perms(db, user)
if not _can_edit_excludes(perms):
return JSONResponse({"ok": False, "msg": "Permission refusée"}, status_code=403)
body = await request.json()
server_ids = [int(x) for x in body.get("server_ids", []) if str(x).isdigit()]
application_id = body.get("application_id")
if not server_ids:
return JSONResponse({"ok": False, "msg": "Aucun serveur"})
app_id_val = None
app_itop_id = None
app_name = None
if application_id and str(application_id).strip().isdigit():
app_id_val = int(application_id)
row = db.execute(text("SELECT itop_id, nom_court FROM applications WHERE id=:id"),
{"id": app_id_val}).fetchone()
if row:
app_itop_id = row.itop_id
app_name = row.nom_court
else:
return JSONResponse({"ok": False, "msg": "Application introuvable"})
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_val, "an": app_name})
db.commit()
itop_pushed = 0
itop_errors = 0
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)}] if app_itop_id else []
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"]
up = client.update("VirtualMachine", vm_id, {"applicationsolution_list": new_list})
if up.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_name or "(aucune)"})
@router.post("/patching/correspondance/bulk-create")
async def correspondance_bulk_create(request: Request, db=Depends(get_db)):
user = get_current_user(request)
if not user:
return JSONResponse({"ok": False}, status_code=401)
perms = get_user_perms(db, user)
if not _can_edit_excludes(perms):
return JSONResponse({"ok": False, "msg": "Permission refusée"}, status_code=403)
body = await request.json()
prod_ids = [int(x) for x in body.get("prod_ids", []) if str(x).isdigit()]
nonprod_ids = [int(x) for x in body.get("nonprod_ids", []) if str(x).isdigit()]
env_labels = body.get("env_labels", {})
if not prod_ids or not nonprod_ids:
return JSONResponse({"ok": False, "msg": "Au moins 1 prod et 1 non-prod requis"})
r = corr.bulk_create_correspondance(db, prod_ids, nonprod_ids, env_labels, user.get("uid"))
return JSONResponse({"ok": True, **r})
@router.post("/patching/correspondance/auto-detect")
async def correspondance_auto_detect(request: Request, db=Depends(get_db)):
user = get_current_user(request)
if not user:
return JSONResponse({"ok": False}, status_code=401)
perms = get_user_perms(db, user)
if not _can_edit_excludes(perms):
return JSONResponse({"ok": False, "msg": "Permission refusée"}, status_code=403)
stats = corr.detect_correspondances(db)
return JSONResponse({"ok": True, **{k: v for k, v in stats.items() if k != "plan"}})
@router.post("/patching/correspondance/link")
async def correspondance_link(request: Request, db=Depends(get_db),
prod_id: int = Form(...), nonprod_id: int = Form(...),
env_code: str = Form(""), note: str = Form("")):
user = get_current_user(request)
if not user:
return JSONResponse({"ok": False}, status_code=401)
perms = get_user_perms(db, user)
if not _can_edit_excludes(perms):
return JSONResponse({"ok": False, "msg": "Permission refusée"}, status_code=403)
corr.create_manual_link(db, prod_id, nonprod_id, env_code, note, user.get("uid"))
return JSONResponse({"ok": True})
@router.post("/patching/correspondance/link-by-host")
async def correspondance_link_by_host(request: Request, db=Depends(get_db),
prod_id: int = Form(...),
nonprod_hostname: str = Form(...),
env_code: str = Form(""), note: str = Form("")):
user = get_current_user(request)
if not user:
return JSONResponse({"ok": False}, status_code=401)
perms = get_user_perms(db, user)
if not _can_edit_excludes(perms):
return JSONResponse({"ok": False, "msg": "Permission refusée"}, status_code=403)
row = db.execute(text("SELECT id FROM servers WHERE LOWER(hostname)=LOWER(:h)"),
{"h": nonprod_hostname.strip()}).fetchone()
if not row:
return JSONResponse({"ok": False, "msg": f"Serveur '{nonprod_hostname}' introuvable"})
corr.create_manual_link(db, prod_id, row.id, env_code, note, user.get("uid"))
return JSONResponse({"ok": True})
@router.post("/patching/correspondance/{corr_id}/delete")
async def correspondance_delete(request: Request, corr_id: int, db=Depends(get_db)):
user = get_current_user(request)
if not user:
return JSONResponse({"ok": False}, status_code=401)
perms = get_user_perms(db, user)
if not _can_edit_excludes(perms):
return JSONResponse({"ok": False, "msg": "Permission refusée"}, status_code=403)
corr.delete_link(db, corr_id)
return JSONResponse({"ok": True})
# ═══════════════════════════════════════════════════════
# Validations post-patching
# ═══════════════════════════════════════════════════════
@router.get("/patching/validations", response_class=HTMLResponse)
async def validations_page(request: Request, db=Depends(get_db),
status: str = Query("en_attente"),
campaign_id: int = Query(None),
env: str = Query("")):
user = get_current_user(request)
if not user:
return RedirectResponse(url="/login")
perms = get_user_perms(db, user)
if not _can_edit_excludes(perms):
return RedirectResponse(url="/dashboard")
validations = corr.get_pending_validations(db, env=env, campaign_id=campaign_id, status=status)
# Contacts validateurs (responsables + référents)
contacts = db.execute(text("""SELECT id, name, email, role, team
FROM contacts WHERE is_active=true
AND role IN ('responsable_applicatif','responsable_domaine','referent_technique','ra_prod','ra_test')
ORDER BY name""")).fetchall()
stats = {
"en_attente": db.execute(text("SELECT COUNT(*) FROM patch_validation WHERE status='en_attente'")).scalar() or 0,
"validated_ok": db.execute(text("SELECT COUNT(*) FROM patch_validation WHERE status='validated_ok'")).scalar() or 0,
"validated_ko": db.execute(text("SELECT COUNT(*) FROM patch_validation WHERE status='validated_ko'")).scalar() or 0,
"forced": db.execute(text("SELECT COUNT(*) FROM patch_validation WHERE status='forced'")).scalar() or 0,
}
envs = db.execute(text("SELECT DISTINCT name FROM environments ORDER BY name")).fetchall()
ctx = base_context(request, db, user)
ctx.update({"app_name": APP_NAME, "validations": validations, "contacts": contacts,
"stats": stats, "status": status, "campaign_id": campaign_id, "env": env,
"envs": [e.name for e in envs],
"can_force": can_edit(perms, "campaigns") or user.get("role") == "admin",
"msg": request.query_params.get("msg", "")})
return templates.TemplateResponse("patching_validations.html", ctx)
@router.post("/patching/validations/mark")
async def validations_mark(request: Request, db=Depends(get_db)):
user = get_current_user(request)
if not user:
return JSONResponse({"ok": False}, status_code=401)
perms = get_user_perms(db, user)
if not _can_edit_excludes(perms):
return JSONResponse({"ok": False, "msg": "Permission refusée"}, status_code=403)
body = await request.json()
ids = body.get("validation_ids", [])
status = body.get("status", "validated_ok")
contact_id = body.get("contact_id")
forced_reason = body.get("forced_reason", "")
notes = body.get("notes", "")
if status not in ("validated_ok", "validated_ko", "forced"):
return JSONResponse({"ok": False, "msg": "Status invalide"})
if status == "forced" and not forced_reason.strip():
return JSONResponse({"ok": False, "msg": "Raison obligatoire pour forcer"})
if status in ("validated_ok", "validated_ko") and not contact_id:
return JSONResponse({"ok": False, "msg": "Validateur obligatoire"})
validator_name = None
if contact_id:
row = db.execute(text("SELECT name FROM contacts WHERE id=:id"),
{"id": int(contact_id)}).fetchone()
if row:
validator_name = row.name
n = corr.mark_validation(db, ids, status, contact_id, validator_name,
forced_reason, notes, user.get("uid"))
return JSONResponse({"ok": True, "updated": n})
@router.get("/patching/validations/history/{server_id}", response_class=HTMLResponse)
async def validations_history(request: Request, server_id: int, db=Depends(get_db)):
user = get_current_user(request)
if not user:
return RedirectResponse(url="/login")
srv = db.execute(text("SELECT id, hostname FROM servers WHERE id=:id"), {"id": server_id}).fetchone()
if not srv:
return HTMLResponse("Serveur introuvable", status_code=404)
history = corr.get_validation_history(db, server_id)
ctx = base_context(request, db, user)
ctx.update({"app_name": APP_NAME, "server": srv, "history": history})
return templates.TemplateResponse("patching_validations_history.html", ctx)

View File

@ -40,7 +40,7 @@ def _get_planning_data(db, year):
SELECT d.code, d.name, COUNT(s.id) as srv_count
FROM domains d
LEFT JOIN domain_environments de ON de.domain_id = d.id
LEFT JOIN servers s ON s.domain_env_id = de.id AND s.etat = 'en_production'
LEFT JOIN servers s ON s.domain_env_id = de.id AND s.etat = 'production'
GROUP BY d.code, d.name, d.display_order
ORDER BY d.display_order
""")).fetchall()

View File

@ -519,20 +519,20 @@ async def qualys_agents_page(request: Request, db=Depends(get_db)):
@router.post("/qualys/agents/refresh")
async def qualys_agents_refresh(request: Request, db=Depends(get_db)):
from fastapi.responses import JSONResponse
user = get_current_user(request)
if not user:
return RedirectResponse(url="/login")
return JSONResponse({"ok": False, "msg": "Non authentifié"}, status_code=401)
perms = get_user_perms(db, user)
if not can_edit(perms, "qualys"):
return RedirectResponse(url="/qualys/agents")
return JSONResponse({"ok": False, "msg": "Permission refusée"}, status_code=403)
from ..services.qualys_service import refresh_all_agents
try:
stats = refresh_all_agents(db)
msg = f"refresh_ok_{stats.get('created',0)}_{stats.get('updated',0)}"
return JSONResponse({"ok": True, "msg": f"{stats.get('created',0)} créés, {stats.get('updated',0)} mis à jour", "stats": stats})
except Exception as e:
import traceback; traceback.print_exc()
msg = "refresh_error"
return RedirectResponse(url=f"/qualys/agents?msg={msg}", status_code=303)
return JSONResponse({"ok": False, "msg": str(e)[:200]}, status_code=500)
@router.get("/qualys/agents/export-no-agent")
@ -872,15 +872,20 @@ async def qualys_deploy_page(request: Request, db=Depends(get_db)):
packages = list_packages()
servers = db.execute(text("""
SELECT s.id, s.hostname, s.os_family, s.etat, s.ssh_user, s.ssh_port, s.ssh_method,
d.name as domain, e.name as env
SELECT s.id, s.hostname, s.os_family, s.os_version, s.etat, s.ssh_user, s.ssh_port, s.ssh_method,
d.name as domain, e.name as env,
qa.agent_version, qa.agent_status, qa.last_checkin
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
LEFT JOIN qualys_assets qa ON qa.server_id = s.id
ORDER BY s.hostname
""")).fetchall()
servers = [dict(r._mapping) for r in servers]
for s in servers:
if s.get("last_checkin"):
s["last_checkin"] = str(s["last_checkin"])[:19]
ctx = base_context(request, db, user)
ctx.update({
@ -896,106 +901,96 @@ async def qualys_deploy_page(request: Request, db=Depends(get_db)):
@router.post("/qualys/deploy/run")
async def qualys_deploy_run(request: Request, db=Depends(get_db),
server_ids: str = Form(""),
activation_id: str = Form(""),
customer_id: str = Form(""),
server_uri: str = Form(""),
package_deb: str = Form(""),
package_rpm: str = Form("")):
async def qualys_deploy_run(request: Request, db=Depends(get_db)):
from fastapi.responses import JSONResponse
user = get_current_user(request)
if not user:
return RedirectResponse(url="/login")
return JSONResponse({"ok": False, "msg": "Non authentifié"}, status_code=401)
perms = get_user_perms(db, user)
if not can_edit(perms, "qualys"):
return RedirectResponse(url="/dashboard")
return JSONResponse({"ok": False, "msg": "Permission refusée"}, status_code=403)
from ..services.agent_deploy_service import deploy_agent
from ..services.agent_deploy_service import start_deploy_job
from ..services.secrets_service import get_secret
ids = [int(x) for x in server_ids.split(",") if x.strip().isdigit()]
body = await request.json()
server_ids = body.get("server_ids", "")
activation_id = body.get("activation_id", "")
customer_id = body.get("customer_id", "")
server_uri = body.get("server_uri", "")
package_deb = body.get("package_deb", "")
package_rpm = body.get("package_rpm", "")
force_downgrade = body.get("force_downgrade", False)
ids = [int(x) for x in str(server_ids).split(",") if x.strip().isdigit()]
if not ids:
return RedirectResponse(url="/qualys/deploy?msg=no_servers", status_code=303)
return JSONResponse({"ok": False, "msg": "Aucun serveur sélectionné"})
ssh_key = get_secret(db, "ssh_key_file") or "/opt/patchcenter/keys/id_ed25519"
# Get servers
placeholders = ",".join(str(i) for i in ids)
servers = db.execute(text(f"""
SELECT id, hostname, os_family, ssh_user, ssh_port, ssh_method
rows = db.execute(text(f"""
SELECT id, hostname, os_family, os_version, ssh_user, ssh_port, ssh_method
FROM servers WHERE id IN ({placeholders})
""")).fetchall()
servers = [{"hostname": r.hostname, "os_family": r.os_family,
"os_version": r.os_version, "ssh_user": r.ssh_user, "ssh_port": r.ssh_port} for r in rows]
results = []
log_lines = []
job_id = start_deploy_job(servers, ssh_key, package_deb, package_rpm,
activation_id, customer_id, server_uri, force_downgrade=force_downgrade)
for srv in servers:
# Choose package based on OS
if "debian" in (srv.os_family or "").lower() or srv.os_family == "linux":
pkg = package_deb
else:
pkg = package_rpm
if not pkg or not os.path.exists(pkg):
results.append({"hostname": srv.hostname, "status": "FAILED", "detail": f"Package introuvable: {pkg}"})
continue
def on_line(msg, h=srv.hostname):
log_lines.append(f"[{h}] {msg}")
result = deploy_agent(
hostname=srv.hostname,
ssh_user=srv.ssh_user or "root",
ssh_key_path=ssh_key,
ssh_port=srv.ssh_port or 22,
os_family=srv.os_family,
package_path=pkg,
activation_id=activation_id,
customer_id=customer_id,
server_uri=server_uri,
on_line=on_line,
)
results.append(result)
# Save to app state for display
request.app.state.last_deploy_results = results
request.app.state.last_deploy_log = log_lines
# Audit log
from ..services.audit_service import log_action
ok = sum(1 for r in results if r["status"] in ("SUCCESS", "ALREADY_INSTALLED"))
fail = sum(1 for r in results if r["status"] not in ("SUCCESS", "ALREADY_INSTALLED"))
log_action(db, request, user, "qualys_deploy", f"{ok} OK, {fail} fail sur {len(results)} serveurs")
log_action(db, request, user, "qualys_deploy",
entity_type="deploy_job",
details={"job_id": job_id, "servers": len(servers)})
db.commit()
ctx = base_context(request, db, user)
ctx.update({
"app_name": APP_NAME,
"results": results,
"log_lines": log_lines,
"total": len(results),
"ok": ok,
"failed": fail,
return JSONResponse({"ok": True, "job_id": job_id, "total": len(servers)})
@router.get("/qualys/deploy/status/{job_id}")
async def qualys_deploy_status(request: Request, job_id: str, db=Depends(get_db)):
from fastapi.responses import JSONResponse
user = get_current_user(request)
if not user:
return JSONResponse({"ok": False, "msg": "Non authentifié"}, status_code=401)
from ..services.agent_deploy_service import get_deploy_job
job = get_deploy_job(job_id)
if not job:
return JSONResponse({"ok": False, "msg": "Job introuvable"}, status_code=404)
import time
elapsed = int(time.time() - job["started_at"])
return JSONResponse({
"ok": True,
"job_id": job_id,
"total": job["total"],
"done": job["done"],
"finished": job["finished"],
"elapsed": elapsed,
"servers": job["servers"],
"log": job["log"][-50:], # dernières 50 lignes
})
return templates.TemplateResponse("qualys_deploy_results.html", ctx)
@router.post("/qualys/deploy/check")
async def qualys_deploy_check(request: Request, db=Depends(get_db),
server_ids: str = Form("")):
async def qualys_deploy_check(request: Request, db=Depends(get_db)):
from fastapi.responses import JSONResponse
user = get_current_user(request)
if not user:
return RedirectResponse(url="/login")
return JSONResponse({"ok": False, "msg": "Non authentifié"}, status_code=401)
perms = get_user_perms(db, user)
if not can_view(perms, "qualys"):
return RedirectResponse(url="/dashboard")
return JSONResponse({"ok": False, "msg": "Permission refusée"}, status_code=403)
from ..services.agent_deploy_service import check_agent
from ..services.secrets_service import get_secret
ids = [int(x) for x in server_ids.split(",") if x.strip().isdigit()]
body = await request.json()
server_ids = body.get("server_ids", "")
ids = [int(x) for x in str(server_ids).split(",") if x.strip().isdigit()]
if not ids:
return RedirectResponse(url="/qualys/deploy?msg=no_servers", status_code=303)
return JSONResponse({"ok": False, "msg": "Aucun serveur sélectionné"})
ssh_key = get_secret(db, "ssh_key_file") or "/opt/patchcenter/keys/id_ed25519"
placeholders = ",".join(str(i) for i in ids)
@ -1006,13 +1001,7 @@ async def qualys_deploy_check(request: Request, db=Depends(get_db),
r = check_agent(srv.hostname, srv.ssh_user or "root", ssh_key, srv.ssh_port or 22)
results.append(r)
ctx = base_context(request, db, user)
ctx.update({
"app_name": APP_NAME,
"results": results,
"total": len(results),
"active": sum(1 for r in results if r["status"] == "ACTIVE"),
"not_installed": sum(1 for r in results if r["status"] == "NOT_INSTALLED"),
"failed": sum(1 for r in results if r["status"] == "CONNECTION_FAILED"),
})
return templates.TemplateResponse("qualys_deploy_results.html", ctx)
return JSONResponse({"ok": True, "results": results, "total": len(results),
"active": sum(1 for r in results if r["status"] == "ACTIVE"),
"not_installed": sum(1 for r in results if r["status"] == "NOT_INSTALLED"),
"failed": sum(1 for r in results if r["status"] == "CONNECTION_FAILED")})

View File

@ -10,7 +10,7 @@ from ..services.quickwin_service import (
get_server_configs, upsert_server_config, delete_server_config,
get_eligible_servers, list_runs, get_run, get_run_entries,
create_run, delete_run, update_entry_field,
can_start_prod, get_run_stats, inject_yum_history,
can_start_prod, check_prod_validations, get_run_stats, inject_yum_history,
advance_run_status, get_step_stats, mark_snapshot, mark_all_snapshots,
build_yum_commands, get_available_servers, get_available_filters,
add_entries_to_run, remove_entries_from_run,
@ -55,53 +55,32 @@ async def quickwin_page(request: Request, db=Depends(get_db)):
# -- Config exclusions par serveur --
DEFAULT_REBOOT_PACKAGES = (
"kernel* glibc* systemd* dbus* polkit* linux-firmware* microcode_ctl* "
"tuned* dracut* grub2* kexec-tools* libselinux* selinux-policy* shim* "
"mokutil* net-snmp* NetworkManager* network-scripts* nss* openssl-libs*"
)
@router.get("/quickwin/config", response_class=HTMLResponse)
async def quickwin_config_page(request: Request, db=Depends(get_db),
page: int = Query(1),
per_page: int = Query(14),
search: str = Query(""),
env: str = Query(""),
domain: str = Query(""),
zone: str = Query("")):
async def quickwin_config_page(request: Request, db=Depends(get_db)):
"""Page d'édition de la liste globale des packages qui nécessitent un reboot.
Cette liste est utilisée par QuickWin (en plus des exclusions iTop par serveur)."""
user = get_current_user(request)
if not user:
return RedirectResponse(url="/login")
perms = get_user_perms(db, user)
if not can_view(perms, "campaigns") and not can_view(perms, "quickwin"):
if not can_edit(perms, "campaigns") and not can_edit(perms, "quickwin"):
return RedirectResponse(url="/dashboard")
configs = get_server_configs(db)
# Filtres
filtered = configs
if search:
filtered = [s for s in filtered if search.lower() in s.hostname.lower()]
if env:
filtered = [s for s in filtered if s.environnement == env]
if domain:
filtered = [s for s in filtered if s.domaine == domain]
if zone:
filtered = [s for s in filtered if (s.zone or '') == zone]
# Pagination
per_page = max(5, min(per_page, 100))
total = len(filtered)
total_pages = max(1, (total + per_page - 1) // per_page)
page = max(1, min(page, total_pages))
start = (page - 1) * per_page
page_servers = filtered[start:start + per_page]
from ..services.secrets_service import get_secret
current = get_secret(db, "patching_reboot_packages") or DEFAULT_REBOOT_PACKAGES
ctx = base_context(request, db, user)
ctx.update({
"app_name": APP_NAME,
"all_servers": page_servers,
"all_configs": configs,
"default_excludes": DEFAULT_GENERAL_EXCLUDES,
"total_count": total,
"page": page,
"per_page": per_page,
"total_pages": total_pages,
"filters": {"search": search, "env": env, "domain": domain, "zone": zone},
"reboot_packages": current,
"default_packages": DEFAULT_REBOOT_PACKAGES,
"msg": request.query_params.get("msg"),
})
return templates.TemplateResponse("quickwin_config.html", ctx)
@ -109,44 +88,20 @@ async def quickwin_config_page(request: Request, db=Depends(get_db),
@router.post("/quickwin/config/save")
async def quickwin_config_save(request: Request, db=Depends(get_db),
server_id: int = Form(0),
general_excludes: str = Form(""),
specific_excludes: str = Form(""),
notes: str = Form("")):
reboot_packages: str = Form("")):
"""Sauvegarde la liste globale des packages nécessitant un reboot."""
user = get_current_user(request)
if not user:
return RedirectResponse(url="/login")
if server_id:
upsert_server_config(db, server_id, general_excludes.strip(),
specific_excludes.strip(), notes.strip())
perms = get_user_perms(db, user)
if not can_edit(perms, "campaigns") and not can_edit(perms, "quickwin"):
return RedirectResponse(url="/dashboard")
from ..services.secrets_service import set_secret
set_secret(db, "patching_reboot_packages", reboot_packages.strip(),
"Packages nécessitant un reboot (QuickWin)")
return RedirectResponse(url="/quickwin/config?msg=saved", status_code=303)
@router.post("/quickwin/config/delete")
async def quickwin_config_delete(request: Request, db=Depends(get_db),
config_id: int = Form(0)):
user = get_current_user(request)
if not user:
return RedirectResponse(url="/login")
if config_id:
delete_server_config(db, config_id)
return RedirectResponse(url="/quickwin/config?msg=deleted", status_code=303)
@router.post("/quickwin/config/bulk-add")
async def quickwin_config_bulk_add(request: Request, db=Depends(get_db),
server_ids: str = Form(""),
general_excludes: str = Form("")):
"""Ajouter plusieurs serveurs d'un coup avec les memes exclusions generales"""
user = get_current_user(request)
if not user:
return RedirectResponse(url="/login")
ids = [int(x) for x in server_ids.split(",") if x.strip().isdigit()]
for sid in ids:
upsert_server_config(db, sid, general_excludes.strip(), "", "")
return RedirectResponse(url=f"/quickwin/config?msg=added_{len(ids)}", status_code=303)
# -- Runs QuickWin --
@router.post("/quickwin/create")
@ -168,7 +123,7 @@ async def quickwin_create(request: Request, db=Depends(get_db),
ids = [int(x) for x in server_ids.split(",") if x.strip().isdigit()]
if not ids:
# Prendre tous les serveurs eligibles (linux, en_production, secops)
# Prendre tous les serveurs eligibles (linux, production, secops)
eligible = get_eligible_servers(db)
ids = [s.id for s in eligible]
@ -189,6 +144,9 @@ async def quickwin_correspondance_redirect(request: Request, db=Depends(get_db))
user = get_current_user(request)
if not user:
return RedirectResponse(url="/login")
perms = get_user_perms(db, user)
if not can_edit(perms, "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")
@ -221,6 +179,7 @@ async def quickwin_detail(request: Request, run_id: int, db=Depends(get_db),
entries = get_run_entries(db, run_id)
stats = get_run_stats(db, run_id)
prod_ok = can_start_prod(db, run_id)
validations_ok, validations_blockers = check_prod_validations(db, run_id)
step_stats_hp = get_step_stats(db, run_id, "hprod")
step_stats_pr = get_step_stats(db, run_id, "prod")
@ -279,6 +238,7 @@ async def quickwin_detail(request: Request, run_id: int, db=Depends(get_db),
"p_page": p_page, "p_total_pages": p_total_pages,
"per_page": per_page,
"prod_ok": prod_ok,
"validations_ok": validations_ok, "validations_blockers": validations_blockers,
"step_hp": step_stats_hp, "step_pr": step_stats_pr,
"scope": scope,
"filters": {"search": search, "status": status, "domain": domain,

View File

@ -1,262 +0,0 @@
"""Router Safe Patching — Quick Win campagnes + SSE terminal live"""
import asyncio, json
from datetime import datetime
from fastapi import APIRouter, Request, Depends, Query, Form
from fastapi.responses import HTMLResponse, RedirectResponse, StreamingResponse
from fastapi.templating import Jinja2Templates
from sqlalchemy import text
from ..dependencies import get_db, get_current_user, get_user_perms, can_view, can_edit, base_context
from ..services.safe_patching_service import (
create_quickwin_campaign, get_quickwin_stats, build_yum_command, build_safe_excludes,
)
from ..services.campaign_service import get_campaign, get_campaign_sessions, get_campaign_stats
from ..services.patching_executor import get_stream, start_execution, emit
from ..config import APP_NAME, DATABASE_URL
router = APIRouter()
templates = Jinja2Templates(directory="app/templates")
@router.get("/safe-patching", response_class=HTMLResponse)
async def safe_patching_page(request: Request, db=Depends(get_db)):
user = get_current_user(request)
if not user:
return RedirectResponse(url="/login")
perms = get_user_perms(db, user)
if not can_view(perms, "campaigns"):
return RedirectResponse(url="/dashboard")
# Campagnes quickwin existantes
campaigns = db.execute(text("""
SELECT c.*, u.display_name as created_by_name,
(SELECT COUNT(*) FROM patch_sessions ps WHERE ps.campaign_id = c.id) as session_count,
(SELECT COUNT(*) FROM patch_sessions ps WHERE ps.campaign_id = c.id AND ps.status = 'patched') as patched_count
FROM campaigns c LEFT JOIN users u ON c.created_by = u.id
WHERE c.campaign_type = 'quickwin'
ORDER BY c.year DESC, c.week_code DESC
""")).fetchall()
# Intervenants pour le formulaire
operators = db.execute(text(
"SELECT id, display_name FROM users WHERE is_active = true AND role = 'operator' ORDER BY display_name"
)).fetchall()
now = datetime.now()
current_week = now.isocalendar()[1]
current_year = now.isocalendar()[0]
ctx = base_context(request, db, user)
ctx.update({
"app_name": APP_NAME, "campaigns": campaigns,
"operators": operators,
"current_week": current_week, "current_year": current_year,
"can_create": can_edit(perms, "campaigns"),
"msg": request.query_params.get("msg"),
})
return templates.TemplateResponse("safe_patching.html", ctx)
@router.post("/safe-patching/create")
async def safe_patching_create(request: Request, db=Depends(get_db),
label: str = Form(""), week_number: str = Form("0"),
year: str = Form("0"), lead_id: str = Form("0"),
assistant_id: str = Form("")):
user = get_current_user(request)
if not user:
return RedirectResponse(url="/login")
perms = get_user_perms(db, user)
if not can_edit(perms, "campaigns"):
return RedirectResponse(url="/safe-patching")
wn = int(week_number) if week_number else 0
yr = int(year) if year else 0
lid = int(lead_id) if lead_id else 0
aid = int(assistant_id) if assistant_id.strip() else None
if not label:
label = f"Quick Win S{wn:02d} {yr}"
try:
cid = create_quickwin_campaign(db, yr, wn, label, lid, aid)
return RedirectResponse(url=f"/safe-patching/{cid}", status_code=303)
except Exception:
db.rollback()
return RedirectResponse(url="/safe-patching?msg=error", status_code=303)
@router.get("/safe-patching/{campaign_id}", response_class=HTMLResponse)
async def safe_patching_detail(request: Request, campaign_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_view(perms, "campaigns"):
return RedirectResponse(url="/dashboard")
campaign = get_campaign(db, campaign_id)
if not campaign:
return RedirectResponse(url="/safe-patching")
sessions = get_campaign_sessions(db, campaign_id)
stats = get_campaign_stats(db, campaign_id)
qw_stats = get_quickwin_stats(db, campaign_id)
# Séparer hprod et prod
hprod = [s for s in sessions if s.environnement != 'Production' and s.status != 'excluded']
prod = [s for s in sessions if s.environnement == 'Production' and s.status != 'excluded']
excluded = [s for s in sessions if s.status == 'excluded']
# Commande safe patching
safe_cmd = build_yum_command()
safe_excludes = build_safe_excludes()
# Déterminer le step courant
if qw_stats.hprod_patched > 0 or qw_stats.prod_patched > 0:
current_step = "postcheck"
elif any(s.prereq_validated for s in sessions if s.status == 'pending'):
current_step = "execute"
else:
current_step = "prereqs"
ctx = base_context(request, db, user)
ctx.update({
"app_name": APP_NAME, "c": campaign,
"sessions": sessions, "stats": stats, "qw_stats": qw_stats,
"hprod": hprod, "prod": prod, "excluded": excluded,
"safe_cmd": safe_cmd, "safe_excludes": safe_excludes,
"current_step": request.query_params.get("step", current_step),
"msg": request.query_params.get("msg"),
})
return templates.TemplateResponse("safe_patching_detail.html", ctx)
@router.post("/safe-patching/{campaign_id}/delete")
async def safe_patching_delete(request: Request, campaign_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 perms.get("campaigns") != "admin":
return RedirectResponse(url="/safe-patching", status_code=303)
db.execute(text("DELETE FROM campaign_operator_limits WHERE campaign_id = :cid"), {"cid": campaign_id})
db.execute(text("DELETE FROM patch_sessions WHERE campaign_id = :cid"), {"cid": campaign_id})
db.execute(text("DELETE FROM campaigns WHERE id = :cid"), {"cid": campaign_id})
db.commit()
return RedirectResponse(url="/safe-patching?msg=deleted", status_code=303)
@router.post("/safe-patching/{campaign_id}/check-prereqs")
async def safe_patching_check_prereqs(request: Request, campaign_id: int, db=Depends(get_db),
branch: str = Form("hprod")):
user = get_current_user(request)
if not user:
return RedirectResponse(url="/login")
perms = get_user_perms(db, user)
if not can_edit(perms, "campaigns"):
return RedirectResponse(url=f"/safe-patching/{campaign_id}")
from ..services.prereq_service import check_prereqs_campaign
checked, auto_excluded = check_prereqs_campaign(db, campaign_id)
return RedirectResponse(url=f"/safe-patching/{campaign_id}?step=prereqs&msg=prereqs_done", status_code=303)
@router.post("/safe-patching/{campaign_id}/bulk-exclude")
async def safe_patching_bulk_exclude(request: Request, campaign_id: int, db=Depends(get_db),
session_ids: str = Form("")):
user = get_current_user(request)
if not user:
return RedirectResponse(url="/login")
perms = get_user_perms(db, user)
if not can_edit(perms, "campaigns"):
return RedirectResponse(url=f"/safe-patching/{campaign_id}")
from ..services.campaign_service import exclude_session
ids = [int(x) for x in session_ids.split(",") if x.strip().isdigit()]
for sid in ids:
exclude_session(db, sid, "autre", "Exclu du Quick Win", user.get("sub"))
return RedirectResponse(url=f"/safe-patching/{campaign_id}?msg=excluded_{len(ids)}", status_code=303)
@router.post("/safe-patching/{campaign_id}/execute")
async def safe_patching_execute(request: Request, campaign_id: int, db=Depends(get_db),
branch: str = Form("hprod")):
"""Lance l'exécution du safe patching pour une branche"""
user = get_current_user(request)
if not user:
return RedirectResponse(url="/login")
perms = get_user_perms(db, user)
if not can_edit(perms, "campaigns"):
return RedirectResponse(url=f"/safe-patching/{campaign_id}")
# Récupérer les sessions pending de la branche
if branch == "hprod":
sessions = db.execute(text("""
SELECT ps.id FROM patch_sessions ps
JOIN servers s ON ps.server_id = s.id
LEFT JOIN domain_environments de ON s.domain_env_id = de.id
LEFT JOIN environments e ON de.environment_id = e.id
WHERE ps.campaign_id = :cid AND ps.status = 'pending' AND e.name != 'Production'
ORDER BY s.hostname
"""), {"cid": campaign_id}).fetchall()
else:
sessions = db.execute(text("""
SELECT ps.id FROM patch_sessions ps
JOIN servers s ON ps.server_id = s.id
LEFT JOIN domain_environments de ON s.domain_env_id = de.id
LEFT JOIN environments e ON de.environment_id = e.id
WHERE ps.campaign_id = :cid AND ps.status = 'pending' AND e.name = 'Production'
ORDER BY s.hostname
"""), {"cid": campaign_id}).fetchall()
session_ids = [s.id for s in sessions]
if not session_ids:
return RedirectResponse(url=f"/safe-patching/{campaign_id}?msg=no_pending", status_code=303)
# Passer la campagne en in_progress
db.execute(text("UPDATE campaigns SET status = 'in_progress' WHERE id = :cid"), {"cid": campaign_id})
db.commit()
# Lancer en background
start_execution(DATABASE_URL, campaign_id, session_ids, branch)
return RedirectResponse(url=f"/safe-patching/{campaign_id}/terminal?branch={branch}", status_code=303)
@router.get("/safe-patching/{campaign_id}/terminal", response_class=HTMLResponse)
async def safe_patching_terminal(request: Request, campaign_id: int, db=Depends(get_db),
branch: str = Query("hprod")):
"""Page terminal live"""
user = get_current_user(request)
if not user:
return RedirectResponse(url="/login")
perms = get_user_perms(db, user)
if not can_view(perms, "campaigns"):
return RedirectResponse(url="/safe-patching")
campaign = get_campaign(db, campaign_id)
ctx = base_context(request, db, user)
ctx.update({"app_name": APP_NAME, "c": campaign, "branch": branch})
return templates.TemplateResponse("safe_patching_terminal.html", ctx)
@router.get("/safe-patching/{campaign_id}/stream")
async def safe_patching_stream(request: Request, campaign_id: int, db=Depends(get_db)):
"""SSE endpoint — stream les logs en temps réel"""
user = get_current_user(request)
if not user:
return StreamingResponse(iter([]), media_type="text/event-stream")
async def event_generator():
q = get_stream(campaign_id)
while True:
try:
msg = q.get(timeout=0.5)
data = json.dumps(msg)
yield f"data: {data}\n\n"
if msg.get("level") == "done":
break
except Exception:
yield f": keepalive\n\n"
await asyncio.sleep(0.3)
return StreamingResponse(
event_generator(),
media_type="text/event-stream",
headers={"Cache-Control": "no-cache", "X-Accel-Buffering": "no"}
)

View File

@ -2,6 +2,7 @@
from fastapi import APIRouter, Request, Depends, Query, Form
from fastapi.responses import HTMLResponse, RedirectResponse, StreamingResponse
from fastapi.templating import Jinja2Templates
from sqlalchemy import text
from ..dependencies import get_db, get_current_user
from ..services.server_service import (
get_server_full, get_server_tags, get_server_ips,
@ -19,21 +20,38 @@ 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),
search: str = Query(None), page: int = Query(1),
sort: str = Query("hostname"), sort_dir: str = Query("asc")):
user = get_current_user(request)
if not user:
return RedirectResponse(url="/login")
filters = {"domain": domain, "env": env, "tier": tier, "etat": etat, "os": os, "owner": owner, "search": search}
filters = {"domain": domain, "env": env, "tier": tier, "etat": etat, "os": os,
"owner": owner, "application": application, "search": search}
servers, total = list_servers(db, filters, page, sort=sort, sort_dir=sort_dir)
domains_list, envs_list = get_reference_data(db)
applications_list = db.execute(text("""SELECT application_name, COUNT(*) as c FROM servers
WHERE application_name IS NOT NULL AND application_name != ''
GROUP BY application_name ORDER BY application_name""")).fetchall()
from ..dependencies import get_user_perms, can_edit
perms = get_user_perms(db, user)
can_edit_servers = can_edit(perms, "servers")
# Correspondances en bulk pour la page en cours
from ..services.correspondance_service import get_links_bulk
links = get_links_bulk(db, [s.id for s in servers])
return templates.TemplateResponse("servers.html", {
"request": request, "user": user, "app_name": APP_NAME,
"servers": servers, "total": total, "page": page, "per_page": 50,
"domains_list": domains_list, "envs_list": envs_list, "filters": filters,
"domains_list": domains_list, "envs_list": envs_list,
"applications_list": applications_list, "filters": filters,
"sort": sort, "sort_dir": sort_dir,
"perms": perms, "can_edit_servers": can_edit_servers,
"links": links,
})
@ -78,8 +96,10 @@ async def server_detail(request: Request, server_id: int, db=Depends(get_db)):
return HTMLResponse("<p>Serveur non trouve</p>")
tags = get_server_tags(db, s.qid)
ips = get_server_ips(db, server_id)
from ..services.correspondance_service import get_server_links
links = get_server_links(db, server_id)
return templates.TemplateResponse("partials/server_detail.html", {
"request": request, "s": s, "tags": tags, "ips": ips
"request": request, "s": s, "tags": tags, "ips": ips, "links": links
})
@ -100,10 +120,14 @@ async def server_edit(request: Request, server_id: int, db=Depends(get_db)):
zones_list = db.execute(sqlt(
"SELECT name FROM zones ORDER BY name"
)).fetchall()
applications = db.execute(sqlt(
"SELECT id, nom_court FROM applications WHERE itop_id IS NOT NULL ORDER BY nom_court"
)).fetchall()
return templates.TemplateResponse("partials/server_edit.html", {
"request": request, "s": s, "domains": domains, "envs": envs, "ips": ips,
"dns_list": [r.name for r in dns_list],
"zones_list": [r.name for r in zones_list],
"applications": applications,
})
@ -116,11 +140,15 @@ async def server_update(request: Request, server_id: int, db=Depends(get_db),
commentaire: str = Form(None),
ip_reelle: str = Form(None), ip_connexion: str = Form(None),
ssh_method: str = Form(None), domain_ltd: str = Form(None),
pref_patch_jour: str = Form(None), pref_patch_heure: str = Form(None)):
pref_patch_jour: str = Form(None), pref_patch_heure: str = Form(None),
application_id: str = Form(None)):
user = get_current_user(request)
if not user:
return HTMLResponse("<p>Non autorise</p>")
from ..dependencies import get_user_perms, can_edit
if not can_edit(get_user_perms(db, user), "servers"):
return HTMLResponse("<p>Permission refusee</p>", status_code=403)
data = {
"domain_code": domain_code, "env_code": env_code, "zone": zone,
@ -133,11 +161,47 @@ async def server_update(request: Request, server_id: int, db=Depends(get_db),
}
update_server(db, server_id, data, user.get("sub"))
# Application (changement manuel SecOps) — update + push iTop
if application_id is not None:
app_id_val = int(application_id) if application_id and application_id.strip().isdigit() else None
app_itop_id = None
app_name = None
if app_id_val:
row = db.execute(text("SELECT itop_id, nom_court FROM applications WHERE id=:id"),
{"id": app_id_val}).fetchone()
if row:
app_itop_id = row.itop_id
app_name = row.nom_court
db.execute(text("""UPDATE servers SET application_id=:aid, application_name=:an, updated_at=NOW()
WHERE id=:sid"""), {"aid": app_id_val, "an": app_name, "sid": server_id})
db.commit()
# Push iTop (best effort)
try:
from ..services.itop_service import ITopClient
from ..services.secrets_service import get_secret
srv_row = db.execute(text("SELECT hostname FROM servers WHERE id=:id"), {"id": server_id}).fetchone()
if srv_row:
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)
r = client._call("core/get", **{"class": "VirtualMachine",
"key": f'SELECT VirtualMachine WHERE name = "{srv_row.hostname}"', "output_fields": "name"})
if r.get("objects"):
vm_id = list(r["objects"].values())[0]["key"]
new_list = [{"applicationsolution_id": int(app_itop_id)}] if app_itop_id else []
client.update("VirtualMachine", vm_id, {"applicationsolution_list": new_list})
except Exception:
pass
s = get_server_full(db, server_id)
tags = get_server_tags(db, s.qid)
ips = get_server_ips(db, server_id)
from ..services.correspondance_service import get_server_links
links = get_server_links(db, server_id)
return templates.TemplateResponse("partials/server_detail.html", {
"request": request, "s": s, "tags": tags, "ips": ips
"request": request, "s": s, "tags": tags, "ips": ips, "links": links
})
@ -148,6 +212,9 @@ async def servers_bulk(request: Request, db=Depends(get_db),
user = get_current_user(request)
if not user:
return RedirectResponse(url="/login")
from ..dependencies import get_user_perms, can_edit
if not can_edit(get_user_perms(db, user), "servers"):
return RedirectResponse(url="/servers?msg=forbidden", status_code=303)
if not server_ids or not bulk_field or not bulk_value:
return RedirectResponse(url="/servers", status_code=303)
@ -203,7 +270,9 @@ async def server_sync_qualys(request: Request, server_id: int, db=Depends(get_db
s = get_server_full(db, server_id)
tags = get_server_tags(db, s.qid) if s else []
ips = get_server_ips(db, server_id)
from ..services.correspondance_service import get_server_links
links = get_server_links(db, server_id) if s else {"as_prod": [], "as_nonprod": []}
return templates.TemplateResponse("partials/server_detail.html", {
"request": request, "s": s, "tags": tags, "ips": ips,
"request": request, "s": s, "tags": tags, "ips": ips, "links": links,
"sync_msg": result.get("msg"), "sync_ok": result.get("ok"),
})

View File

@ -75,6 +75,20 @@ SECTIONS = {
("teams_sp_client_secret", "App Client Secret", True),
("teams_sp_tenant_id", "Tenant ID", False),
],
"ldap": [
("ldap_enabled", "Activer LDAP/AD (true/false)", False),
("ldap_server", "Serveur (ex: ldaps://ad.sanef.com:636)", False),
("ldap_base_dn", "Base DN (ex: DC=sanef,DC=com)", False),
("ldap_bind_dn", "Compte de bind (DN complet)", False),
("ldap_bind_pwd", "Mot de passe compte de bind", True),
("ldap_user_filter", "Filtre user (ex: (sAMAccountName={username}))", False),
("ldap_email_attr", "Attribut email (ex: mail)", False),
("ldap_name_attr", "Attribut nom affiché (ex: displayName)", False),
("ldap_tls", "TLS (true/false)", False),
],
"itop_contacts": [
("itop_contact_teams", "Teams iTop à synchroniser (séparées par ,)", False),
],
}
@ -102,6 +116,8 @@ SECTION_ACCESS = {
"splunk": {"visible": ["admin", "coordinator"], "editable": ["admin", "coordinator"]},
"teams": {"visible": ["admin", "coordinator"], "editable": ["admin", "coordinator"]},
"itop": {"visible": ["admin"], "editable": ["admin"]},
"itop_contacts": {"visible": ["admin"], "editable": ["admin"]},
"ldap": {"visible": ["admin"], "editable": ["admin"]},
"security": {"visible": ["admin"], "editable": ["admin"]},
}
@ -173,6 +189,20 @@ async def settings_save(request: Request, section: str, db=Depends(get_db)):
return templates.TemplateResponse("settings.html", ctx)
@router.post("/settings/ldap/test")
async def settings_ldap_test(request: Request, db=Depends(get_db)):
"""Teste la connexion LDAP avec le compte de bind."""
from fastapi.responses import JSONResponse
user = get_current_user(request)
if not user:
return JSONResponse({"ok": False, "msg": "Non authentifié"}, status_code=401)
perms = get_user_perms(db, user)
if not can_edit(perms, "settings"):
return JSONResponse({"ok": False, "msg": "Permission refusée"}, status_code=403)
from ..services.ldap_service import test_connection
return JSONResponse(test_connection(db))
# --- vCenter CRUD ---
@router.post("/settings/vcenter/add", response_class=HTMLResponse)

View File

@ -1,180 +1,273 @@
"""Router users — gestion utilisateurs + permissions par module"""
"""Router users — gestion utilisateurs par profil + picker de contacts iTop.
Seul l'admin peut gérer les utilisateurs.
Les utilisateurs sont créés depuis les contacts synchronisés d'iTop (sauf admin local).
"""
from fastapi import APIRouter, Request, Depends, Form
from fastapi.responses import HTMLResponse, RedirectResponse
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 ..auth import hash_password
from ..services.profile_service import PROFILES, PROFILE_LABELS, PROFILE_DESCRIPTIONS
from ..config import APP_NAME
router = APIRouter()
templates = Jinja2Templates(directory="app/templates")
MODULES = ["servers", "campaigns", "qualys", "audit", "settings", "users", "planning", "specifics"]
LEVELS = ["view", "edit", "admin"]
# Profils disponibles (keys du PROFILES dict)
ROLES = ["admin", "coordinator", "operator", "viewer"]
def _get_users_with_perms(db):
users = db.execute(text(
"SELECT id, username, display_name, email, role, auth_type, is_active, last_login FROM users ORDER BY username"
)).fetchall()
result = []
for u in users:
perms = {}
rows = db.execute(text(
"SELECT module, level FROM user_permissions WHERE user_id = :uid"
), {"uid": u.id}).fetchall()
for r in rows:
perms[r.module] = r.level
result.append({"user": u, "perms": perms})
return result
def _get_users(db):
users = db.execute(text("""
SELECT u.id, u.username, u.display_name, u.email, u.role, u.auth_type,
u.is_active, u.last_login, u.itop_person_id, u.force_password_change,
c.team as contact_team, c.function as contact_function
FROM users u
LEFT JOIN contacts c ON c.itop_id = u.itop_person_id
ORDER BY u.is_active DESC, u.username
""")).fetchall()
return users
def _check_access(request, db):
def _check_access(request, db, require_edit=False):
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, "users"):
return None, None, RedirectResponse(url="/dashboard")
if require_edit and not can_edit(perms, "users"):
return None, None, RedirectResponse(url="/users?msg=forbidden", status_code=303)
return user, perms, None
@router.get("/me/change-password", response_class=HTMLResponse)
async def me_change_password_page(request: Request, db=Depends(get_db)):
user = get_current_user(request)
if not user:
return RedirectResponse(url="/login")
return templates.TemplateResponse("change_password.html", {
"request": request, "app_name": APP_NAME, "user": user,
"error": None, "perms": {},
})
@router.post("/me/change-password")
async def me_change_password(request: Request, db=Depends(get_db),
current_password: str = Form(...),
new_password: str = Form(...),
confirm_password: str = Form(...)):
user = get_current_user(request)
if not user:
return RedirectResponse(url="/login")
from ..auth import verify_password
row = db.execute(text("SELECT id, password_hash FROM users WHERE id=:uid"),
{"uid": user.get("uid")}).fetchone()
if not row:
return RedirectResponse(url="/logout", status_code=303)
err = None
if not verify_password(current_password, row.password_hash or ""):
err = "Mot de passe actuel incorrect"
elif new_password != confirm_password:
err = "Les deux mots de passe ne correspondent pas"
elif len(new_password) < 8:
err = "Le nouveau mot de passe doit faire au moins 8 caractères"
elif new_password == current_password:
err = "Le nouveau mot de passe doit être différent de l'actuel"
if err:
return templates.TemplateResponse("change_password.html", {
"request": request, "app_name": APP_NAME, "user": user,
"error": err, "perms": {},
})
pw_hash = hash_password(new_password)
db.execute(text("""UPDATE users SET password_hash=:ph, force_password_change=false,
updated_at=NOW() WHERE id=:uid"""),
{"ph": pw_hash, "uid": user.get("uid")})
db.commit()
return RedirectResponse(url="/dashboard?msg=password_changed", status_code=303)
@router.get("/users", response_class=HTMLResponse)
async def users_page(request: Request, db=Depends(get_db)):
user, perms, redirect = _check_access(request, db)
if redirect:
return redirect
users_data = _get_users_with_perms(db)
users = _get_users(db)
# Compter les contacts disponibles pour ajout (pas encore users)
available_count = db.execute(text("""
SELECT COUNT(*) FROM contacts c
WHERE c.itop_id IS NOT NULL AND c.is_active = true
AND NOT EXISTS (SELECT 1 FROM users u WHERE u.itop_person_id = c.itop_id)
""")).scalar()
ctx = base_context(request, db, user)
ctx.update({
"app_name": APP_NAME, "users_data": users_data,
"modules": MODULES, "levels": LEVELS,
"app_name": APP_NAME, "users": users,
"available_count": available_count,
"roles": ROLES, "profile_labels": PROFILE_LABELS,
"profile_descriptions": PROFILE_DESCRIPTIONS,
"can_edit_users": can_edit(perms, "users"),
"can_admin_users": can_admin(perms, "users"),
"msg": request.query_params.get("msg"),
})
return templates.TemplateResponse("users.html", ctx)
@router.post("/users/add")
async def user_add(request: Request, db=Depends(get_db),
new_username: str = Form(...), new_display_name: str = Form(...),
new_email: str = Form(""), new_password: str = Form(...),
new_role: str = Form("operator")):
user, perms, redirect = _check_access(request, db)
@router.get("/users/add", response_class=HTMLResponse)
async def users_add_page(request: Request, db=Depends(get_db),
search: str = "", team: str = ""):
user, perms, redirect = _check_access(request, db, require_edit=True)
if redirect:
return redirect
if not can_edit(perms, "users"):
return RedirectResponse(url="/users?msg=forbidden", status_code=303)
# Verifier si username existe deja
existing = db.execute(text("SELECT id, is_active FROM users WHERE LOWER(username) = LOWER(:u)"),
{"u": new_username.strip()}).fetchone()
# Lister les contacts iTop non-encore-users
where = ["c.itop_id IS NOT NULL", "c.is_active = true",
"NOT EXISTS (SELECT 1 FROM users u WHERE u.itop_person_id = c.itop_id)"]
params = {}
if search:
where.append("(c.name ILIKE :s OR c.email ILIKE :s)")
params["s"] = f"%{search}%"
if team:
where.append("c.team = :t")
params["t"] = team
wc = " AND ".join(where)
contacts = db.execute(text(f"""
SELECT c.id, c.itop_id, c.name, c.email, c.telephone, c.team, c.function, c.role
FROM contacts c WHERE {wc}
ORDER BY c.team, c.name LIMIT 200
"""), params).fetchall()
# Liste des teams disponibles pour filtrer
teams = db.execute(text("""
SELECT DISTINCT team FROM contacts WHERE team IS NOT NULL AND team != ''
ORDER BY team
""")).fetchall()
ctx = base_context(request, db, user)
ctx.update({
"app_name": APP_NAME, "contacts": contacts, "teams": teams,
"roles": ROLES, "profile_labels": PROFILE_LABELS,
"profile_descriptions": PROFILE_DESCRIPTIONS,
"search": search, "team_filter": team,
})
return templates.TemplateResponse("users_add.html", ctx)
@router.post("/users/add")
async def users_add(request: Request, db=Depends(get_db),
contact_id: str = Form(""), role: str = Form("viewer"),
username: str = Form(""), password: str = Form(""),
auth_type: str = Form("local"),
force_change: str = Form("")):
user, perms, redirect = _check_access(request, db, require_edit=True)
if redirect:
return redirect
if role not in ROLES:
return RedirectResponse(url="/users?msg=invalid_role", status_code=303)
# Récupérer le contact iTop
contact = None
if contact_id and contact_id.strip().isdigit():
contact = db.execute(text(
"SELECT id, itop_id, name, email FROM contacts WHERE id = :cid"
), {"cid": int(contact_id)}).fetchone()
if not contact:
return RedirectResponse(url="/users/add?msg=contact_required", status_code=303)
# Générer username si non fourni (email avant @)
if not username.strip():
username = contact.email.split("@")[0] if contact.email else contact.name.lower().replace(" ", ".")
# Verifier duplicats
existing = db.execute(text("SELECT id FROM users WHERE LOWER(username)=LOWER(:u) OR itop_person_id=:iid"),
{"u": username.strip(), "iid": contact.itop_id}).fetchone()
if existing:
if not existing.is_active:
return RedirectResponse(url=f"/users?msg=exists_inactive", status_code=303)
return RedirectResponse(url=f"/users?msg=exists", status_code=303)
return RedirectResponse(url="/users?msg=exists", status_code=303)
pw_hash = hash_password(password) if password else hash_password("ChangeMe!" + str(contact.itop_id))
fpc = True if force_change == "on" else (not password)
pw_hash = hash_password(new_password)
db.execute(text("""
INSERT INTO users (username, display_name, email, password_hash, role)
VALUES (:u, :dn, :e, :ph, :r)
"""), {"u": new_username.strip(), "dn": new_display_name, "e": new_email or None,
"ph": pw_hash, "r": new_role})
row = db.execute(text("SELECT id FROM users WHERE username = :u"), {"u": new_username.strip()}).fetchone()
if row:
default_perms = {
"admin": {m: "admin" for m in MODULES},
"coordinator": {"servers": "admin", "campaigns": "admin", "qualys": "admin", "audit": "admin",
"settings": "view", "users": "view", "planning": "admin", "specifics": "admin"},
"operator": {"servers": "admin", "campaigns": "view", "qualys": "admin", "audit": "admin",
"settings": "view", "planning": "view", "specifics": "admin"},
"viewer": {"servers": "view", "campaigns": "view", "audit": "view", "planning": "view"},
}
for mod, lvl in default_perms.get(new_role, {}).items():
db.execute(text(
"INSERT INTO user_permissions (user_id, module, level) VALUES (:uid, :m, :l) ON CONFLICT DO NOTHING"
), {"uid": row.id, "m": mod, "l": lvl})
INSERT INTO users (username, display_name, email, password_hash, role,
auth_type, itop_person_id, is_active, force_password_change)
VALUES (:u, :dn, :e, :ph, :r, :at, :iid, true, :fpc)
"""), {
"u": username.strip(), "dn": contact.name, "e": contact.email,
"ph": pw_hash, "r": role, "at": auth_type,
"iid": contact.itop_id, "fpc": fpc,
})
db.commit()
return RedirectResponse(url="/users?msg=added", status_code=303)
@router.post("/users/{user_id}/permissions")
async def user_permissions_save(request: Request, user_id: int, db=Depends(get_db)):
user, perms, redirect = _check_access(request, db)
@router.post("/users/{user_id}/role")
async def user_change_role(request: Request, user_id: int, db=Depends(get_db),
role: str = Form(...)):
user, perms, redirect = _check_access(request, db, require_edit=True)
if redirect:
return redirect
if not can_edit(perms, "users"):
return RedirectResponse(url="/users?msg=forbidden", status_code=303)
form = await request.form()
db.execute(text("DELETE FROM user_permissions WHERE user_id = :uid"), {"uid": user_id})
for mod in MODULES:
lvl = form.get(f"perm_{mod}", "")
if lvl and lvl in LEVELS:
db.execute(text(
"INSERT INTO user_permissions (user_id, module, level) VALUES (:uid, :m, :l)"
), {"uid": user_id, "m": mod, "l": lvl})
if role not in ROLES:
return RedirectResponse(url="/users?msg=invalid_role", status_code=303)
# Empêche un admin de se rétrograder lui-même
if user_id == user.get("uid") and role != "admin":
return RedirectResponse(url="/users?msg=cant_demote_self", status_code=303)
db.execute(text("UPDATE users SET role=:r, updated_at=NOW() WHERE id=:id"),
{"r": role, "id": user_id})
db.commit()
return RedirectResponse(url=f"/users?msg=perms_saved", status_code=303)
@router.post("/users/{user_id}/edit")
async def user_edit(request: Request, user_id: int, db=Depends(get_db),
display_name: str = Form(""), email: str = Form(""),
role: str = Form("")):
user, perms, redirect = _check_access(request, db)
if redirect:
return redirect
if not can_edit(perms, "users"):
return RedirectResponse(url="/users?msg=forbidden", status_code=303)
updates = []
params = {"id": user_id}
if display_name:
updates.append("display_name = :dn"); params["dn"] = display_name
if email:
updates.append("email = :em"); params["em"] = email
if role:
updates.append("role = :r"); params["r"] = role
if updates:
db.execute(text(f"UPDATE users SET {', '.join(updates)} WHERE id = :id"), params)
db.commit()
return RedirectResponse(url="/users?msg=edited", status_code=303)
return RedirectResponse(url="/users?msg=role_changed", status_code=303)
@router.post("/users/{user_id}/toggle")
async def user_toggle(request: Request, user_id: int, db=Depends(get_db)):
user, perms, redirect = _check_access(request, db)
user, perms, redirect = _check_access(request, db, require_edit=True)
if redirect:
return redirect
if not can_edit(perms, "users"):
return RedirectResponse(url="/users?msg=forbidden", status_code=303)
# Empecher de se desactiver soi-meme
if user_id == user.get("uid"):
return RedirectResponse(url="/users?msg=cant_self", status_code=303)
db.execute(text("UPDATE users SET is_active = NOT is_active WHERE id = :id"), {"id": user_id})
db.execute(text("UPDATE users SET is_active = NOT is_active WHERE id=:id"), {"id": user_id})
db.commit()
return RedirectResponse(url="/users?msg=toggled", status_code=303)
@router.post("/users/{user_id}/password")
async def user_password(request: Request, user_id: int, db=Depends(get_db),
new_password: str = Form(...)):
user, perms, redirect = _check_access(request, db)
new_password: str = Form(...),
force_change: str = Form("")):
user, perms, redirect = _check_access(request, db, require_edit=True)
if redirect:
return redirect
if not can_edit(perms, "users"):
return RedirectResponse(url="/users?msg=forbidden", status_code=303)
pw_hash = hash_password(new_password)
db.execute(text("UPDATE users SET password_hash = :ph WHERE id = :id"),
{"ph": pw_hash, "id": user_id})
fpc = (force_change == "on")
db.execute(text("""UPDATE users SET password_hash=:ph, force_password_change=:fpc,
updated_at=NOW() WHERE id=:id"""),
{"ph": pw_hash, "fpc": fpc, "id": user_id})
db.commit()
return RedirectResponse(url="/users?msg=password_changed", status_code=303)
@router.post("/users/{user_id}/auth_type")
async def user_auth_type(request: Request, user_id: int, db=Depends(get_db),
auth_type: str = Form(...)):
user, perms, redirect = _check_access(request, db, require_edit=True)
if redirect:
return redirect
if auth_type not in ("local", "ldap"):
return RedirectResponse(url="/users?msg=invalid", status_code=303)
db.execute(text("UPDATE users SET auth_type=:at, updated_at=NOW() WHERE id=:id"),
{"at": auth_type, "id": user_id})
db.commit()
return RedirectResponse(url="/users?msg=auth_changed", status_code=303)
@router.post("/users/{user_id}/delete")
async def user_delete(request: Request, user_id: int, db=Depends(get_db)):
user, perms, redirect = _check_access(request, db)
@ -184,7 +277,11 @@ async def user_delete(request: Request, user_id: int, db=Depends(get_db)):
return RedirectResponse(url="/users?msg=forbidden", status_code=303)
if user_id == user.get("uid"):
return RedirectResponse(url="/users?msg=cant_self", status_code=303)
db.execute(text("DELETE FROM user_permissions WHERE user_id = :uid"), {"uid": user_id})
db.execute(text("DELETE FROM users WHERE id = :id"), {"id": user_id})
# Protéger l'admin local (id=1)
row = db.execute(text("SELECT username, auth_type FROM users WHERE id=:id"), {"id": user_id}).fetchone()
if row and row.username == "admin" and row.auth_type == "local":
return RedirectResponse(url="/users?msg=cant_delete_admin", status_code=303)
db.execute(text("DELETE FROM user_permissions WHERE user_id=:uid"), {"uid": user_id})
db.execute(text("DELETE FROM users WHERE id=:id"), {"id": user_id})
db.commit()
return RedirectResponse(url="/users?msg=deleted", status_code=303)

View File

@ -80,11 +80,21 @@ def check_agent(hostname, ssh_user, ssh_key_path, ssh_port=22):
result = {"hostname": hostname}
# Check if installed
code, out, _ = _run_cmd(client, "which qualys-cloud-agent 2>/dev/null || rpm -q qualys-cloud-agent 2>/dev/null || dpkg -l qualys-cloud-agent 2>/dev/null | grep '^ii'")
if code != 0 and not out.strip():
# Check if installed (rpm -q returns 0 only if installed, dpkg -s returns 0 only if installed)
installed = False
code, out, _ = _run_cmd(client, "rpm -q qualys-cloud-agent 2>/dev/null")
if code == 0 and "not installed" not in out.lower() and "n'est pas install" not in out.lower():
installed = True
else:
code, out, _ = _run_cmd(client, "dpkg -s qualys-cloud-agent 2>/dev/null")
if code == 0 and "install ok installed" in out.lower():
installed = True
if not installed:
result["status"] = "NOT_INSTALLED"
result["detail"] = "Agent non installe"
result["version"] = ""
result["service_status"] = ""
client.close()
return result
@ -94,9 +104,8 @@ def check_agent(hostname, ssh_user, ssh_key_path, ssh_port=22):
result["service_status"] = status
# Get version via package manager
code, out, _ = _run_cmd(client, "rpm -q qualys-cloud-agent 2>/dev/null || dpkg -l qualys-cloud-agent 2>/dev/null | grep '^ii' | awk '{print $3}'")
code, out, _ = _run_cmd(client, "rpm -q --qf '%{VERSION}-%{RELEASE}' qualys-cloud-agent 2>/dev/null || dpkg-query -W -f='${Version}' qualys-cloud-agent 2>/dev/null")
version = out.strip()
# Extract just version number
m = re.search(r'(\d+\.\d+\.\d+[\.\-]\d+)', version)
result["version"] = m.group(1) if m else version[:50]
@ -118,65 +127,129 @@ def check_agent(hostname, ssh_user, ssh_key_path, ssh_port=22):
return result
def _compare_versions(v1, v2):
"""Compare deux versions. Retourne -1 (v1<v2), 0 (egales), 1 (v1>v2)"""
def to_parts(v):
return [int(x) for x in re.split(r'[.\-]', v) if x.isdigit()]
p1, p2 = to_parts(v1 or ""), to_parts(v2 or "")
for a, b in zip(p1, p2):
if a < b: return -1
if a > b: return 1
if len(p1) < len(p2): return -1
if len(p1) > len(p2): return 1
return 0
def deploy_agent(hostname, ssh_user, ssh_key_path, ssh_port, os_family,
package_path, activation_id, customer_id, server_uri,
on_line=None):
"""Deploie l'agent Qualys sur un serveur"""
on_line=None, on_stage=None, force_downgrade=False):
"""Deploie l'agent Qualys sur un serveur (install, upgrade ou downgrade)"""
def emit(msg):
if on_line:
on_line(msg)
log.info(f"[{hostname}] {msg}")
def stage(name, detail=""):
if on_stage:
on_stage(name, detail)
stage("connecting", f"Connexion SSH {ssh_user}@{hostname}:{ssh_port}")
emit(f"Connexion SSH {ssh_user}@{hostname}:{ssh_port}...")
client, error = _get_ssh_client(hostname, ssh_user, ssh_key_path, ssh_port)
if not client:
emit(f"ERREUR connexion: {error}")
stage("failed", error)
return {"hostname": hostname, "status": "FAILED", "detail": error}
result = {"hostname": hostname, "status": "PENDING"}
pkg_name = os.path.basename(package_path)
pkg_version = _extract_version(pkg_name)
is_deb = pkg_name.endswith(".deb")
try:
# 1. Detect OS if not provided
if not os_family:
code, out, _ = _run_cmd(client, "cat /etc/os-release 2>/dev/null | head -3")
os_family = "linux" # default
os_family = "linux"
if "debian" in out.lower() or "ubuntu" in out.lower():
os_family = "debian"
elif "red hat" in out.lower() or "centos" in out.lower() or "rocky" in out.lower():
os_family = "rhel"
emit(f"OS detecte: {os_family}")
# 2. Check if already installed
code, out, _ = _run_cmd(client, "systemctl is-active qualys-cloud-agent 2>/dev/null")
if out.strip() == "active":
emit("Agent deja installe et actif - skip")
result["status"] = "ALREADY_INSTALLED"
result["detail"] = "Agent deja actif"
client.close()
return result
# 2. Check installed version
stage("checking", "Verification agent existant")
installed_version = None
code, out, _ = _run_cmd(client, "rpm -q qualys-cloud-agent 2>/dev/null")
if code == 0 and "not installed" not in out.lower() and "n'est pas install" not in out.lower():
m = re.search(r'(\d+\.\d+\.\d+[\.\-]\d+)', out)
installed_version = m.group(1) if m else None
else:
code, out, _ = _run_cmd(client, "dpkg-query -W -f='${Version}' qualys-cloud-agent 2>/dev/null")
if code == 0 and out.strip():
m = re.search(r'(\d+\.\d+\.\d+[\.\-]\d+)', out)
installed_version = m.group(1) if m else None
if installed_version:
cmp = _compare_versions(pkg_version, installed_version)
emit(f"Version installee: {installed_version}, package: {pkg_version}")
if cmp == 0:
# Meme version
code, out, _ = _run_cmd(client, "systemctl is-active qualys-cloud-agent 2>/dev/null")
if out.strip() == "active":
emit(f"Meme version ({installed_version}) et agent actif - skip")
stage("already_installed", f"v{installed_version} deja active")
result["status"] = "ALREADY_INSTALLED"
result["detail"] = f"v{installed_version} deja installee et active"
client.close()
return result
else:
emit(f"Meme version mais service {out.strip()} - reinstallation")
elif cmp < 0:
# Downgrade
if not force_downgrade:
emit(f"DOWNGRADE refuse: {installed_version}{pkg_version}")
stage("downgrade_refused", f"v{installed_version} > v{pkg_version}")
result["status"] = "DOWNGRADE_REFUSED"
result["detail"] = f"Version installee ({installed_version}) plus recente que le package ({pkg_version}). Cochez 'Forcer le downgrade'."
client.close()
return result
else:
emit(f"DOWNGRADE force: {installed_version}{pkg_version}")
else:
emit(f"UPGRADE: {installed_version}{pkg_version}")
# 3. Copy package
pkg_name = os.path.basename(package_path)
remote_path = f"/tmp/{pkg_name}"
emit(f"Copie {pkg_name} ({os.path.getsize(package_path)//1024//1024} Mo)...")
pkg_size = os.path.getsize(package_path) // 1024 // 1024
stage("copying", f"Copie {pkg_name} ({pkg_size} Mo)")
emit(f"Copie {pkg_name} ({pkg_size} Mo)...")
sftp = client.open_sftp()
sftp.put(package_path, remote_path)
sftp.put(package_path, remote_path=f"/tmp/{pkg_name}")
sftp.close()
emit("Copie terminee")
# 4. Install
is_deb = pkg_name.endswith(".deb")
remote_path = f"/tmp/{pkg_name}"
# 4. Install / Upgrade / Downgrade
if is_deb:
stage("installing", "Installation (dpkg)")
emit("Installation (dpkg)...")
code, out, err = _run_cmd(client, f"dpkg --install {remote_path}", sudo=True, timeout=120)
else:
emit("Installation (rpm)...")
code, out, err = _run_cmd(client, f"rpm -ivh --nosignature {remote_path}", sudo=True, timeout=120)
if force_downgrade and installed_version:
stage("installing", f"Downgrade (rpm) {installed_version}{pkg_version}")
emit(f"Downgrade (rpm --oldpackage)...")
code, out, err = _run_cmd(client, f"rpm -Uvh --nosignature --oldpackage {remote_path}", sudo=True, timeout=120)
else:
stage("installing", "Installation/Upgrade (rpm)")
emit("Installation/Upgrade (rpm -Uvh)...")
code, out, err = _run_cmd(client, f"rpm -Uvh --nosignature {remote_path}", sudo=True, timeout=120)
if code != 0 and "already installed" not in (out + err).lower():
emit(f"ERREUR installation (code {code}): {err[:200]}")
stage("failed", f"Installation echouee: {err[:100]}")
result["status"] = "INSTALL_FAILED"
result["detail"] = err[:200]
client.close()
@ -184,6 +257,7 @@ def deploy_agent(hostname, ssh_user, ssh_key_path, ssh_port, os_family,
emit("Installation OK")
# 5. Activate
stage("activating", "Activation de l'agent")
emit("Activation de l'agent...")
activate_cmd = (
f"/usr/local/qualys/cloud-agent/bin/qualys-cloud-agent.sh "
@ -195,6 +269,7 @@ def deploy_agent(hostname, ssh_user, ssh_key_path, ssh_port, os_family,
code, out, err = _run_cmd(client, activate_cmd, sudo=True, timeout=60)
if code != 0:
emit(f"ERREUR activation (code {code}): {err[:200]}")
stage("failed", f"Activation echouee: {err[:100]}")
result["status"] = "ACTIVATE_FAILED"
result["detail"] = err[:200]
client.close()
@ -202,17 +277,22 @@ def deploy_agent(hostname, ssh_user, ssh_key_path, ssh_port, os_family,
emit("Activation OK")
# 6. Restart service
stage("restarting", "Redemarrage du service")
emit("Redemarrage du service...")
_run_cmd(client, "systemctl restart qualys-cloud-agent", sudo=True)
# 7. Verify
stage("verifying", "Verification du service")
code, out, _ = _run_cmd(client, "systemctl is-active qualys-cloud-agent")
action = "Upgrade" if installed_version else "Install"
if out.strip() == "active":
emit("Agent deploye et actif !")
emit(f"Agent {action.lower()} OK et actif !")
stage("success", f"{action} OK — v{pkg_version} active")
result["status"] = "SUCCESS"
result["detail"] = "Agent deploye avec succes"
result["detail"] = f"{action} v{pkg_version} OK"
else:
emit(f"Agent installe mais statut: {out.strip()}")
stage("partial", f"Service: {out.strip()}")
result["status"] = "PARTIAL"
result["detail"] = f"Installe, service: {out.strip()}"
@ -221,8 +301,108 @@ def deploy_agent(hostname, ssh_user, ssh_key_path, ssh_port, os_family,
except Exception as e:
emit(f"ERREUR: {e}")
stage("failed", str(e)[:100])
result["status"] = "FAILED"
result["detail"] = str(e)[:200]
client.close()
return result
# ═══════════════════════════════════════════════
# Background job manager
# ═══════════════════════════════════════════════
import threading
import uuid
import time
_deploy_jobs = {} # job_id -> job dict
def start_deploy_job(servers, ssh_key, package_deb, package_rpm,
activation_id, customer_id, server_uri, force_downgrade=False):
"""Lance un job de deploiement en arriere-plan. Retourne le job_id."""
job_id = str(uuid.uuid4())[:8]
job = {
"id": job_id,
"started_at": time.time(),
"total": len(servers),
"done": 0,
"servers": {},
"log": [],
"finished": False,
}
for srv in servers:
job["servers"][srv["hostname"]] = {
"hostname": srv["hostname"],
"stage": "pending",
"detail": "En attente",
"status": None,
"result": None,
}
_deploy_jobs[job_id] = job
def _run():
threads = []
for srv in servers:
t = threading.Thread(target=_deploy_one, args=(job, srv, ssh_key,
package_deb, package_rpm, activation_id, customer_id, server_uri, force_downgrade))
t.daemon = True
threads.append(t)
t.start()
for t in threads:
t.join()
job["finished"] = True
job["finished_at"] = time.time()
master = threading.Thread(target=_run, daemon=True)
master.start()
return job_id
def _deploy_one(job, srv, ssh_key, package_deb, package_rpm,
activation_id, customer_id, server_uri, force_downgrade=False):
hostname = srv["hostname"]
def on_stage(stage, detail=""):
job["servers"][hostname]["stage"] = stage
job["servers"][hostname]["detail"] = detail
def on_line(msg):
job["log"].append(f"[{hostname}] {msg}")
osv = (srv.get("os_version") or "").lower()
if any(k in osv for k in ("centos", "red hat", "rhel", "rocky", "oracle", "fedora", "alma")):
pkg = package_rpm
else:
pkg = package_deb
if not pkg or not os.path.exists(pkg):
job["servers"][hostname]["stage"] = "failed"
job["servers"][hostname]["detail"] = f"Package introuvable: {pkg}"
job["servers"][hostname]["status"] = "FAILED"
job["done"] += 1
return
result = deploy_agent(
hostname=hostname, ssh_user=srv.get("ssh_user") or "root",
ssh_key_path=ssh_key, ssh_port=srv.get("ssh_port") or 22,
os_family=srv.get("os_family"), package_path=pkg,
activation_id=activation_id, customer_id=customer_id,
server_uri=server_uri, on_line=on_line, on_stage=on_stage,
force_downgrade=force_downgrade,
)
job["servers"][hostname]["status"] = result["status"]
job["servers"][hostname]["result"] = result
job["done"] += 1
def get_deploy_job(job_id):
"""Retourne l'etat d'un job de deploiement."""
return _deploy_jobs.get(job_id)
def list_deploy_jobs():
"""Liste les jobs recents (< 1h)."""
now = time.time()
return {jid: j for jid, j in _deploy_jobs.items() if now - j["started_at"] < 3600}

View File

@ -124,7 +124,7 @@ def get_servers_for_planning(db, year, week_number):
or_clauses.append("z.name = 'DMZ'")
where = f"""
s.etat = 'en_production' AND s.patch_os_owner = 'secops'
s.etat = 'production' AND s.patch_os_owner = 'secops'
AND s.licence_support IN ('active', 'els') AND s.os_family = 'linux'
AND ({' OR '.join(or_clauses)})
"""

View File

@ -0,0 +1,442 @@
"""Service de correspondance prod ↔ hors-prod + validations post-patching.
Détection automatique par signature de hostname :
- 2ème caractère = environnement SANEF (p=prod, r=recette, t=test, i=preprod, v=validation, d=dev, o=preprod, s=prod)
- Signature = hostname avec le 2ème char remplacé par "_"
- Tous les hostnames avec la même signature sont candidats correspondants.
Exceptions (ls-*, sp-*, etc.) : ne sont pas traitées automatiquement.
"""
import logging
from sqlalchemy import text
from collections import defaultdict
log = logging.getLogger(__name__)
# Lettres prod (un prod pour une signature)
PROD_CHARS = {"p", "s"} # p=Production, s=Production secours (à valider)
# Lettres hors-prod avec label
NONPROD_CHARS = {
"r": "Recette", "t": "Test", "i": "Pre-production",
"v": "Validation", "d": "Developpement", "o": "Pre-production",
}
# Préfixes qui ne suivent PAS la nomenclature
EXCEPTION_PREFIXES = ("ls-", "sp")
def _signature(hostname):
"""Retourne (signature, env_char) ou (None, None) si non analysable."""
hn = (hostname or "").lower().strip()
if not hn or len(hn) < 3:
return None, None
# Préfixes d'exception
for pref in EXCEPTION_PREFIXES:
if hn.startswith(pref):
return None, None
# Format standard : X{env_char}YYYYYY
env_char = hn[1]
if env_char not in PROD_CHARS and env_char not in NONPROD_CHARS:
return None, None
signature = hn[0] + "_" + hn[2:]
return signature, env_char
def detect_correspondances(db, dry_run=False):
"""Parcourt tous les serveurs, groupe par signature, crée les liens auto.
Ne touche pas aux liens 'manual' ou 'exception' existants.
Retourne un dict de stats."""
stats = {"signatures": 0, "prod_found": 0, "nonprod_found": 0,
"links_created": 0, "links_kept_manual": 0, "orphan_nonprod": 0,
"ambiguous": 0, "exceptions": 0}
# 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()
by_signature = defaultdict(list) # signature -> [(server_id, env_char, hostname)]
for r in rows:
sig, env = _signature(r.hostname)
if sig is None:
stats["exceptions"] += 1
continue
by_signature[sig].append((r.id, env, r.hostname))
stats["signatures"] = len(by_signature)
if dry_run:
# Préparer plan
plan = []
for sig, members in by_signature.items():
prods = [m for m in members if m[1] in PROD_CHARS]
nps = [m for m in members if m[1] in NONPROD_CHARS]
if len(prods) == 1 and nps:
plan.append({"signature": sig, "prod": prods[0][2],
"nonprods": [(n[2], NONPROD_CHARS[n[1]]) for n in nps]})
elif len(prods) > 1 and nps:
stats["ambiguous"] += 1
elif not prods and nps:
stats["orphan_nonprod"] += len(nps)
stats["plan"] = plan[:50]
return stats
for sig, members in by_signature.items():
prods = [m for m in members if m[1] in PROD_CHARS]
nonprods = [m for m in members if m[1] in NONPROD_CHARS]
stats["prod_found"] += len(prods)
stats["nonprod_found"] += len(nonprods)
if not prods and nonprods:
stats["orphan_nonprod"] += len(nonprods)
continue
if len(prods) > 1:
stats["ambiguous"] += 1
# On n'auto-détecte pas quand plusieurs prods (ambigu)
continue
if len(prods) == 1 and nonprods:
prod_id = prods[0][0]
for np_id, np_env, np_host in nonprods:
env_label = NONPROD_CHARS.get(np_env, "Autre")
# Insert si pas déjà présent + pas 'manual' ou 'exception'
existing = db.execute(text("""SELECT id, source FROM server_correspondance
WHERE prod_server_id=:p AND nonprod_server_id=:n"""),
{"p": prod_id, "n": np_id}).fetchone()
if existing:
if existing.source in ("manual", "exception"):
stats["links_kept_manual"] += 1
# sinon déjà auto, on skip
continue
try:
db.execute(text("""INSERT INTO server_correspondance
(prod_server_id, nonprod_server_id, environment_code, source)
VALUES (:p, :n, :env, 'auto')"""),
{"p": prod_id, "n": np_id, "env": env_label})
stats["links_created"] += 1
except Exception as e:
db.rollback()
db.commit()
return stats
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')"]
params = {}
if search:
where.append("s.hostname ILIKE :s"); params["s"] = f"%{search}%"
if app:
where.append("s.application_name = :app"); params["app"] = app
if domain:
where.append("d.name = :dom"); params["dom"] = domain
if env:
where.append("e.name = :env"); params["env"] = env
wc = " AND ".join(where)
return db.execute(text(f"""
SELECT s.id, s.hostname, s.application_name,
e.name as env_name, d.name as domain_name, z.name as zone_name,
(SELECT COUNT(*) FROM server_correspondance sc WHERE sc.prod_server_id = s.id) as n_as_prod,
(SELECT COUNT(*) FROM server_correspondance sc WHERE sc.nonprod_server_id = s.id) as n_as_nonprod
FROM servers s
LEFT JOIN domain_environments de ON s.domain_env_id = de.id
LEFT JOIN environments e ON de.environment_id = e.id
LEFT JOIN domains d ON de.domain_id = d.id
LEFT JOIN zones z ON s.zone_id = z.id
WHERE {wc}
ORDER BY e.name, s.hostname
LIMIT 500
"""), params).fetchall()
def bulk_create_correspondance(db, prod_ids, nonprod_ids, env_labels, user_id):
"""Crée toutes les correspondances prod × non-prod.
env_labels est un dict {nonprod_id: env_label} optionnel."""
created = 0
skipped = 0
for pid in prod_ids:
for npid in nonprod_ids:
if pid == npid:
continue
existing = db.execute(text("""SELECT id FROM server_correspondance
WHERE prod_server_id=:p AND nonprod_server_id=:n"""),
{"p": pid, "n": npid}).fetchone()
if existing:
skipped += 1
continue
env = (env_labels or {}).get(str(npid)) or (env_labels or {}).get(npid) or ""
db.execute(text("""INSERT INTO server_correspondance
(prod_server_id, nonprod_server_id, environment_code, source, created_by)
VALUES (:p, :n, :env, 'manual', :uid)"""),
{"p": pid, "n": npid, "env": env, "uid": user_id})
created += 1
db.commit()
return {"created": created, "skipped": skipped}
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')"]
params = {}
if search:
where.append("s.hostname ILIKE :s"); params["s"] = f"%{search}%"
if app:
where.append("s.application_name = :app"); params["app"] = app
if env:
where.append("e.name = :env"); params["env"] = env
else:
# Par défaut : tout ce qui ressemble à prod (Production ou code prod)
where.append("(e.name ILIKE '%production%' OR e.code ILIKE '%prod%')")
wc = " AND ".join(where)
prods = db.execute(text(f"""
SELECT s.id, s.hostname, s.application_name, e.name as env_name,
d.name as domain_name
FROM servers s
LEFT JOIN domain_environments de ON s.domain_env_id = de.id
LEFT JOIN environments e ON de.environment_id = e.id
LEFT JOIN domains d ON de.domain_id = d.id
WHERE {wc}
ORDER BY s.application_name, s.hostname
"""), params).fetchall()
results = []
for p in prods:
corrs = db.execute(text("""SELECT sc.id as corr_id, sc.environment_code, sc.source, sc.note,
ns.id as np_id, ns.hostname as np_hostname,
(SELECT pv.status FROM patch_validation pv WHERE pv.server_id = ns.id
ORDER BY pv.patch_date DESC LIMIT 1) as last_validation_status,
(SELECT pv.validated_at FROM patch_validation pv WHERE pv.server_id = ns.id
ORDER BY pv.patch_date DESC LIMIT 1) as last_validated_at,
(SELECT pv.patch_date FROM patch_validation pv WHERE pv.server_id = ns.id
ORDER BY pv.patch_date DESC LIMIT 1) as last_patch_date
FROM server_correspondance sc
JOIN servers ns ON sc.nonprod_server_id = ns.id
WHERE sc.prod_server_id = :pid
ORDER BY sc.environment_code, ns.hostname"""), {"pid": p.id}).fetchall()
# Validation status agrégé du prod
# Compter statuts des hors-prod liés
n_total = len(corrs)
n_ok = sum(1 for c in corrs if c.last_validation_status in ("validated_ok", "forced"))
n_pending = sum(1 for c in corrs if c.last_validation_status == "en_attente")
n_ko = sum(1 for c in corrs if c.last_validation_status == "validated_ko")
if n_total == 0:
global_status = "no_nonprod" # gris
elif n_ko > 0:
global_status = "ko"
elif n_pending > 0:
global_status = "pending"
elif n_ok == n_total:
global_status = "all_ok"
else:
global_status = "partial"
results.append({
"prod_id": p.id, "prod_hostname": p.hostname,
"application": p.application_name, "domain": p.domain_name,
"env": p.env_name,
"correspondants": [dict(c._mapping) for c in corrs],
"n_total": n_total, "n_ok": n_ok, "n_pending": n_pending, "n_ko": n_ko,
"global_status": global_status,
})
return results
def get_server_links(db, server_id):
"""Pour un serveur donné, retourne ses liens :
- as_prod : liste des hors-prod qui lui sont liés (si ce serveur est prod)
- as_nonprod : liste des prod auxquels il est lié (si ce serveur est non-prod)
Chaque item : {hostname, env_name, environment_code, source, corr_id}
"""
as_prod = db.execute(text("""SELECT sc.id as corr_id, sc.environment_code, sc.source,
ns.id, ns.hostname, e.name as env_name
FROM server_correspondance sc
JOIN servers ns ON sc.nonprod_server_id = ns.id
LEFT JOIN domain_environments de ON ns.domain_env_id = de.id
LEFT JOIN environments e ON de.environment_id = e.id
WHERE sc.prod_server_id = :id ORDER BY e.name, ns.hostname"""),
{"id": server_id}).fetchall()
as_nonprod = db.execute(text("""SELECT sc.id as corr_id, sc.environment_code, sc.source,
ps.id, ps.hostname, e.name as env_name
FROM server_correspondance sc
JOIN servers ps ON sc.prod_server_id = ps.id
LEFT JOIN domain_environments de ON ps.domain_env_id = de.id
LEFT JOIN environments e ON de.environment_id = e.id
WHERE sc.nonprod_server_id = :id ORDER BY ps.hostname"""),
{"id": server_id}).fetchall()
return {
"as_prod": [dict(r._mapping) for r in as_prod],
"as_nonprod": [dict(r._mapping) for r in as_nonprod],
}
def get_links_bulk(db, server_ids):
"""Pour une liste d'IDs, retourne un dict {server_id: {as_prod: [...], as_nonprod: [...]}}.
Optimisé pour affichage en liste (/servers)."""
if not server_ids:
return {}
placeholders = ",".join(str(i) for i in server_ids if str(i).isdigit())
if not placeholders:
return {}
result = {sid: {"as_prod": [], "as_nonprod": []} for sid in server_ids}
# Prod → non-prods
rows = db.execute(text(f"""SELECT sc.prod_server_id as sid, sc.environment_code,
ns.hostname, e.name as env_name
FROM server_correspondance sc
JOIN servers ns ON sc.nonprod_server_id = ns.id
LEFT JOIN domain_environments de ON ns.domain_env_id = de.id
LEFT JOIN environments e ON de.environment_id = e.id
WHERE sc.prod_server_id IN ({placeholders})
ORDER BY ns.hostname""")).fetchall()
for r in rows:
if r.sid in result:
result[r.sid]["as_prod"].append({"hostname": r.hostname,
"env_name": r.env_name, "environment_code": r.environment_code})
# Non-prod → prods
rows = db.execute(text(f"""SELECT sc.nonprod_server_id as sid,
ps.hostname, e.name as env_name
FROM server_correspondance sc
JOIN servers ps ON sc.prod_server_id = ps.id
LEFT JOIN domain_environments de ON ps.domain_env_id = de.id
LEFT JOIN environments e ON de.environment_id = e.id
WHERE sc.nonprod_server_id IN ({placeholders})
ORDER BY ps.hostname""")).fetchall()
for r in rows:
if r.sid in result:
result[r.sid]["as_nonprod"].append({"hostname": r.hostname, "env_name": r.env_name})
return result
def get_orphan_nonprod(db):
"""Retourne les hors-prod sans prod associée (exclut stock/obsolete)."""
rows = db.execute(text("""
SELECT s.id, s.hostname, s.application_name, e.name as env_name,
d.name as domain_name
FROM servers s
LEFT JOIN domain_environments de ON s.domain_env_id = de.id
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 NOT EXISTS (SELECT 1 FROM server_correspondance sc WHERE sc.nonprod_server_id = s.id)
ORDER BY s.application_name, s.hostname
LIMIT 500
""")).fetchall()
return rows
def create_manual_link(db, prod_id, nonprod_id, env_code, note, user_id):
"""Crée un lien manuel."""
existing = db.execute(text("""SELECT id FROM server_correspondance
WHERE prod_server_id=:p AND nonprod_server_id=:n"""),
{"p": prod_id, "n": nonprod_id}).fetchone()
if existing:
db.execute(text("""UPDATE server_correspondance SET source='manual',
environment_code=:env, note=:note, updated_at=NOW() WHERE id=:id"""),
{"env": env_code, "note": note, "id": existing.id})
else:
db.execute(text("""INSERT INTO server_correspondance (prod_server_id,
nonprod_server_id, environment_code, source, note, created_by)
VALUES (:p, :n, :env, 'manual', :note, :uid)"""),
{"p": prod_id, "n": nonprod_id, "env": env_code, "note": note, "uid": user_id})
db.commit()
def delete_link(db, corr_id):
db.execute(text("DELETE FROM server_correspondance WHERE id=:id"), {"id": corr_id})
db.commit()
# ─── Patch validation ───
def create_validation_entry(db, server_id, campaign_id=None, campaign_type="manual"):
"""Crée une entrée 'en_attente' après patching."""
db.execute(text("""INSERT INTO patch_validation (server_id, campaign_id, campaign_type,
patch_date, status) VALUES (:sid, :cid, :ct, NOW(), 'en_attente')"""),
{"sid": server_id, "cid": campaign_id, "ct": campaign_type})
db.commit()
def mark_validation(db, validation_ids, status, validator_contact_id, validator_name,
forced_reason, notes, user_id):
"""Marque N validations. status dans (validated_ok, validated_ko, forced)."""
placeholders = ",".join(str(i) for i in validation_ids if str(i).isdigit())
if not placeholders:
return 0
db.execute(text(f"""UPDATE patch_validation SET
status=:s, validated_by_contact_id=:cid, validated_by_name=:n,
validated_at=NOW(), marked_by_user_id=:uid,
forced_reason=:fr, notes=:nt, updated_at=NOW()
WHERE id IN ({placeholders})"""),
{"s": status, "cid": validator_contact_id, "n": validator_name,
"uid": user_id, "fr": forced_reason, "nt": notes})
db.commit()
return len(placeholders.split(","))
def get_pending_validations(db, env="", campaign_id=None, status="en_attente", limit=500):
"""Liste les validations filtrées."""
where = ["1=1"]
params = {}
if status:
where.append("pv.status = :st"); params["st"] = status
if campaign_id:
where.append("pv.campaign_id = :cid"); params["cid"] = campaign_id
if env:
where.append("e.name = :env"); params["env"] = env
wc = " AND ".join(where)
return db.execute(text(f"""
SELECT pv.id, pv.server_id, s.hostname, s.application_name,
e.name as env_name, d.name as domain_name,
pv.campaign_id, pv.campaign_type, pv.patch_date, pv.status,
pv.validated_by_name, pv.validated_at,
pv.forced_reason, pv.notes,
EXTRACT(day FROM NOW() - pv.patch_date) as days_pending
FROM patch_validation pv
JOIN servers s ON pv.server_id = s.id
LEFT JOIN domain_environments de ON s.domain_env_id = de.id
LEFT JOIN environments e ON de.environment_id = e.id
LEFT JOIN domains d ON de.domain_id = d.id
WHERE {wc}
ORDER BY pv.patch_date DESC
LIMIT {int(limit)}
"""), params).fetchall()
def get_validation_history(db, server_id):
return db.execute(text("""
SELECT pv.id, pv.campaign_id, pv.campaign_type, pv.patch_date, pv.status,
pv.validated_by_name, pv.validated_at, pv.forced_reason, pv.notes,
u.display_name as marked_by
FROM patch_validation pv
LEFT JOIN users u ON pv.marked_by_user_id = u.id
WHERE pv.server_id = :sid
ORDER BY pv.patch_date DESC
"""), {"sid": server_id}).fetchall()
def can_patch_prod(db, prod_server_id):
"""Retourne (bool, list_of_pending_hostnames) : peut-on patcher le prod ?
OK si tous les hors-prod liés ont validated_ok ou forced sur leur dernier patching."""
corrs = db.execute(text("""SELECT ns.id, ns.hostname,
(SELECT pv.status FROM patch_validation pv WHERE pv.server_id = ns.id
ORDER BY pv.patch_date DESC LIMIT 1) as last_status
FROM server_correspondance sc JOIN servers ns ON sc.nonprod_server_id = ns.id
WHERE sc.prod_server_id = :pid"""), {"pid": prod_server_id}).fetchall()
if not corrs:
return True, [] # pas de hors-prod = OK (ou selon règle, à ajuster)
blockers = [c.hostname for c in corrs if c.last_status not in ("validated_ok", "forced")]
return (len(blockers) == 0), blockers

View File

@ -43,19 +43,67 @@ class ITopClient:
return self._call("core/create", **{"class": cls, "fields": fields, "comment": "PatchCenter sync"})
def _normalize_os_for_itop(os_string):
"""Normalise PRETTY_NAME vers un nom propre pour iTop OSVersion.
Ex: 'Debian GNU/Linux 12 (bookworm)' 'Debian 12 (Bookworm)'
'CentOS Stream 9' 'CentOS Stream 9'
'Red Hat Enterprise Linux 9.4 (Plow)' 'RHEL 9.4 (Plow)'
"""
import re
s = os_string.strip()
# Debian GNU/Linux X (codename) → Debian X (Codename)
m = re.match(r'Debian GNU/Linux (\d+)\s*\((\w+)\)', s, re.I)
if m:
return f"Debian {m.group(1)} ({m.group(2).capitalize()})"
# Ubuntu X.Y LTS
m = re.match(r'Ubuntu (\d+\.\d+(?:\.\d+)?)\s*(LTS)?', s, re.I)
if m:
lts = " LTS" if m.group(2) else ""
return f"Ubuntu {m.group(1)}{lts}"
# Red Hat Enterprise Linux [Server] [release] X → RHEL X
m = re.match(r'Red Hat Enterprise Linux\s*(?:Server)?\s*(?:release)?\s*(\d+[\.\d]*)\s*\(?([\w]*)\)?', s, re.I)
if m:
codename = f" ({m.group(2).capitalize()})" if m.group(2) else ""
return f"RHEL {m.group(1)}{codename}"
# CentOS Stream release X → CentOS Stream X
m = re.match(r'CentOS Stream\s+release\s+(\d+[\.\d]*)', s, re.I)
if m:
return f"CentOS Stream {m.group(1)}"
# CentOS Linux X.Y → CentOS X.Y
m = re.match(r'CentOS\s+Linux\s+(\d+[\.\d]*)', s, re.I)
if m:
return f"CentOS {m.group(1)}"
# Rocky Linux X.Y (codename) → Rocky Linux X.Y
m = re.match(r'Rocky\s+Linux\s+(\d+[\.\d]*)', s, re.I)
if m:
return f"Rocky Linux {m.group(1)}"
# Oracle Linux / Oracle Enterprise Linux
m = re.match(r'Oracle\s+(?:Enterprise\s+)?Linux\s+(\d+[\.\d]*)', s, re.I)
if m:
return f"Oracle Linux {m.group(1)}"
# Fallback: remove "release" word
return re.sub(r'\s+release\s+', ' ', s).strip()
def _upsert_ip(db, server_id, ip):
if not ip:
return
existing = db.execute(text(
# Remove all old itop-managed primary IPs for this server (keep only the current one)
db.execute(text(
"DELETE FROM server_ips WHERE server_id=:sid AND ip_type='primary' AND description='itop' AND ip_address != :ip"),
{"sid": server_id, "ip": ip})
# Check if this exact IP already exists
exact = db.execute(text(
"SELECT id FROM server_ips WHERE server_id=:sid AND ip_address=:ip"),
{"sid": server_id, "ip": ip}).fetchone()
if not existing:
try:
db.execute(text(
"INSERT INTO server_ips (server_id, ip_address, ip_type, is_ssh, description) VALUES (:sid, :ip, 'primary', true, 'itop')"),
{"sid": server_id, "ip": ip})
except Exception:
pass
if exact:
return
try:
db.execute(text(
"INSERT INTO server_ips (server_id, ip_address, ip_type, is_ssh, description) VALUES (:sid, :ip, 'primary', true, 'itop')"),
{"sid": server_id, "ip": ip})
except Exception:
pass
def _save_sync_timestamp(db, direction, stats):
@ -154,11 +202,20 @@ def sync_from_itop(db, itop_url, itop_user, itop_pass):
except Exception:
db.rollback()
# ─── 5. Contacts + Teams ───
persons = client.get_all("Person", "name,first_name,email,phone,org_name")
# ─── 5. Contacts + Teams (filtre périmètre IT uniquement) ───
persons = client.get_all("Person", "name,first_name,email,phone,org_name,function,status")
# Périmètre IT : teams à synchroniser (configurable via settings)
from .secrets_service import get_secret as _gs
it_teams_raw = _gs(db, "itop_contact_teams")
it_teams_list = ["SecOps", "iPOP", "Externe", "DSI", "Admin DSI"]
if it_teams_raw:
it_teams_list = [t.strip() for t in it_teams_raw.split(",") if t.strip()]
# Map person → (itop_id, team)
person_info = {} # fullname_lower -> {itop_id, team}
team_members = {} # pour responsables domaine×env plus bas
# Get team memberships to determine role
team_members = {} # person_fullname_lower -> team_name
teams = client.get_all("Team", "name,persons_list")
for t in teams:
team_name = t.get("name", "")
@ -167,29 +224,67 @@ def sync_from_itop(db, itop_url, itop_user, itop_pass):
if pname:
team_members[pname] = team_name
team_role_map = {"SecOps": "referent_technique", "iPOP": "responsable_applicatif", "Externe": "referent_technique"}
team_role_map = {"SecOps": "referent_technique", "iPOP": "responsable_applicatif",
"Externe": "referent_technique", "DSI": "referent_technique",
"Admin DSI": "referent_technique"}
# Set des itop_id et emails vus dans le périmètre IT (pour désactiver les autres)
seen_itop_ids = set()
seen_emails = set()
stats["contacts_deactivated"] = 0
for p in persons:
fullname = f"{p.get('first_name','')} {p.get('name','')}".strip()
email = p.get("email", "")
if not email:
continue
# Determine role from team
team = team_members.get(fullname.lower(), "")
# Filtre : ne synchroniser que les persons dans le périmètre IT
if team not in it_teams_list:
continue
role = team_role_map.get(team, "referent_technique")
itop_id = p.get("itop_id")
phone = p.get("phone", "")
function = p.get("function", "")
# Status iTop : active/inactive
itop_status = (p.get("status", "") or "").lower()
is_active = itop_status != "inactive"
if itop_id:
seen_itop_ids.add(int(itop_id))
seen_emails.add(email.lower())
existing = db.execute(text("SELECT id FROM contacts WHERE LOWER(email)=LOWER(:e)"), {"e": email}).fetchone()
if existing:
db.execute(text("UPDATE contacts SET name=:n, role=:r, updated_at=NOW() WHERE id=:id"),
{"id": existing.id, "n": fullname, "r": role})
db.execute(text("""UPDATE contacts SET name=:n, role=:r, itop_id=:iid,
telephone=:tel, team=:t, function=:f, is_active=:a, updated_at=NOW() WHERE id=:id"""),
{"id": existing.id, "n": fullname, "r": role, "iid": itop_id,
"tel": phone, "t": team, "f": function, "a": is_active})
else:
try:
db.execute(text("INSERT INTO contacts (name, email, role) VALUES (:n, :e, :r)"),
{"n": fullname, "e": email, "r": role})
db.execute(text("""INSERT INTO contacts (name, email, role, itop_id,
telephone, team, function, is_active) VALUES (:n, :e, :r, :iid, :tel, :t, :f, :a)"""),
{"n": fullname, "e": email, "r": role, "iid": itop_id,
"tel": phone, "t": team, "f": function, "a": is_active})
stats["contacts"] += 1
except Exception:
db.rollback()
# Désactiver les contacts iTop qui ne sont plus dans le périmètre (plus dans les teams IT)
# Critère : a un itop_id mais n'a pas été vu dans le sync
if seen_itop_ids:
placeholders = ",".join(str(i) for i in seen_itop_ids)
r = db.execute(text(f"""UPDATE contacts SET is_active=false, updated_at=NOW()
WHERE itop_id IS NOT NULL AND itop_id NOT IN ({placeholders}) AND is_active=true"""))
stats["contacts_deactivated"] = r.rowcount
# Désactiver les users PatchCenter liés à des contacts devenus inactifs
stats["users_deactivated"] = 0
r = db.execute(text("""UPDATE users SET is_active=false, updated_at=NOW()
WHERE is_active=true AND itop_person_id IN
(SELECT itop_id FROM contacts WHERE is_active=false AND itop_id IS NOT NULL)"""))
stats["users_deactivated"] = r.rowcount
# ─── 6. Build lookup maps ───
domain_map = {r.name.lower(): r.id for r in db.execute(text("SELECT id, name FROM domains")).fetchall()}
env_map = {r.name.lower(): r.id for r in db.execute(text("SELECT id, name FROM environments")).fetchall()}
@ -206,6 +301,30 @@ def sync_from_itop(db, itop_url, itop_user, itop_pass):
de_responsables = defaultdict(lambda: {"resp_dom": defaultdict(int), "resp_dom_email": {},
"referent": defaultdict(int), "referent_email": {}})
# ─── 7bis. ApplicationSolutions ───
crit_map = {"high": "haute", "critical": "critique", "medium": "standard", "low": "basse"}
itop_apps = client.get_all("ApplicationSolution", "name,description,business_criticity,status")
for app in itop_apps:
iid = app.get("itop_id")
name = (app.get("name") or "")[:50]
full = (app.get("name") or "")[:200]
desc = (app.get("description") or "")[:500]
crit = crit_map.get((app.get("business_criticity") or "").lower(), "basse")
st = (app.get("status") or "active")[:30]
try:
db.execute(text("""INSERT INTO applications (itop_id, nom_court, nom_complet, description, criticite, status)
VALUES (:iid, :n, :nc, :d, :c, :s)
ON CONFLICT (itop_id) DO UPDATE SET nom_court=EXCLUDED.nom_court,
nom_complet=EXCLUDED.nom_complet, description=EXCLUDED.description,
criticite=EXCLUDED.criticite, status=EXCLUDED.status, updated_at=NOW()"""),
{"iid": iid, "n": name, "nc": full, "d": desc, "c": crit, "s": st})
stats["applications"] = stats.get("applications", 0) + 1
except Exception:
db.rollback()
db.commit()
app_by_itop_id = {r.itop_id: r.id for r in db.execute(text(
"SELECT id, itop_id FROM applications WHERE itop_id IS NOT NULL")).fetchall()}
# ─── 8. VirtualMachines ───
vms = client.get_all("VirtualMachine",
"name,description,status,managementip,osfamily_id_friendlyname,"
@ -215,10 +334,12 @@ def sync_from_itop(db, itop_url, itop_user, itop_pass):
"contacts_list,virtualhost_name,business_criticity,"
"tier_name,connexion_method_name,ssh_user_name,"
"patch_frequency_name,pref_patch_jour_name,patch_window,"
"patch_excludes,domain_ldap_name,last_patch_date")
"patch_excludes,domain_ldap_name,last_patch_date,"
"applicationsolution_list")
itop_status = {"production": "en_production", "stock": "stock",
"implementation": "en_cours", "obsolete": "decommissionne"}
# PatchCenter etat = iTop status (meme enum: production, implementation, stock, obsolete)
itop_status = {"production": "production", "stock": "stock",
"implementation": "implementation", "obsolete": "obsolete"}
for v in vms:
hostname = v.get("name", "").split(".")[0].lower()
@ -264,12 +385,25 @@ def sync_from_itop(db, itop_url, itop_user, itop_pass):
resp_srv_name = v.get("responsable_serveur_name", "")
resp_dom_name = v.get("responsable_domaine_name", "")
# ApplicationSolution (première app si plusieurs)
app_id = None
app_name = None
apps_list = v.get("applicationsolution_list") or []
if apps_list:
first = apps_list[0]
try:
itop_aid = int(first.get("applicationsolution_id", 0))
app_id = app_by_itop_id.get(itop_aid)
app_name = first.get("applicationsolution_name", "")
except (ValueError, TypeError):
pass
vals = {
"hostname": hostname, "fqdn": v.get("name", hostname),
"os_family": "linux" if "linux" in v.get("osfamily_id_friendlyname", "").lower() else "windows",
"os_version": v.get("osversion_id_friendlyname", ""),
"machine_type": "vm",
"etat": itop_status.get(v.get("status", ""), "en_production"),
"etat": itop_status.get(v.get("status", ""), "production"),
"de_id": de_id, "zone_id": zone_id,
"resp_srv": resp_srv_name,
"resp_srv_email": person_email.get(resp_srv_name.lower(), ""),
@ -282,6 +416,7 @@ def sync_from_itop(db, itop_url, itop_user, itop_pass):
"patch_freq": patch_freq, "patch_excludes": v.get("patch_excludes", ""),
"domain_ltd": v.get("domain_ldap_name", ""),
"pref_jour": pref_jour, "pref_heure": pref_heure,
"app_id": app_id, "app_name": app_name,
}
existing = db.execute(text("SELECT id FROM servers WHERE LOWER(hostname)=LOWER(:h)"), {"h": hostname}).fetchone()
@ -293,6 +428,7 @@ def sync_from_itop(db, itop_url, itop_user, itop_pass):
tier=:tier, ssh_method=:ssh_method, ssh_user=:ssh_user,
patch_frequency=:patch_freq, patch_excludes=:patch_excludes,
domain_ltd=:domain_ltd, pref_patch_jour=:pref_jour, pref_patch_heure=:pref_heure,
application_id=:app_id, application_name=:app_name,
updated_at=NOW() WHERE id=:sid"""), {**vals, "sid": existing.id})
if vals["ip"]:
_upsert_ip(db, existing.id, vals["ip"])
@ -341,22 +477,33 @@ def sync_from_itop(db, itop_url, itop_user, itop_pass):
hostname = s.get("name", "").split(".")[0].lower()
if not hostname:
continue
contacts = s.get("contacts_list", [])
resp = contacts[0].get("contact_id_friendlyname", "") if contacts else ""
ip = s.get("managementip", "")
osf = "linux" if "linux" in s.get("osfamily_id_friendlyname", "").lower() else "windows"
osv = s.get("osversion_id_friendlyname", "")
existing = db.execute(text("SELECT id FROM servers WHERE LOWER(hostname)=LOWER(:h)"), {"h": hostname}).fetchone()
if not existing:
if existing:
db.execute(text("""UPDATE servers SET fqdn=:f, os_family=:osf, os_version=:osv,
responsable_nom=:resp, commentaire=:desc, site=:site, updated_at=NOW()
WHERE id=:sid"""),
{"f": s.get("name", hostname), "osf": osf, "osv": osv,
"resp": resp, "desc": s.get("description", ""),
"site": s.get("location_name", ""), "sid": existing.id})
if ip:
_upsert_ip(db, existing.id, ip)
stats["servers_updated"] += 1
else:
try:
contacts = s.get("contacts_list", [])
resp = contacts[0].get("contact_id_friendlyname", "") if contacts else ""
db.execute(text("""INSERT INTO servers (hostname, fqdn, os_family, os_version, machine_type,
etat, responsable_nom, commentaire, site, ssh_port, ssh_user, ssh_method, tier)
VALUES (:h, :f, :osf, :osv, 'physical', 'en_production', :resp, :desc, :site,
VALUES (:h, :f, :osf, :osv, 'physical', 'production', :resp, :desc, :site,
22, 'root', 'ssh_key', 'tier0')"""),
{"h": hostname, "f": s.get("name", hostname),
"osf": "linux" if "linux" in s.get("osfamily_id_friendlyname", "").lower() else "windows",
"osv": s.get("osversion_id_friendlyname", ""),
{"h": hostname, "f": s.get("name", hostname), "osf": osf, "osv": osv,
"resp": resp, "desc": s.get("description", ""),
"site": s.get("location_name", "")})
db.flush()
ip = s.get("managementip", "")
if ip:
new_srv = db.execute(text("SELECT id FROM servers WHERE hostname=:h"), {"h": hostname}).fetchone()
if new_srv:
@ -414,26 +561,42 @@ def sync_to_itop(db, itop_url, itop_user, itop_pass):
for v in client.get_all("VirtualMachine", "name"):
itop_vms[v["name"].split(".")[0].lower()] = v
status_map = {"en_production": "production", "decommissionne": "obsolete",
"stock": "stock", "en_cours": "implementation"}
status_map = {"production": "production", "implementation": "implementation",
"stock": "stock", "obsolete": "obsolete"}
tier_map = {"tier0": "Tier 0", "tier1": "Tier 1", "tier2": "Tier 2", "tier3": "Tier 3"}
# Build OSVersion cache: name.lower() → itop_id
itop_osversions = {}
for ov in client.get_all("OSVersion", "name,osfamily_name"):
itop_osversions[ov["name"].lower()] = ov
# Build OSFamily cache
itop_osfamilies = {}
for of in client.get_all("OSFamily", "name"):
itop_osfamilies[of["name"].lower()] = of["itop_id"]
# Build Person name → itop_id lookup for responsable sync
itop_persons = {}
for p in client.get_all("Person", "name,first_name"):
fullname = f"{p.get('first_name','')} {p.get('name','')}".strip()
itop_persons[fullname.lower()] = p["itop_id"]
rows = db.execute(text("""SELECT hostname, fqdn, os_version, etat, commentaire, tier,
ssh_method, ssh_user, patch_frequency, patch_excludes, domain_ltd,
pref_patch_jour, pref_patch_heure, responsable_nom, referent_nom
FROM servers WHERE machine_type='vm'""")).fetchall()
rows = db.execute(text("""SELECT s.hostname, s.fqdn, s.os_version, s.os_family, s.etat, s.commentaire, s.tier,
s.ssh_method, s.ssh_user, s.patch_frequency, s.patch_excludes, s.domain_ltd,
s.pref_patch_jour, s.pref_patch_heure, s.responsable_nom, s.referent_nom,
s.application_id,
(SELECT si.ip_address::text FROM server_ips si WHERE si.server_id = s.id AND si.ip_type = 'primary' LIMIT 1) as mgmt_ip,
(SELECT sa.audit_date FROM server_audit sa WHERE sa.server_id = s.id ORDER BY sa.audit_date DESC LIMIT 1) as last_audit_date,
(SELECT a.itop_id FROM applications a WHERE a.id = s.application_id) as app_itop_id
FROM servers s WHERE s.machine_type='vm'""")).fetchall()
for srv in rows:
hostname = (srv.hostname or "").lower()
itop_vm = itop_vms.get(hostname)
fields = {}
if srv.mgmt_ip:
fields["managementip"] = srv.mgmt_ip.split("/")[0]
if srv.etat:
fields["status"] = status_map.get(srv.etat, "production")
if srv.commentaire:
@ -455,6 +618,29 @@ def sync_to_itop(db, itop_url, itop_user, itop_pass):
fields["patch_window"] = srv.pref_patch_heure
if srv.domain_ltd:
fields["domain_ldap_id"] = f"SELECT DomainLdap WHERE name = '{srv.domain_ltd}'"
# Date dernier audit
if srv.last_audit_date:
fields["last_patch_date"] = str(srv.last_audit_date)[:10]
# ApplicationSolution : pousser si défini (replace la liste)
if srv.app_itop_id:
fields["applicationsolution_list"] = [{"applicationsolution_id": int(srv.app_itop_id)}]
# OS version — chercher/créer dans iTop
if srv.os_version:
osv_name = _normalize_os_for_itop(srv.os_version)
osf_name = "Linux" if srv.os_family == "linux" else "Windows" if srv.os_family == "windows" else "Linux"
match = itop_osversions.get(osv_name.lower())
if match:
fields["osversion_id"] = match["itop_id"]
else:
# Créer l'OSVersion dans iTop
osf_id = itop_osfamilies.get(osf_name.lower())
if osf_id:
cr = client.create("OSVersion", {"name": osv_name, "osfamily_id": osf_id})
if cr.get("code") == 0 and cr.get("objects"):
new_id = list(cr["objects"].values())[0]["key"]
fields["osversion_id"] = new_id
itop_osversions[osv_name.lower()] = {"itop_id": new_id, "name": osv_name}
stats["ref_created"] += 1
# Responsable serveur
if srv.responsable_nom:
pid = itop_persons.get(srv.responsable_nom.lower())

View File

@ -0,0 +1,113 @@
"""Service LDAP/AD — authentification via annuaire.
Configuration via settings (clés) :
- ldap_enabled : "true" / "false"
- ldap_server : URL du serveur (ex: ldaps://ad.sanef.com:636)
- ldap_base_dn : DN de base (ex: DC=sanef,DC=com)
- ldap_bind_dn : DN du compte de bind (ex: CN=svc_pc,OU=SVC,DC=sanef,DC=com)
- ldap_bind_pwd : mot de passe (stocké chiffré via secrets_service)
- ldap_user_filter : filtre utilisateur (ex: (sAMAccountName={username}))
- ldap_tls : "true" / "false"
"""
import logging
from sqlalchemy import text
log = logging.getLogger(__name__)
try:
from ldap3 import Server, Connection, ALL, NTLM, SIMPLE, Tls
import ssl
LDAP_OK = True
except ImportError:
LDAP_OK = False
def _get_config(db):
"""Retourne la config LDAP depuis app_secrets (via get_secret)."""
from .secrets_service import get_secret
def s(k, default=""):
return get_secret(db, k) or default
return {
"enabled": s("ldap_enabled", "false").lower() == "true",
"server": s("ldap_server"),
"base_dn": s("ldap_base_dn"),
"bind_dn": s("ldap_bind_dn"),
"bind_pwd": s("ldap_bind_pwd"),
"user_filter": s("ldap_user_filter", "(sAMAccountName={username})"),
"tls": s("ldap_tls", "true").lower() == "true",
"email_attr": s("ldap_email_attr", "mail"),
"name_attr": s("ldap_name_attr", "displayName"),
}
def is_enabled(db):
"""LDAP activé et configuré ?"""
cfg = _get_config(db)
return cfg["enabled"] and cfg["server"] and cfg["base_dn"]
def authenticate(db, username, password):
"""Tente l'authentification LDAP.
Retourne dict {ok, email, name, dn} ou {ok: False, msg: '...'}"""
if not LDAP_OK:
return {"ok": False, "msg": "Module ldap3 non installé"}
cfg = _get_config(db)
if not cfg["enabled"]:
return {"ok": False, "msg": "LDAP désactivé"}
if not cfg["server"] or not cfg["base_dn"]:
return {"ok": False, "msg": "LDAP non configuré"}
# 1. Bind service account pour chercher le DN de l'utilisateur
try:
server = Server(cfg["server"], get_info=ALL, use_ssl=cfg["server"].startswith("ldaps://"))
conn = Connection(server, user=cfg["bind_dn"], password=cfg["bind_pwd"], auto_bind=True)
except Exception as e:
log.error(f"LDAP bind failed: {e}")
return {"ok": False, "msg": f"Connexion LDAP échouée: {e}"}
# 2. Recherche de l'utilisateur
user_filter = cfg["user_filter"].replace("{username}", username)
try:
conn.search(cfg["base_dn"], user_filter,
attributes=[cfg["email_attr"], cfg["name_attr"], "distinguishedName"])
except Exception as e:
conn.unbind()
return {"ok": False, "msg": f"Recherche LDAP échouée: {e}"}
if not conn.entries:
conn.unbind()
return {"ok": False, "msg": "Utilisateur introuvable dans LDAP"}
entry = conn.entries[0]
user_dn = str(entry.distinguishedName) if hasattr(entry, "distinguishedName") else entry.entry_dn
email = str(getattr(entry, cfg["email_attr"], "")) or ""
name = str(getattr(entry, cfg["name_attr"], "")) or username
conn.unbind()
# 3. Bind avec les credentials fournis
try:
user_conn = Connection(server, user=user_dn, password=password, auto_bind=True)
user_conn.unbind()
except Exception as e:
return {"ok": False, "msg": "Mot de passe incorrect"}
return {"ok": True, "dn": user_dn, "email": email, "name": name}
def test_connection(db):
"""Test la connexion LDAP avec le compte de bind (pour admin)."""
if not LDAP_OK:
return {"ok": False, "msg": "Module ldap3 non installé"}
cfg = _get_config(db)
if not cfg["server"]:
return {"ok": False, "msg": "Serveur non configuré"}
try:
server = Server(cfg["server"], get_info=ALL, use_ssl=cfg["server"].startswith("ldaps://"))
conn = Connection(server, user=cfg["bind_dn"], password=cfg["bind_pwd"], auto_bind=True)
conn.unbind()
return {"ok": True, "msg": "Connexion réussie"}
except Exception as e:
return {"ok": False, "msg": str(e)[:200]}

View File

@ -1,146 +0,0 @@
"""Exécuteur de patching — exécute les commandes SSH et stream les résultats"""
import threading
import queue
import time
from datetime import datetime
from sqlalchemy import text
# File de messages par campagne (thread-safe)
_streams = {} # campaign_id -> queue.Queue
def get_stream(campaign_id):
"""Récupère ou crée la file de messages pour une campagne"""
if campaign_id not in _streams:
_streams[campaign_id] = queue.Queue(maxsize=1000)
return _streams[campaign_id]
def emit(campaign_id, msg, level="info"):
"""Émet un message dans le stream"""
ts = datetime.now().strftime("%H:%M:%S")
q = get_stream(campaign_id)
try:
q.put_nowait({"ts": ts, "msg": msg, "level": level})
except queue.Full:
pass # Drop si full
def clear_stream(campaign_id):
"""Vide le stream"""
if campaign_id in _streams:
while not _streams[campaign_id].empty():
try:
_streams[campaign_id].get_nowait()
except queue.Empty:
break
def execute_safe_patching(db_url, campaign_id, session_ids, branch="hprod"):
"""Exécute le safe patching en background (thread)"""
from sqlalchemy import create_engine, text
engine = create_engine(db_url)
emit(campaign_id, f"=== Safe Patching — Branche {'Hors-prod' if branch == 'hprod' else 'Production'} ===", "header")
emit(campaign_id, f"{len(session_ids)} serveur(s) à traiter", "info")
emit(campaign_id, "")
with engine.connect() as conn:
for i, sid in enumerate(session_ids, 1):
row = conn.execute(text("""
SELECT ps.id, s.hostname, s.fqdn, s.satellite_host, s.machine_type
FROM patch_sessions ps JOIN servers s ON ps.server_id = s.id
WHERE ps.id = :sid
"""), {"sid": sid}).fetchone()
if not row:
continue
hn = row.hostname
emit(campaign_id, f"[{i}/{len(session_ids)}] {hn}", "server")
# Step 1: Check SSH
emit(campaign_id, f" Connexion SSH...", "step")
ssh_ok = _check_ssh(hn)
if ssh_ok:
emit(campaign_id, f" SSH : OK", "ok")
else:
emit(campaign_id, f" SSH : ÉCHEC — serveur ignoré", "error")
conn.execute(text("UPDATE patch_sessions SET status = 'failed' WHERE id = :id"), {"id": sid})
conn.commit()
continue
# Step 2: Check disk
emit(campaign_id, f" Espace disque...", "step")
emit(campaign_id, f" Disque : OK (mode démo)", "ok")
# Step 3: Check satellite
emit(campaign_id, f" Satellite...", "step")
emit(campaign_id, f" Satellite : OK (mode démo)", "ok")
# Step 4: Snapshot
if row.machine_type == 'vm':
emit(campaign_id, f" Snapshot vSphere...", "step")
emit(campaign_id, f" Snapshot : OK (mode démo)", "ok")
# Step 5: Save state
emit(campaign_id, f" Sauvegarde services/ports...", "step")
emit(campaign_id, f" État sauvegardé", "ok")
# Step 6: Dry run
emit(campaign_id, f" Dry run yum check-update...", "step")
time.sleep(0.3) # Simule
emit(campaign_id, f" X packages disponibles (mode démo)", "info")
# Step 7: Patching
emit(campaign_id, f" Exécution safe patching...", "step")
time.sleep(0.5) # Simule
emit(campaign_id, f" Patching : OK (mode démo)", "ok")
# Step 8: Post-check
emit(campaign_id, f" Vérification post-patch...", "step")
emit(campaign_id, f" needs-restarting : pas de reboot ✓", "ok")
emit(campaign_id, f" Services : identiques ✓", "ok")
# Update status
conn.execute(text("""
UPDATE patch_sessions SET status = 'patched', date_realise = now() WHERE id = :id
"""), {"id": sid})
conn.commit()
emit(campaign_id, f"{hn} PATCHÉ ✓", "success")
emit(campaign_id, "")
# Fin
emit(campaign_id, f"=== Terminé — {len(session_ids)} serveur(s) traité(s) ===", "header")
emit(campaign_id, "__DONE__", "done")
def _check_ssh(hostname):
"""Check SSH TCP (mode démo = toujours OK)"""
import socket
suffixes = ["", ".sanef.groupe", ".sanef-rec.fr"]
for suffix in suffixes:
try:
sock = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
sock.settimeout(3)
r = sock.connect_ex((hostname + suffix, 22))
sock.close()
if r == 0:
return True
except Exception:
continue
# Mode démo : retourner True même si pas joignable
return True
def start_execution(db_url, campaign_id, session_ids, branch="hprod"):
"""Lance l'exécution dans un thread séparé"""
clear_stream(campaign_id)
t = threading.Thread(
target=execute_safe_patching,
args=(db_url, campaign_id, session_ids, branch),
daemon=True
)
t.start()
return t

View File

@ -142,24 +142,24 @@ def _check_server(s):
result["disk_var_mb"] = 3000
result["disk_ok"] = True
# Quand meme exclure les EOL et decom
if s.licence_support == 'eol':
if s.licence_support == 'obsolete':
result["eligible"] = False
result["exclude_reason"] = "eol"
result["exclude_reason"] = "obsolete"
result["exclude_detail"] = "Licence EOL"
elif s.etat != 'en_production':
elif s.etat != 'production':
result["eligible"] = False
result["exclude_reason"] = "non_patchable"
result["exclude_detail"] = f"Etat: {s.etat}"
return result
# 1. Eligibilite de base
if s.licence_support == 'eol':
if s.licence_support == 'obsolete':
result["eligible"] = False
result["exclude_reason"] = "eol"
result["exclude_reason"] = "obsolete"
result["exclude_detail"] = "Licence EOL — serveur non supporte"
return result
if s.etat != 'en_production':
if s.etat != 'production':
result["eligible"] = False
result["exclude_reason"] = "non_patchable"
result["exclude_detail"] = f"Etat: {s.etat}"
@ -255,17 +255,17 @@ def _auto_exclude(db, campaign_id):
FROM patch_sessions ps
JOIN servers s ON ps.server_id = s.id
WHERE ps.campaign_id = :cid AND ps.status = 'pending'
AND (s.licence_support = 'eol'
OR s.etat != 'en_production'
AND (s.licence_support = 'obsolete'
OR s.etat != 'production'
OR ps.prereq_ssh = 'ko'
OR ps.prereq_disk_ok = false)
"""), {"cid": campaign_id}).fetchall()
count = 0
for s in non_eligible:
if s.licence_support == 'eol':
reason, detail = "eol", "Licence EOL — auto-exclu"
elif s.etat != 'en_production':
if s.licence_support == 'obsolete':
reason, detail = "obsolete", "Licence EOL — auto-exclu"
elif s.etat != 'production':
reason, detail = "non_patchable", f"Etat {s.etat} — auto-exclu"
elif s.prereq_disk_ok is False:
reason, detail = "creneau_inadequat", "Espace disque insuffisant — auto-exclu"

View File

@ -0,0 +1,58 @@
"""Profils utilisateurs PatchCenter — mapping role → permissions pré-définies.
4 profils :
- admin : tout (view/edit/admin sur tous les modules)
- coordinator : SecOps + coordination (Patcheur + gestion campagnes/planning)
- operator : Patcheur (intervenant SecOps exécution patching)
- viewer : Invité (view-only : dashboard, servers, qualys, audit)
"""
# Matrix profil → {module: level}
# level: "view" | "edit" | "admin"
PROFILES = {
"admin": {
"dashboard": "admin", "servers": "admin", "campaigns": "admin",
"planning": "admin", "specifics": "admin", "audit": "admin",
"contacts": "admin", "qualys": "admin", "quickwin": "admin",
"users": "admin", "settings": "admin", "referentiel": "admin",
},
# Coordinateur = SecOps + gestion campagnes/planning
"coordinator": {
"dashboard": "view", "servers": "edit", "campaigns": "admin",
"planning": "edit", "specifics": "edit", "audit": "edit",
"contacts": "view", "qualys": "edit", "quickwin": "admin",
"users": "view", "referentiel": "view",
},
# Patcheur = intervenant SecOps
"operator": {
"dashboard": "view", "servers": "view", "campaigns": "view",
"planning": "view", "audit": "edit", "qualys": "view",
"quickwin": "edit", "contacts": "view",
},
# Invité = view-only (pas d'accès à l'audit)
"viewer": {
"dashboard": "view", "servers": "view", "qualys": "view",
"contacts": "view", "planning": "view", "quickwin": "view",
},
}
def get_profile_perms(role: str) -> dict:
"""Retourne les permissions pour un profil donné."""
return dict(PROFILES.get(role, {}))
PROFILE_LABELS = {
"admin": "Admin",
"coordinator": "Coordinateur",
"operator": "Patcheur",
"viewer": "Invité",
}
PROFILE_DESCRIPTIONS = {
"admin": "Accès complet : gestion des utilisateurs, paramètres, tous les modules en admin",
"coordinator": "SecOps + coordination : gestion des campagnes, planning, exécution patching",
"operator": "Patcheur (intervenant SecOps) : exécution du patching, audit des serveurs",
"viewer": "Invité : consultation en lecture seule (dashboard, serveurs, Qualys, audit)",
}

View File

@ -72,7 +72,7 @@ def delete_server_config(db, config_id):
def get_eligible_servers(db):
"""Serveurs Linux en_production, patch_os_owner=secops"""
"""Serveurs Linux production, patch_os_owner=secops"""
return db.execute(text("""
SELECT s.id, s.hostname, s.os_family, s.os_version, s.machine_type,
s.tier, s.etat, s.patch_excludes, s.is_flux_libre, s.is_podman,
@ -86,7 +86,7 @@ def get_eligible_servers(db):
LEFT JOIN environments e ON de.environment_id = e.id
LEFT JOIN quickwin_server_config qc ON qc.server_id = s.id
WHERE s.os_family = 'linux'
AND s.etat = 'en_production'
AND s.etat = 'production'
AND s.patch_os_owner = 'secops'
ORDER BY e.display_order, d.display_order, s.hostname
""")).fetchall()
@ -140,25 +140,26 @@ def create_run(db, year, week_number, label, user_id, server_ids, notes=""):
"""), {"y": year, "w": week_number, "l": label, "uid": user_id, "n": notes}).fetchone()
run_id = row.id
# Lire les reboot packages globaux (source: app_secrets)
from .secrets_service import get_secret
reboot_pkgs = get_secret(db, "patching_reboot_packages") or DEFAULT_GENERAL_EXCLUDES
for sid in server_ids:
srv = db.execute(text("""
SELECT s.id, e.name as env_name,
COALESCE(qc.general_excludes, '') as ge,
COALESCE(qc.specific_excludes, '') as se
SELECT s.id, e.name as env_name, COALESCE(s.patch_excludes, '') as pe
FROM servers s
LEFT JOIN domain_environments de ON s.domain_env_id = de.id
LEFT JOIN environments e ON de.environment_id = e.id
LEFT JOIN quickwin_server_config qc ON qc.server_id = s.id
WHERE s.id = :sid
"""), {"sid": sid}).fetchone()
if not srv:
continue
branch = "prod" if srv.env_name and "production" in srv.env_name.lower() else "hprod"
ge = srv.ge if srv.ge else DEFAULT_GENERAL_EXCLUDES
# QuickWin : reboot globaux + exclusions iTop du serveur
db.execute(text("""
INSERT INTO quickwin_entries (run_id, server_id, branch, general_excludes, specific_excludes)
VALUES (:rid, :sid, :br, :ge, :se)
"""), {"rid": run_id, "sid": sid, "br": branch, "ge": ge, "se": srv.se})
"""), {"rid": run_id, "sid": sid, "br": branch, "ge": reboot_pkgs, "se": srv.pe})
db.commit()
return run_id
@ -183,7 +184,7 @@ def get_available_servers(db, run_id, search="", domains=None, envs=None, zones=
LEFT JOIN environments e ON de.environment_id = e.id
LEFT JOIN zones z ON s.zone_id = z.id
WHERE s.os_family = 'linux'
AND s.etat = 'en_production'
AND s.etat = 'production'
AND s.patch_os_owner = 'secops'
AND s.id NOT IN (SELECT server_id FROM quickwin_entries WHERE run_id = :rid)
ORDER BY d.name, e.name, s.hostname
@ -210,7 +211,7 @@ def get_available_filters(db, run_id):
LEFT JOIN environments e ON de.environment_id = e.id
LEFT JOIN zones z ON s.zone_id = z.id
WHERE s.os_family = 'linux'
AND s.etat = 'en_production'
AND s.etat = 'production'
AND s.patch_os_owner = 'secops'
AND s.id NOT IN (SELECT server_id FROM quickwin_entries WHERE run_id = :rid)
"""), {"rid": run_id}).fetchall()
@ -241,29 +242,28 @@ def add_entries_to_run(db, run_id, server_ids, user=None):
), {"rid": run_id}).fetchall())
by = user.get("display_name", user.get("username", "")) if user else ""
from .secrets_service import get_secret
reboot_pkgs = get_secret(db, "patching_reboot_packages") or DEFAULT_GENERAL_EXCLUDES
added = 0
hostnames = []
for sid in server_ids:
if sid in existing:
continue
srv = db.execute(text("""
SELECT s.id, s.hostname, e.name as env_name,
COALESCE(qc.general_excludes, '') as ge,
COALESCE(qc.specific_excludes, '') as se
SELECT s.id, s.hostname, e.name as env_name, COALESCE(s.patch_excludes, '') as pe
FROM servers s
LEFT JOIN domain_environments de ON s.domain_env_id = de.id
LEFT JOIN environments e ON de.environment_id = e.id
LEFT JOIN quickwin_server_config qc ON qc.server_id = s.id
WHERE s.id = :sid
"""), {"sid": sid}).fetchone()
if not srv:
continue
branch = "prod" if srv.env_name and "production" in srv.env_name.lower() else "hprod"
ge = srv.ge if srv.ge else DEFAULT_GENERAL_EXCLUDES
db.execute(text("""
INSERT INTO quickwin_entries (run_id, server_id, branch, general_excludes, specific_excludes)
VALUES (:rid, :sid, :br, :ge, :se)
"""), {"rid": run_id, "sid": sid, "br": branch, "ge": ge, "se": srv.se})
"""), {"rid": run_id, "sid": sid, "br": branch, "ge": reboot_pkgs, "se": srv.pe})
added += 1
hostnames.append(srv.hostname)
if added:
@ -395,6 +395,23 @@ def update_entry_status(db, entry_id, status, patch_output="", packages_count=0,
"pp": packages, "rb": reboot_required, "n": notes})
db.commit()
# Création auto d'une entrée patch_validation (en_attente) pour les serveurs patchés
if status == "patched":
row = db.execute(text("SELECT server_id, run_id FROM quickwin_entries WHERE id=:id"),
{"id": entry_id}).fetchone()
if row:
# Éviter les doublons (même run + même server dans la dernière heure)
existing = db.execute(text("""SELECT id FROM patch_validation
WHERE server_id=:sid AND campaign_id=:cid AND campaign_type='quickwin'
AND patch_date >= NOW() - INTERVAL '1 hour'"""),
{"sid": row.server_id, "cid": row.run_id}).fetchone()
if not existing:
db.execute(text("""INSERT INTO patch_validation (server_id, campaign_id,
campaign_type, patch_date, status)
VALUES (:sid, :cid, 'quickwin', NOW(), 'en_attente')"""),
{"sid": row.server_id, "cid": row.run_id})
db.commit()
def update_entry_field(db, entry_id, field, value):
"""Mise a jour d'un champ unique (pour inline edit)"""
@ -417,6 +434,31 @@ def can_start_prod(db, run_id):
return pending.cnt == 0
def check_prod_validations(db, run_id):
"""Vérifie que chaque prod du run a ses non-prod liés validés (via server_correspondance + patch_validation).
Retourne (ok, blockers) blockers = liste [{prod_hostname, nonprod_hostname, status}].
Ignore les prods sans non-prod lié (OK par défaut)."""
rows = db.execute(text("""
SELECT qe.id as entry_id, ps.id as prod_id, ps.hostname as prod_host
FROM quickwin_entries qe JOIN servers ps ON qe.server_id = ps.id
WHERE qe.run_id = :rid AND qe.branch = 'prod' AND qe.status NOT IN ('excluded','skipped','patched')
"""), {"rid": run_id}).fetchall()
blockers = []
for r in rows:
corrs = db.execute(text("""SELECT ns.hostname,
(SELECT pv.status FROM patch_validation pv WHERE pv.server_id = ns.id
ORDER BY pv.patch_date DESC LIMIT 1) as last_status
FROM server_correspondance sc JOIN servers ns ON sc.nonprod_server_id = ns.id
WHERE sc.prod_server_id = :pid"""), {"pid": r.prod_id}).fetchall()
for c in corrs:
if c.last_status not in ("validated_ok", "forced"):
blockers.append({"prod_hostname": r.prod_host,
"nonprod_hostname": c.hostname,
"status": c.last_status or "aucun_patching"})
return (len(blockers) == 0), blockers
def get_run_stats(db, run_id):
return db.execute(text("""
SELECT

View File

@ -32,7 +32,7 @@ def _get_ssh_settings():
# Commandes d'audit (simplifiees pour le temps reel)
AUDIT_CMDS = {
"os_release": "cat /etc/redhat-release 2>/dev/null || head -1 /etc/os-release 2>/dev/null",
"os_release": "cat /etc/redhat-release 2>/dev/null || grep '^PRETTY_NAME=' /etc/os-release 2>/dev/null | cut -d'\"' -f2",
"kernel": "uname -r",
"uptime": "uptime -p 2>/dev/null || uptime",
"selinux": "getenforce 2>/dev/null || echo N/A",
@ -193,6 +193,124 @@ def audit_servers_list(hostnames):
return results
# ═══════════════════════════════════════════════
# Background audit job manager
# ═══════════════════════════════════════════════
import threading
import uuid
import time as _time
_audit_jobs = {}
def start_audit_job(hostnames):
"""Lance un audit en arriere-plan. Retourne le job_id."""
job_id = str(uuid.uuid4())[:8]
job = {
"id": job_id,
"started_at": _time.time(),
"total": len(hostnames),
"done": 0,
"servers": {},
"results": [],
"finished": False,
}
for hn in hostnames:
job["servers"][hn] = {"hostname": hn, "stage": "pending", "detail": "En attente", "status": None}
_audit_jobs[job_id] = job
def _run():
threads = []
for hn in hostnames:
t = threading.Thread(target=_audit_one, args=(job, hn.strip()), daemon=True)
threads.append(t)
t.start()
for t in threads:
t.join()
job["finished"] = True
job["finished_at"] = _time.time()
threading.Thread(target=_run, daemon=True).start()
return job_id
def _audit_one(job, hostname):
job["servers"][hostname]["stage"] = "resolving"
job["servers"][hostname]["detail"] = "Résolution DNS"
target = _resolve(hostname)
if not target:
job["servers"][hostname]["stage"] = "failed"
job["servers"][hostname]["detail"] = "DNS: aucun suffixe résolu"
job["servers"][hostname]["status"] = "CONNECTION_FAILED"
result = {"hostname": hostname, "status": "CONNECTION_FAILED",
"connection_method": f"DNS: aucun suffixe résolu ({hostname})", "resolved_fqdn": None}
job["results"].append(result)
job["done"] += 1
return
job["servers"][hostname]["stage"] = "connecting"
job["servers"][hostname]["detail"] = f"Connexion SSH → {target}"
client = _connect(target)
if not client:
job["servers"][hostname]["stage"] = "failed"
job["servers"][hostname]["detail"] = f"SSH refusé ({target})"
job["servers"][hostname]["status"] = "CONNECTION_FAILED"
result = {"hostname": hostname, "status": "CONNECTION_FAILED",
"connection_method": f"SSH: connexion refusée ({target})", "resolved_fqdn": target}
job["results"].append(result)
job["done"] += 1
return
job["servers"][hostname]["stage"] = "auditing"
job["servers"][hostname]["detail"] = "Collecte des données"
result = {"hostname": hostname, "status": "OK", "resolved_fqdn": target,
"audit_date": datetime.now().strftime("%Y-%m-%d %H:%M")}
ssh_key, ssh_user = _get_ssh_settings()
result["connection_method"] = f"ssh_key ({ssh_user}@{target})"
for key, cmd in AUDIT_CMDS.items():
result[key] = _run(client, cmd)
try:
client.close()
except Exception:
pass
# Post-traitement
agents = result.get("agents", "")
result["qualys_active"] = "qualys" in agents and "active" in agents
result["sentinelone_active"] = "sentinelone" in agents and "active" in agents
result["disk_alert"] = False
for line in (result.get("disk_space") or "").split("\n"):
parts = line.split()
pcts = [p for p in parts if "%" in p]
if pcts:
try:
pct = int(pcts[0].replace("%", ""))
if pct >= 90:
result["disk_alert"] = True
except ValueError:
pass
job["servers"][hostname]["stage"] = "success"
job["servers"][hostname]["detail"] = result.get("os_release", "OK")
job["servers"][hostname]["status"] = "OK"
job["results"].append(result)
job["done"] += 1
def get_audit_job(job_id):
return _audit_jobs.get(job_id)
def list_audit_jobs():
now = _time.time()
return {jid: j for jid, j in _audit_jobs.items() if now - j["started_at"] < 3600}
def save_audit_to_db(db, results):
"""Sauvegarde/met a jour les resultats d'audit en base"""
updated = 0
@ -274,9 +392,10 @@ def save_audit_to_db(db, results):
except Exception:
pass
from .itop_service import _normalize_os_for_itop
updates = {}
if r.get("os_release"):
updates["os_version"] = r["os_release"].strip()
updates["os_version"] = _normalize_os_for_itop(r["os_release"].strip())
if ip_addr:
updates["fqdn"] = resolved

View File

@ -1,110 +0,0 @@
"""Service Safe Patching — Quick Win : patching sans interruption de service"""
from datetime import datetime
from sqlalchemy import text
# Packages qui TOUJOURS nécessitent un reboot
REBOOT_PACKAGES = [
"kernel", "kernel-core", "kernel-modules", "kernel-tools",
"glibc", "glibc-common", "glibc-devel",
"systemd", "systemd-libs", "systemd-udev",
"dbus", "dbus-libs", "dbus-daemon",
"linux-firmware", "microcode_ctl",
"polkit", "polkit-libs",
"tuned",
]
# Standard excludes (middleware/apps — jamais en safe)
STD_EXCLUDES = [
"mongodb*", "mysql*", "postgres*", "mariadb*", "oracle*", "pgdg*",
"php*", "java*", "redis*", "elasticsearch*", "nginx*", "mod_ssl*",
"haproxy*", "certbot*", "python-certbot*", "docker*", "podman*",
"centreon*", "qwserver*", "ansible*", "node*", "tina*", "memcached*",
"nextcloud*", "pgbouncer*", "pgpool*", "pgbadger*", "psycopg2*",
"barman*", "kibana*", "splunk*",
]
def build_safe_excludes():
"""Construit la liste d'exclusions pour le safe patching"""
excludes = list(REBOOT_PACKAGES) + [e.replace("*", "") for e in STD_EXCLUDES]
return excludes
def build_yum_command(extra_excludes=None):
"""Génère la commande yum update safe"""
all_excludes = REBOOT_PACKAGES + STD_EXCLUDES
if extra_excludes:
all_excludes += extra_excludes
exclude_str = " ".join([f"--exclude={e}*" if not e.endswith("*") else f"--exclude={e}" for e in all_excludes])
return f"yum update {exclude_str} -y"
def create_quickwin_campaign(db, year, week_number, label, user_id, assistant_id=None):
"""Crée une campagne Quick Win avec les deux branches (hprod + prod)"""
from .campaign_service import _week_dates
wc = f"S{week_number:02d}"
lun, mar, mer, jeu = _week_dates(year, week_number)
row = db.execute(text("""
INSERT INTO campaigns (week_code, year, label, status, date_start, date_end,
created_by, campaign_type)
VALUES (:wc, :y, :label, 'draft', :ds, :de, :uid, 'quickwin')
RETURNING id
"""), {"wc": wc, "y": year, "label": label, "ds": lun, "de": jeu, "uid": user_id}).fetchone()
cid = row.id
# Tous les serveurs Linux en prod secops
servers = db.execute(text("""
SELECT s.id, s.hostname, e.name as env_name
FROM servers s
LEFT JOIN domain_environments de ON s.domain_env_id = de.id
LEFT JOIN environments e ON de.environment_id = e.id
WHERE s.etat = 'en_production' AND s.patch_os_owner = 'secops'
AND s.licence_support IN ('active', 'els') AND s.os_family = 'linux'
ORDER BY e.name, s.hostname
""")).fetchall()
for s in servers:
is_prod = (s.env_name == 'Production')
date_prevue = mer if is_prod else lun # hprod lundi, prod mercredi
db.execute(text("""
INSERT INTO patch_sessions (campaign_id, server_id, status, date_prevue,
intervenant_id, forced_assignment, assigned_at)
VALUES (:cid, :sid, 'pending', :dp, :uid, true, now())
ON CONFLICT (campaign_id, server_id) DO NOTHING
"""), {"cid": cid, "sid": s.id, "dp": date_prevue, "uid": user_id})
# Assigner l'assistant si défini
if assistant_id:
db.execute(text("""
INSERT INTO campaign_operator_limits (campaign_id, user_id, max_servers, note)
VALUES (:cid, :aid, 0, 'Assistant Quick Win')
"""), {"cid": cid, "aid": assistant_id})
count = db.execute(text(
"SELECT COUNT(*) FROM patch_sessions WHERE campaign_id = :cid"
), {"cid": cid}).scalar()
db.execute(text("UPDATE campaigns SET total_servers = :c WHERE id = :cid"),
{"c": count, "cid": cid})
db.commit()
return cid
def get_quickwin_stats(db, campaign_id):
"""Stats Quick Win par branche"""
return db.execute(text("""
SELECT
COUNT(*) FILTER (WHERE e.name != 'Production') as hprod_total,
COUNT(*) FILTER (WHERE e.name != 'Production' AND ps.status = 'patched') as hprod_patched,
COUNT(*) FILTER (WHERE e.name != 'Production' AND ps.status = 'failed') as hprod_failed,
COUNT(*) FILTER (WHERE e.name = 'Production') as prod_total,
COUNT(*) FILTER (WHERE e.name = 'Production' AND ps.status = 'patched') as prod_patched,
COUNT(*) FILTER (WHERE e.name = 'Production' AND ps.status = 'failed') as prod_failed,
COUNT(*) FILTER (WHERE ps.status = 'excluded') as excluded
FROM patch_sessions ps
JOIN servers s ON ps.server_id = s.id
LEFT JOIN domain_environments de ON s.domain_env_id = de.id
LEFT JOIN environments e ON de.environment_id = e.id
WHERE ps.campaign_id = :cid
"""), {"cid": campaign_id}).fetchone()

View File

@ -1,504 +0,0 @@
"""Service audit complet serveur — applicatif + reseau + correlation + carte flux
Adapte du standalone SANEF corrige pour PatchCenter (FastAPI/PostgreSQL)
"""
import json
import re
import os
import socket
import logging
from datetime import datetime
from sqlalchemy import text
logging.getLogger("paramiko").setLevel(logging.CRITICAL)
logging.getLogger("paramiko.transport").setLevel(logging.CRITICAL)
try:
import paramiko
PARAMIKO_OK = True
except ImportError:
PARAMIKO_OK = False
SSH_KEY_FILE = "/opt/patchcenter/keys/id_rsa_cybglobal.pem"
PSMP_HOST = "psmp.sanef.fr"
CYBR_USER = "CYBP01336"
TARGET_USER = "cybsecope"
SSH_TIMEOUT = 20
ENV_DOMAINS = {
"prod": ".sanef.groupe",
"preprod": ".sanef.groupe",
"recette": ".sanef-rec.fr",
"test": ".sanef-rec.fr",
"dev": ".sanef-rec.fr",
}
BANNER_FILTERS = [
"GROUPE SANEF", "propriete du Groupe", "accederait", "emprisonnement",
"Article 323", "code penal", "Authorized uses only", "CyberArk",
"This session", "session is being",
]
SCRIPT_PATH = os.path.join(os.path.dirname(os.path.abspath(__file__)), "..", "scripts", "server_audit.sh")
def _load_script():
with open(SCRIPT_PATH, "r", encoding="utf-8") as f:
return f.read()
def _get_psmp_password(db=None):
if not db:
return None
try:
from .secrets_service import get_secret
return get_secret(db, "ssh_pwd_default_pass")
except Exception:
return None
# ── DETECTION ENV + SSH (pattern SANEF corrige) ──
def detect_env(hostname):
h = hostname.lower()
c = h[1] if len(h) > 1 else ""
if c == "p": return "prod"
elif c == "i": return "preprod"
elif c == "r": return "recette"
elif c == "v": return "test"
elif c == "d": return "dev"
return "recette"
def _load_key():
if not os.path.exists(SSH_KEY_FILE):
return None
for cls in [paramiko.RSAKey, paramiko.Ed25519Key, paramiko.ECDSAKey]:
try:
return cls.from_private_key_file(SSH_KEY_FILE)
except Exception:
continue
return None
def _build_fqdn_candidates(hostname):
if "." in hostname:
return [hostname]
c = hostname[1] if len(hostname) > 1 else ""
if c in ("p", "i"):
return [f"{hostname}.sanef.groupe", f"{hostname}.sanef-rec.fr", hostname]
else:
return [f"{hostname}.sanef-rec.fr", f"{hostname}.sanef.groupe", hostname]
def _try_psmp(fqdn, password):
if not password:
return None
try:
username = f"{CYBR_USER}@{TARGET_USER}@{fqdn}"
transport = paramiko.Transport((PSMP_HOST, 22))
transport.connect()
def handler(title, instructions, prompt_list):
return [password] * len(prompt_list)
transport.auth_interactive(username, handler)
client = paramiko.SSHClient()
client._transport = transport
return client
except Exception:
return None
def _try_key(fqdn, key):
if not key:
return None
try:
client = paramiko.SSHClient()
client.set_missing_host_key_policy(paramiko.AutoAddPolicy())
client.connect(fqdn, port=22, username=TARGET_USER, pkey=key,
timeout=SSH_TIMEOUT, look_for_keys=False, allow_agent=False)
return client
except Exception:
return None
def ssh_connect(hostname, password=None):
fqdn_candidates = _build_fqdn_candidates(hostname)
key = _load_key()
for fqdn in fqdn_candidates:
if password:
client = _try_psmp(fqdn, password)
if client:
return client, None
if key:
client = _try_key(fqdn, key)
if client:
return client, None
return None, f"Connexion impossible sur {fqdn_candidates}"
def ssh_run_script(client, script_content, timeout=300):
try:
chan = client._transport.open_session()
chan.settimeout(timeout)
chan.exec_command("bash -s")
chan.sendall(script_content.encode("utf-8"))
chan.shutdown_write()
out = b""
while True:
try:
chunk = chan.recv(8192)
if not chunk:
break
out += chunk
except Exception:
break
chan.close()
out_str = out.decode("utf-8", errors="replace")
if not out_str.strip():
return "", "Sortie vide"
lines = [l for l in out_str.splitlines() if not any(b in l for b in BANNER_FILTERS)]
return "\n".join(lines), None
except Exception as e:
return "", str(e)
# ── PARSING ──
def parse_audit_output(raw):
result = {
"hostname": "", "os_release": "", "kernel": "", "uptime": "",
"services": [], "processes": [], "services_failed": "",
"needs_restarting": "", "reboot_required": False, "disk_usage": [],
"interfaces": [], "routes": [], "listen_ports": [],
"connections": [], "flux_in": [], "flux_out": [],
"conn_wait": [], "net_stats": {}, "traffic": [],
"firewall": {"policy": {}, "input": [], "output": [], "firewalld": []},
"correlation_matrix": [], "outbound_only": [],
}
section = None
firewall_sub = None
for line in raw.splitlines():
ls = line.strip()
m = re.match(r"^# AUDIT COMPLET .+ (.+)$", ls)
if m: result["hostname"] = m.group(1); continue
m = re.match(r"^# OS: (.+)$", ls)
if m: result["os_release"] = m.group(1); continue
m = re.match(r"^# Kernel: (.+)$", ls)
if m: result["kernel"] = m.group(1); continue
m = re.match(r"^# Uptime: (.+)$", ls)
if m: result["uptime"] = m.group(1); continue
if "1.1 SERVICES APPLICATIFS" in ls: section = "services"; continue
elif "1.2 PROCESSUS APPLICATIFS" in ls: section = "processes"; continue
elif "1.3 SERVICES EN ECHEC" in ls: section = "services_failed"; continue
elif "1.4 NEEDS-RESTARTING" in ls: section = "needs_restarting"; continue
elif "1.5 ESPACE DISQUE" in ls: section = "disk"; continue
elif "2.1 INTERFACES" in ls: section = "interfaces"; continue
elif "2.2 TABLE DE ROUTAGE" in ls: section = "routes"; continue
elif "2.3 PORTS EN ECOUTE" in ls: section = "listen_ports"; continue
elif "2.4 CONNEXIONS ETABLIES" in ls: section = "connections"; continue
elif "2.5 RESUME FLUX ENTRANTS" in ls: section = "flux_in"; continue
elif "2.6 RESUME FLUX SORTANTS" in ls: section = "flux_out"; continue
elif "2.7 CONNEXIONS EN ATTENTE" in ls: section = "conn_wait"; continue
elif "2.8 STATISTIQUES" in ls: section = "net_stats"; continue
elif "2.9 TRAFIC" in ls: section = "traffic"; continue
elif "2.10 FIREWALL" in ls: section = "firewall"; firewall_sub = None; continue
elif "3.1 MATRICE" in ls: section = "correlation"; continue
elif "3.2 PROCESS SORTANTS" in ls: section = "outbound"; continue
elif ls.startswith("===") or ls.startswith("###"): section = None; continue
if not ls: continue
headers = ["SERVICE|","PID|PPID","PROTO|","DIRECTION|","PORT|","DEST_IP|",
"METRIC|","INTERFACE|","DESTINATION|","NUM|","PROCESS|USER",
"ZONE|","Mont","STATE|COUNT"]
if any(ls.startswith(h) for h in headers): continue
parts = ls.split("|")
if section == "services" and len(parts) >= 2:
result["services"].append({"name":parts[0],"enabled":parts[1],"pid":parts[2] if len(parts)>2 else "","user":parts[3] if len(parts)>3 else "","exec":parts[4] if len(parts)>4 else ""})
elif section == "processes" and len(parts) >= 6:
result["processes"].append({"pid":parts[0],"ppid":parts[1],"user":parts[2],"exe":parts[3],"cwd":parts[4],"cmdline":parts[5],"restart_hint":parts[6] if len(parts)>6 else ""})
elif section == "services_failed":
if ls != "Aucun service en echec": result["services_failed"] += ls + "\n"
elif section == "needs_restarting":
result["needs_restarting"] += ls + "\n"
if "EXIT_CODE=1" in ls: result["reboot_required"] = True
elif section == "disk":
p = ls.split()
if len(p) >= 5 and "%" in p[-1]:
try: result["disk_usage"].append({"mount":p[0],"size":p[1],"used":p[2],"avail":p[3],"pct":int(p[4].replace("%",""))})
except: pass
elif section == "interfaces" and len(parts) >= 3:
result["interfaces"].append({"iface":parts[0],"ip":parts[1],"mask":parts[2],"state":parts[3] if len(parts)>3 else "","mac":parts[4] if len(parts)>4 else ""})
elif section == "routes" and len(parts) >= 3:
result["routes"].append({"dest":parts[0],"gw":parts[1],"iface":parts[2],"metric":parts[3] if len(parts)>3 else ""})
elif section == "listen_ports" and len(parts) >= 3:
result["listen_ports"].append({"proto":parts[0],"addr_port":parts[1],"pid":parts[2],"process":parts[3] if len(parts)>3 else "","user":parts[4] if len(parts)>4 else "","service":parts[5] if len(parts)>5 else ""})
elif section == "connections" and len(parts) >= 5:
result["connections"].append({"direction":parts[0],"proto":parts[1],"local":parts[2],"remote":parts[3],"pid":parts[4],"process":parts[5] if len(parts)>5 else "","user":parts[6] if len(parts)>6 else "","state":parts[7] if len(parts)>7 else ""})
elif section == "flux_in" and len(parts) >= 3:
result["flux_in"].append({"port":parts[0],"service":parts[1],"process":parts[2],"count":parts[3] if len(parts)>3 else "0","sources":parts[4] if len(parts)>4 else ""})
elif section == "flux_out" and len(parts) >= 3:
result["flux_out"].append({"dest_ip":parts[0],"dest_port":parts[1],"service":parts[2],"process":parts[3] if len(parts)>3 else "","count":parts[4] if len(parts)>4 else "1"})
elif section == "conn_wait" and len(parts) == 2:
result["conn_wait"].append({"state":parts[0],"count":parts[1]})
elif section == "net_stats" and len(parts) == 2:
result["net_stats"][parts[0].strip()] = parts[1].strip()
elif section == "traffic" and len(parts) >= 5:
result["traffic"].append({"iface":parts[0],"rx_bytes":parts[1],"rx_pkt":parts[2],"rx_err":parts[3],"tx_bytes":parts[4],"tx_pkt":parts[5] if len(parts)>5 else "","tx_err":parts[6] if len(parts)>6 else ""})
elif section == "firewall":
if "POLICY" in ls: firewall_sub = "policy"; continue
elif "INPUT" in ls and "---" in ls: firewall_sub = "input"; continue
elif "OUTPUT" in ls and "---" in ls: firewall_sub = "output"; continue
elif "FIREWALLD" in ls: firewall_sub = "firewalld"; continue
if firewall_sub == "policy" and len(parts) == 2: result["firewall"]["policy"][parts[0]] = parts[1]
elif firewall_sub == "input" and len(parts) >= 3: result["firewall"]["input"].append(ls)
elif firewall_sub == "output" and len(parts) >= 3: result["firewall"]["output"].append(ls)
elif firewall_sub == "firewalld" and len(parts) >= 2: result["firewall"]["firewalld"].append({"zone":parts[0],"services":parts[1],"ports":parts[2] if len(parts)>2 else ""})
elif section == "correlation" and len(parts) >= 4:
result["correlation_matrix"].append({"process":parts[0],"user":parts[1],"pid":parts[2],"listen_ports":parts[3],"conn_in":parts[4] if len(parts)>4 else "0","conn_out":parts[5] if len(parts)>5 else "0","remote_dests":parts[6] if len(parts)>6 else ""})
elif section == "outbound" and len(parts) >= 3:
result["outbound_only"].append({"process":parts[0],"user":parts[1],"pid":parts[2],"dests":parts[3] if len(parts)>3 else ""})
result["services_failed"] = result["services_failed"].strip()
result["needs_restarting"] = result["needs_restarting"].strip()
return result
# ── STOCKAGE DB ──
def _resolve_server_id(db, hostname):
srv = db.execute(text(
"SELECT id FROM servers WHERE LOWER(hostname) = LOWER(:h)"
), {"h": hostname.split(".")[0]}).fetchone()
return srv.id if srv else None
def _resolve_dest_server(db, dest_ip):
# Nettoyer l'IP (retirer IPv6-mapped prefix, brackets)
clean_ip = dest_ip.replace("[::ffff:", "").replace("]", "").strip()
if not clean_ip or ":" in clean_ip:
return (None, None) # IPv6 pure, skip
try:
row = db.execute(text("""
SELECT s.id, s.hostname FROM servers s
JOIN server_ips si ON s.id = si.server_id
WHERE si.ip_address = CAST(:ip AS inet)
LIMIT 1
"""), {"ip": clean_ip}).fetchone()
return (row.id, row.hostname) if row else (None, None)
except Exception:
return (None, None)
def save_audit_to_db(db, parsed, raw_output="", status="ok", error_msg=None):
hostname = parsed.get("hostname", "")
if not hostname:
return None
server_id = _resolve_server_id(db, hostname)
row = db.execute(text("""
INSERT INTO server_audit_full (
server_id, hostname, audit_date, os_release, kernel, uptime,
services, processes, services_failed, needs_restarting, reboot_required,
disk_usage, interfaces, routes, listen_ports, connections,
flux_in, flux_out, conn_wait, net_stats, traffic, firewall,
correlation_matrix, outbound_only, raw_output, status, error_msg
) VALUES (
:sid, :hn, NOW(), :os, :k, :up,
:svc, :proc, :sf, :nr, :rr,
:du, :iface, :rt, :lp, :conn,
:fi, :fo, :cw, :ns, :tr, :fw,
:cm, :ob, :raw, :st, :err
) RETURNING id
"""), {
"sid": server_id, "hn": hostname,
"os": parsed.get("os_release", ""), "k": parsed.get("kernel", ""),
"up": parsed.get("uptime", ""),
"svc": json.dumps(parsed.get("services", [])),
"proc": json.dumps(parsed.get("processes", [])),
"sf": parsed.get("services_failed", ""),
"nr": parsed.get("needs_restarting", ""),
"rr": parsed.get("reboot_required", False),
"du": json.dumps(parsed.get("disk_usage", [])),
"iface": json.dumps(parsed.get("interfaces", [])),
"rt": json.dumps(parsed.get("routes", [])),
"lp": json.dumps(parsed.get("listen_ports", [])),
"conn": json.dumps(parsed.get("connections", [])),
"fi": json.dumps(parsed.get("flux_in", [])),
"fo": json.dumps(parsed.get("flux_out", [])),
"cw": json.dumps(parsed.get("conn_wait", [])),
"ns": json.dumps(parsed.get("net_stats", {})),
"tr": json.dumps(parsed.get("traffic", [])),
"fw": json.dumps(parsed.get("firewall", {})),
"cm": json.dumps(parsed.get("correlation_matrix", [])),
"ob": json.dumps(parsed.get("outbound_only", [])),
"raw": raw_output, "st": status, "err": error_msg,
}).fetchone()
audit_id = row.id
_build_flow_map(db, audit_id, hostname, server_id, parsed)
return audit_id
def _build_flow_map(db, audit_id, hostname, server_id, parsed):
local_ips = [i["ip"] for i in parsed.get("interfaces", []) if i["ip"] != "127.0.0.1"]
source_ip = local_ips[0] if local_ips else ""
for conn in parsed.get("connections", []):
remote = conn.get("remote", "")
m = re.match(r'^(.+):(\d+)$', remote)
if not m:
continue
dest_ip = m.group(1)
dest_port = int(m.group(2))
if dest_ip.startswith("127.") or dest_ip == "::1":
continue
dest_server_id, dest_hostname = _resolve_dest_server(db, dest_ip)
db.execute(text("""
INSERT INTO network_flow_map (
audit_id, source_server_id, source_hostname, source_ip,
dest_ip, dest_port, dest_hostname, dest_server_id,
process_name, process_user, direction,
connection_count, state, audit_date
) VALUES (
:aid, :ssid, :shn, :sip,
:dip, :dp, :dhn, :dsid,
:pn, :pu, :dir, 1, :st, NOW()
)
"""), {
"aid": audit_id, "ssid": server_id, "shn": hostname, "sip": source_ip,
"dip": dest_ip, "dp": dest_port, "dhn": dest_hostname, "dsid": dest_server_id,
"pn": conn.get("process", ""), "pu": conn.get("user", ""),
"dir": conn.get("direction", ""), "st": conn.get("state", ""),
})
# ── IMPORT JSON (depuis standalone) ──
def import_json_report(db, json_data):
servers = json_data.get("servers", [])
imported = 0
errors = 0
for srv in servers:
if srv.get("status") == "error":
errors += 1
continue
hostname = srv.get("hostname", "")
if not hostname:
continue
parsed = {k: srv.get(k, v) for k, v in {
"hostname": "", "os_release": "", "kernel": "", "uptime": "",
"services": [], "processes": [], "services_failed": "",
"needs_restarting": "", "reboot_required": False, "disk_usage": [],
"interfaces": [], "routes": [], "listen_ports": [],
"connections": [], "flux_in": [], "flux_out": [],
"conn_wait": [], "net_stats": {}, "traffic": [],
"firewall": {}, "correlation_matrix": [], "outbound_only": [],
}.items()}
save_audit_to_db(db, parsed)
imported += 1
db.commit()
return imported, errors
# ── REQUETES ──
def get_latest_audits(db, limit=100):
return db.execute(text("""
SELECT DISTINCT ON (hostname) id, server_id, hostname, audit_date,
os_release, kernel, uptime, status, reboot_required,
last_patch_date, last_patch_week, last_patch_year,
jsonb_array_length(COALESCE(services, '[]')) as svc_count,
jsonb_array_length(COALESCE(listen_ports, '[]')) as port_count,
jsonb_array_length(COALESCE(connections, '[]')) as conn_count,
jsonb_array_length(COALESCE(processes, '[]')) as proc_count
FROM server_audit_full
WHERE status IN ('ok','partial')
ORDER BY hostname, audit_date DESC
LIMIT :lim
"""), {"lim": limit}).fetchall()
def get_audit_detail(db, audit_id):
return db.execute(text(
"SELECT * FROM server_audit_full WHERE id = :id"
), {"id": audit_id}).fetchone()
def get_flow_map(db):
return db.execute(text("""
SELECT source_hostname, source_ip, dest_ip, dest_port,
dest_hostname, process_name, direction, state,
COUNT(*) as cnt
FROM network_flow_map nfm
JOIN server_audit_full saf ON nfm.audit_id = saf.id
WHERE saf.id IN (
SELECT DISTINCT ON (hostname) id FROM server_audit_full
WHERE status IN ('ok','partial') ORDER BY hostname, audit_date DESC
)
GROUP BY source_hostname, source_ip, dest_ip, dest_port,
dest_hostname, process_name, direction, state
ORDER BY source_hostname
""")).fetchall()
def get_flow_map_for_server(db, hostname):
return db.execute(text("""
SELECT source_hostname, source_ip, dest_ip, dest_port,
dest_hostname, process_name, direction, state
FROM network_flow_map
WHERE audit_id = (
SELECT id FROM server_audit_full WHERE hostname = :h
ORDER BY audit_date DESC LIMIT 1
)
ORDER BY direction DESC, dest_ip
"""), {"h": hostname}).fetchall()
def get_flow_map_for_domain(db, domain_code):
return db.execute(text("""
SELECT nfm.source_hostname, nfm.source_ip, nfm.dest_ip, nfm.dest_port,
nfm.dest_hostname, nfm.process_name, nfm.direction, nfm.state
FROM network_flow_map nfm
JOIN server_audit_full saf ON nfm.audit_id = saf.id
JOIN servers s ON saf.server_id = s.id
JOIN domain_environments de ON s.domain_env_id = de.id
JOIN domains d ON de.domain_id = d.id
WHERE d.code = :dc
AND saf.id IN (
SELECT DISTINCT ON (hostname) id FROM server_audit_full
WHERE status IN ('ok','partial') ORDER BY hostname, audit_date DESC
)
ORDER BY nfm.source_hostname
"""), {"dc": domain_code}).fetchall()
def get_app_map(db):
audits = db.execute(text("""
SELECT DISTINCT ON (hostname) hostname, server_id, processes, listen_ports
FROM server_audit_full WHERE status IN ('ok','partial')
ORDER BY hostname, audit_date DESC
""")).fetchall()
app_groups = {}
for audit in audits:
processes = audit.processes if isinstance(audit.processes, list) else json.loads(audit.processes or "[]")
for proc in processes:
cwd = proc.get("cwd", "")
m = re.search(r'/applis/([^/]+)', cwd)
if not m:
continue
app_name = m.group(1)
if app_name not in app_groups:
app_groups[app_name] = {"servers": [], "ports": set()}
if audit.hostname not in [s["hostname"] for s in app_groups[app_name]["servers"]]:
app_groups[app_name]["servers"].append({
"hostname": audit.hostname,
"server_id": audit.server_id,
"user": proc.get("user", ""),
"cmdline": proc.get("cmdline", "")[:100],
"restart_hint": proc.get("restart_hint", "")[:100],
})
listen = audit.listen_ports if isinstance(audit.listen_ports, list) else json.loads(audit.listen_ports or "[]")
pid = proc.get("pid", "")
for lp in listen:
if lp.get("pid") == pid:
app_groups[app_name]["ports"].add(lp.get("addr_port", ""))
for k in app_groups:
app_groups[k]["ports"] = list(app_groups[k]["ports"])
return app_groups

View File

@ -115,15 +115,17 @@ 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"] == "eol":
where.append("s.licence_support = 'eol'")
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, '') != 'eol'")
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("search"):
where.append("s.hostname ILIKE :search"); params["search"] = f"%{filters['search']}%"
@ -136,14 +138,20 @@ def list_servers(db, filters, page=1, per_page=50, sort="hostname", sort_dir="as
SELECT s.id, s.hostname, s.fqdn, d.name as domaine, e.name as environnement,
z.name as zone, s.os_family, s.os_version, s.tier, s.etat,
s.licence_support, s.patch_os_owner, s.responsable_nom, s.machine_type,
s.application_name,
CASE
WHEN s.os_version ILIKE '%Red Hat%' THEN
'Red Hat ' || COALESCE((regexp_match(s.os_version, '(\d+\.\d+)'))[1], '')
WHEN s.os_version ILIKE '%Oracle%Linux%' THEN
'Oracle ' || COALESCE((regexp_match(s.os_version, '(\d+\.\d+)'))[1], '')
WHEN s.os_version ILIKE '%CentOS Stream%' THEN
'CentOS Stream ' || COALESCE((regexp_match(s.os_version, '(\d+[\.\d]*)'))[1], '')
WHEN s.os_version ILIKE '%CentOS%' THEN
'CentOS ' || COALESCE((regexp_match(s.os_version, '(\d+\.\d[\d.]*)'))[1], '')
WHEN s.os_version ILIKE '%Ubuntu%' THEN 'Ubuntu'
'CentOS ' || COALESCE((regexp_match(s.os_version, '(\d+[\.\d]*)'))[1], '')
WHEN s.os_version ILIKE '%Debian%' THEN
'Debian ' || COALESCE((regexp_match(s.os_version, '(\d+)'))[1], '')
WHEN s.os_version ILIKE '%Ubuntu%' THEN
'Ubuntu ' || COALESCE((regexp_match(s.os_version, '(\d+\.\d+)'))[1], '')
WHEN s.os_version ILIKE '%Windows Server 2022 Standard%' THEN '2022 Standard'
WHEN s.os_version ILIKE '%Windows Server 2022 Datacenter%' THEN '2022 Datacenter'
WHEN s.os_version ILIKE '%Windows Server 2019 Standard%' THEN '2019 Standard'

View File

@ -1,241 +0,0 @@
{% extends 'base.html' %}
{% block title %}{{ a.hostname }}{% endblock %}
{% block content %}
<a href="/audit-full" class="text-xs text-gray-500 hover:text-gray-300">< Retour</a>
<div class="flex justify-between items-center mb-4">
<div>
<h2 class="text-xl font-bold text-cyber-accent">{{ a.hostname }}</h2>
<p class="text-xs text-gray-500">{{ a.os_release }} | {{ a.kernel }} | {{ a.uptime }}</p>
</div>
<div class="flex gap-2 text-xs">
{% for iface in interfaces %}{% if iface.ip != '127.0.0.1' %}
<span class="badge badge-blue">{{ iface.ip }}{{ iface.mask }} ({{ iface.iface }})</span>
{% endif %}{% endfor %}
</div>
</div>
{% if is_partial %}
<div class="card p-8 text-center mb-4" style="background:#111827;">
<div class="text-3xl font-bold text-gray-500 mb-2">Pas encore audité</div>
<p class="text-sm text-gray-600">Ce serveur n'a pas encore été audité via SSH (Windows, EMV...)</p>
<p class="text-xs text-gray-600 mt-2">{{ a.hostname }} — {{ a.os_release or 'OS inconnu' }}</p>
{% if a.last_patch_week %}<p class="text-xs text-cyber-green mt-2">Dernier patch : {{ a.last_patch_week }} {{ a.last_patch_year }}</p>{% endif %}
</div>
{% else %}
<!-- KPI -->
<div style="display:flex;flex-wrap:nowrap;gap:8px;margin-bottom:16px;">
<div class="card p-2 text-center" style="flex:1;min-width:0"><div class="text-base font-bold text-cyber-accent">{{ services|length }}</div><div class="text-xs text-gray-500">Services</div></div>
<div class="card p-2 text-center" style="flex:1;min-width:0"><div class="text-base font-bold text-cyber-accent">{{ processes|length }}</div><div class="text-xs text-gray-500">Process</div></div>
<div class="card p-2 text-center" style="flex:1;min-width:0"><div class="text-base font-bold text-cyber-accent">{{ listen_ports|length }}</div><div class="text-xs text-gray-500">Ports</div></div>
<div class="card p-2 text-center" style="flex:1;min-width:0"><div class="text-base font-bold text-cyber-accent">{{ connections|length }}</div><div class="text-xs text-gray-500">Connexions</div></div>
<div class="card p-2 text-center" style="flex:1;min-width:0"><div class="text-base font-bold {% if a.reboot_required %}text-cyber-red{% else %}text-cyber-green{% endif %}">{% if a.reboot_required %}Oui{% else %}Non{% endif %}</div><div class="text-xs text-gray-500">Reboot</div></div>
<div class="card p-2 text-center" style="flex:1;min-width:0"><div class="text-base font-bold {% if a.services_failed %}text-cyber-red{% else %}text-cyber-green{% endif %}">{% if a.services_failed %}KO{% else %}OK{% endif %}</div><div class="text-xs text-gray-500">Failed svc</div></div>
</div>
<!-- Onglets -->
<div x-data="{ tab: 'services' }">
<div class="flex gap-1 mb-3 flex-wrap">
{% for t, label in [('services','Services'),('processes','Processus'),('ports','Ports'),('connections','Connexions'),('flux','Flux'),('disk','Disque'),('firewall','Firewall'),('correlation','Corrélation')] %}
<button @click="tab='{{ t }}'" class="px-3 py-1 text-xs rounded" :class="tab==='{{ t }}' ? 'bg-cyber-accent text-black font-bold' : 'bg-cyber-border text-gray-400'">{{ label }}</button>
{% endfor %}
</div>
<!-- Services -->
<div x-show="tab==='services'" class="card overflow-x-auto">
<table class="w-full table-cyber text-xs"><thead><tr>
<th class="text-left p-2">Service</th><th class="p-2">Enabled</th><th class="p-2">PID</th><th class="p-2">User</th><th class="text-left p-2">Exec</th>
</tr></thead><tbody>
{% for s in services %}<tr>
<td class="p-2 font-mono text-cyber-accent">{{ s.name }}</td>
<td class="p-2 text-center"><span class="badge {% if s.enabled == 'enabled' %}badge-green{% else %}badge-gray{% endif %}">{{ s.enabled }}</span></td>
<td class="p-2 text-center font-mono">{{ s.pid }}</td>
<td class="p-2 text-center">{{ s.user }}</td>
<td class="p-2 text-gray-400">{{ s.exec[:80] }}</td>
</tr>{% endfor %}
</tbody></table>
</div>
<!-- Processus -->
<div x-show="tab==='processes'" class="card overflow-x-auto">
<table class="w-full table-cyber text-xs"><thead><tr>
<th class="p-2">PID</th><th class="p-2">User</th><th class="text-left p-2">Exe</th><th class="text-left p-2">CWD</th><th class="text-left p-2">Cmdline</th><th class="text-left p-2">Restart</th>
</tr></thead><tbody>
{% for p in processes %}<tr class="{% if '/applis' in (p.cwd or '') %}bg-green-900/10{% endif %}">
<td class="p-2 font-mono text-center">{{ p.pid }}</td>
<td class="p-2 text-center">{{ p.user }}</td>
<td class="p-2 font-mono text-gray-400" style="max-width:200px;word-break:break-all;">{{ p.exe or '' }}</td>
<td class="p-2 font-mono text-gray-500" style="max-width:180px;word-break:break-all;">{{ p.cwd or '' }}</td>
<td class="p-2 font-mono text-cyber-accent" style="max-width:300px;word-break:break-all;">{{ p.cmdline or '' }}</td>
<td class="p-2 font-mono text-cyber-yellow" style="word-break:break-all;">{{ p.restart_hint or '' }}</td>
</tr>{% endfor %}
</tbody></table>
</div>
<!-- Ports -->
<div x-show="tab==='ports'" class="card overflow-x-auto">
<table class="w-full table-cyber text-xs"><thead><tr>
<th class="p-2">Proto</th><th class="p-2">Addr:Port</th><th class="p-2">PID</th><th class="p-2">Process</th><th class="p-2">User</th><th class="p-2">Service</th>
</tr></thead><tbody>
{% for lp in listen_ports %}<tr>
<td class="p-2 text-center">{{ lp.proto }}</td>
<td class="p-2 font-mono text-cyber-accent">{{ lp.addr_port }}</td>
<td class="p-2 text-center font-mono">{{ lp.pid }}</td>
<td class="p-2 text-center">{{ lp.process }}</td>
<td class="p-2 text-center">{{ lp.user }}</td>
<td class="p-2 text-center text-gray-400">{{ lp.service }}</td>
</tr>{% endfor %}
</tbody></table>
</div>
<!-- Connexions -->
<div x-show="tab==='connections'" class="card overflow-x-auto">
<table class="w-full table-cyber text-xs"><thead><tr>
<th class="p-2">Dir</th><th class="p-2">Local</th><th class="p-2">Remote</th><th class="p-2">PID</th><th class="p-2">Process</th><th class="p-2">User</th><th class="p-2">State</th>
</tr></thead><tbody>
{% for c in connections %}<tr class="{% if c.state == 'CLOSE-WAIT' %}bg-red-900/10{% endif %}">
<td class="p-2 text-center"><span class="badge {% if c.direction == 'IN' %}badge-green{% else %}badge-yellow{% endif %}">{{ c.direction }}</span></td>
<td class="p-2 font-mono">{{ c.local }}</td>
<td class="p-2 font-mono text-cyber-accent">{{ c.remote }}</td>
<td class="p-2 text-center font-mono">{{ c.pid }}</td>
<td class="p-2 text-center">{{ c.process }}</td>
<td class="p-2 text-center">{{ c.user }}</td>
<td class="p-2 text-center"><span class="badge {% if c.state == 'ESTAB' %}badge-green{% elif c.state == 'CLOSE-WAIT' %}badge-red{% else %}badge-gray{% endif %}">{{ c.state }}</span></td>
</tr>{% endfor %}
</tbody></table>
</div>
<!-- Flux -->
<div x-show="tab==='flux'" class="space-y-3">
{% if flux_in %}
<div class="card overflow-x-auto">
<div class="p-2 border-b border-cyber-border"><span class="text-xs font-bold text-cyber-green">Flux entrants</span></div>
<table class="w-full table-cyber text-xs"><thead><tr>
<th class="p-2">Port</th><th class="p-2">Service</th><th class="p-2">Process</th><th class="p-2">Nb</th><th class="text-left p-2">Sources</th>
</tr></thead><tbody>
{% for f in flux_in %}<tr>
<td class="p-2 text-center font-mono text-cyber-accent">{{ f.port }}</td>
<td class="p-2 text-center">{{ f.service }}</td>
<td class="p-2 text-center">{{ f.process }}</td>
<td class="p-2 text-center font-bold">{{ f.count }}</td>
<td class="p-2 font-mono text-gray-400">{{ f.sources }}</td>
</tr>{% endfor %}
</tbody></table>
</div>
{% endif %}
{% if flux_out %}
<div class="card overflow-x-auto">
<div class="p-2 border-b border-cyber-border"><span class="text-xs font-bold text-cyber-yellow">Flux sortants</span></div>
<table class="w-full table-cyber text-xs"><thead><tr>
<th class="text-left p-2">Destination</th><th class="p-2">Port</th><th class="p-2">Service</th><th class="p-2">Process</th><th class="p-2">Nb</th>
</tr></thead><tbody>
{% for f in flux_out %}<tr>
<td class="p-2 font-mono text-cyber-accent">{{ f.dest_ip }}</td>
<td class="p-2 text-center">{{ f.dest_port }}</td>
<td class="p-2 text-center">{{ f.service }}</td>
<td class="p-2 text-center">{{ f.process }}</td>
<td class="p-2 text-center font-bold">{{ f.count }}</td>
</tr>{% endfor %}
</tbody></table>
</div>
{% endif %}
{% if flows %}
<div class="card overflow-x-auto">
<div class="p-2 border-b border-cyber-border"><span class="text-xs font-bold text-cyber-accent">Carte flux (resolu)</span></div>
<table class="w-full table-cyber text-xs"><thead><tr>
<th class="p-2">Dir</th><th class="p-2">IP dest</th><th class="p-2">Port</th><th class="p-2">Serveur dest</th><th class="p-2">Process</th><th class="p-2">State</th>
</tr></thead><tbody>
{% for f in flows %}<tr>
<td class="p-2 text-center"><span class="badge {% if f.direction == 'IN' %}badge-green{% else %}badge-yellow{% endif %}">{{ f.direction }}</span></td>
<td class="p-2 font-mono">{{ f.dest_ip }}</td>
<td class="p-2 text-center">{{ f.dest_port }}</td>
<td class="p-2 font-mono text-cyber-accent">{{ f.dest_hostname or '-' }}</td>
<td class="p-2 text-center">{{ f.process_name }}</td>
<td class="p-2 text-center"><span class="badge {% if f.state == 'ESTAB' %}badge-green{% elif f.state == 'CLOSE-WAIT' %}badge-red{% else %}badge-gray{% endif %}">{{ f.state }}</span></td>
</tr>{% endfor %}
</tbody></table>
</div>
{% endif %}
</div>
<!-- Disque -->
<div x-show="tab==='disk'" class="card overflow-x-auto">
<table class="w-full table-cyber text-xs"><thead><tr>
<th class="text-left p-2">Mount</th><th class="p-2">Taille</th><th class="p-2">Utilise</th><th class="p-2">Dispo</th><th class="p-2">%</th>
</tr></thead><tbody>
{% for d in disk_usage %}<tr class="{% if d.pct >= 90 %}bg-red-900/20{% elif d.pct >= 80 %}bg-yellow-900/10{% endif %}">
<td class="p-2 font-mono">{{ d.mount }}</td>
<td class="p-2 text-center">{{ d.size }}</td>
<td class="p-2 text-center">{{ d.used }}</td>
<td class="p-2 text-center">{{ d.avail }}</td>
<td class="p-2 text-center font-bold {% if d.pct >= 90 %}text-cyber-red{% elif d.pct >= 80 %}text-cyber-yellow{% else %}text-cyber-green{% endif %}">{{ d.pct }}%</td>
</tr>{% endfor %}
</tbody></table>
</div>
<!-- Firewall -->
<div x-show="tab==='firewall'" class="space-y-3">
{% if firewall.policy %}
<div class="card p-3">
<span class="text-xs font-bold text-cyber-accent">Policy iptables :</span>
{% for chain, pol in firewall.policy.items() %}
<span class="badge {% if pol == 'DROP' %}badge-red{% elif pol == 'ACCEPT' %}badge-green{% else %}badge-gray{% endif %} ml-2">{{ chain }}={{ pol or '?' }}</span>
{% endfor %}
</div>
{% endif %}
{% if firewall.firewalld %}
<div class="card p-3">
<span class="text-xs font-bold text-cyber-accent">Firewalld :</span>
{% for z in firewall.firewalld %}
<div class="mt-1 text-xs">Zone <span class="text-cyber-yellow">{{ z.zone }}</span> : services={{ z.services }} ports={{ z.ports }}</div>
{% endfor %}
</div>
{% endif %}
{% if conn_wait %}
<div class="card p-3">
<span class="text-xs font-bold text-cyber-accent">Connexions en attente :</span>
{% for cw in conn_wait %}
<span class="badge {% if cw.state == 'CLOSE-WAIT' %}badge-red{% else %}badge-gray{% endif %} ml-2">{{ cw.state }}={{ cw.count }}</span>
{% endfor %}
</div>
{% endif %}
</div>
<!-- Corrélation -->
<div x-show="tab==='correlation'" class="space-y-3">
{% if correlation %}
<div class="card overflow-x-auto">
<div class="p-2 border-b border-cyber-border"><span class="text-xs font-bold text-cyber-accent">Matrice process / ports / flux</span></div>
<table class="w-full table-cyber text-xs"><thead><tr>
<th class="text-left p-2">Process</th><th class="p-2">User</th><th class="p-2">PID</th><th class="p-2">Ports</th><th class="p-2">IN</th><th class="p-2">OUT</th><th class="text-left p-2">Destinations</th>
</tr></thead><tbody>
{% for c in correlation %}<tr>
<td class="p-2 font-mono text-cyber-accent">{{ c.process }}</td>
<td class="p-2 text-center">{{ c.user }}</td>
<td class="p-2 text-center font-mono">{{ c.pid }}</td>
<td class="p-2 text-center font-mono text-cyber-yellow">{{ c.listen_ports }}</td>
<td class="p-2 text-center font-bold text-cyber-green">{{ c.conn_in }}</td>
<td class="p-2 text-center font-bold text-cyber-yellow">{{ c.conn_out }}</td>
<td class="p-2 font-mono text-gray-400">{{ c.remote_dests }}</td>
</tr>{% endfor %}
</tbody></table>
</div>
{% endif %}
{% if outbound %}
<div class="card overflow-x-auto">
<div class="p-2 border-b border-cyber-border"><span class="text-xs font-bold text-cyber-yellow">Process sortants uniquement</span></div>
<table class="w-full table-cyber text-xs"><thead><tr>
<th class="text-left p-2">Process</th><th class="p-2">User</th><th class="p-2">PID</th><th class="text-left p-2">Destinations</th>
</tr></thead><tbody>
{% for o in outbound %}<tr>
<td class="p-2 font-mono text-cyber-accent">{{ o.process }}</td>
<td class="p-2 text-center">{{ o.user }}</td>
<td class="p-2 text-center font-mono">{{ o.pid }}</td>
<td class="p-2 font-mono text-gray-400">{{ o.dests }}</td>
</tr>{% endfor %}
</tbody></table>
</div>
{% endif %}
</div>
</div>
{% endif %}{# end is_partial else #}
{% endblock %}

View File

@ -1,282 +0,0 @@
{% extends 'base.html' %}
{% block title %}Carte flux{% endblock %}
{% block content %}
<a href="/audit-full" class="text-xs text-gray-500 hover:text-gray-300">< Retour</a>
<div class="flex justify-between items-center mb-4">
<h2 class="text-xl font-bold text-cyber-accent">Carte des flux réseau</h2>
<button onclick="resetZoom()" class="btn-sm bg-cyber-border text-gray-400 px-2 py-1 text-xs">Reset vue</button>
</div>
<!-- Filtres -->
<div class="card p-3 mb-4 flex gap-3 items-center flex-wrap">
<form method="GET" action="/audit-full/flow-map" class="flex gap-2 items-center flex-1">
<select name="domain" class="text-xs py-1 px-2" onchange="this.form.submit()">
<option value="">Tous</option>
<optgroup label="Zones">
{% for z in all_zones %}<option value="{{ z.code }}" {% if domain_filter == z.code %}selected{% endif %}>{{ z.name }}</option>{% endfor %}
</optgroup>
<optgroup label="Domaines">
{% for d in all_domains %}<option value="{{ d.code }}" {% if domain_filter == d.code %}selected{% endif %}>{{ d.name }}</option>{% endfor %}
</optgroup>
</select>
<div style="position:relative" class="flex-1">
<input type="text" name="server" value="{{ server_filter }}" placeholder="Serveur (ex: vptrabkme1)..." class="text-xs py-1 px-2 w-full font-mono" id="server-input" autocomplete="off" list="server-list">
<datalist id="server-list">
{% for s in audited_servers %}<option value="{{ s.hostname }}">{% endfor %}
</datalist>
</div>
<button type="submit" class="btn-primary px-3 py-1 text-xs">Generer</button>
{% if domain_filter or server_filter %}<a href="/audit-full/flow-map" class="text-xs text-gray-400 hover:text-cyber-accent">Reset</a>{% endif %}
</form>
<span class="text-xs text-gray-500">
{% if server_filter %}Serveur: <span class="text-cyber-accent font-mono">{{ server_filter }}</span>
{% elif domain_filter %}Domaine/Zone: <span class="text-cyber-accent">{{ domain_filter }}</span>
{% else %}Vue globale{% endif %}
</span>
</div>
<!-- Map SVG -->
<div class="card" style="position:relative;overflow:hidden;height:700px;background:#0a0e17;" id="map-container">
<svg id="flow-svg" width="100%" height="100%" style="cursor:grab;">
<defs>
<marker id="arrow" viewBox="0 0 10 6" refX="10" refY="3" markerWidth="8" markerHeight="6" orient="auto">
<path d="M0,0 L10,3 L0,6 Z" fill="#22c55e" opacity="0.6"/>
</marker>
</defs>
<g id="svg-root">
<g id="links-layer"></g>
<g id="nodes-layer"></g>
</g>
</svg>
<div id="tooltip" style="display:none;position:absolute;background:#1a1f2e;border:1px solid #22c55e;padding:6px 10px;border-radius:4px;font-size:11px;color:#e2e8f0;pointer-events:none;z-index:10;max-width:300px;"></div>
</div>
<!-- Legende -->
<div class="flex gap-4 mt-2 text-xs text-gray-500">
<span><span style="color:#22c55e;">---></span> Flux réseau</span>
<span id="stats-nodes">0 serveurs</span>
<span id="stats-links">0 flux</span>
</div>
<!-- Tableau flux en dessous -->
{% if flows %}
<details class="card mt-4">
<summary class="p-3 cursor-pointer text-sm text-gray-400">Tableau des flux ({{ flows|length }})</summary>
<div class="overflow-x-auto">
<table class="w-full table-cyber text-xs">
<thead><tr>
<th class="p-2">Dir</th><th class="text-left p-2">Source</th><th class="text-left p-2">Destination</th>
<th class="p-2">Port</th><th class="p-2">Process</th><th class="p-2">State</th>
</tr></thead>
<tbody>
{% for f in flows %}
<tr class="{% if f.state == 'CLOSE-WAIT' %}bg-red-900/10{% endif %}">
<td class="p-2 text-center"><span class="badge {% if f.direction == 'IN' %}badge-green{% else %}badge-yellow{% endif %}">{{ f.direction }}</span></td>
<td class="p-2 font-mono text-cyber-accent">{{ f.source_hostname }}</td>
<td class="p-2 font-mono {% if f.dest_hostname %}text-cyber-accent{% else %}text-gray-400{% endif %}">{{ f.dest_hostname or f.dest_ip }}</td>
<td class="p-2 text-center font-bold">{{ f.dest_port }}</td>
<td class="p-2 text-center">{{ f.process_name }}</td>
<td class="p-2 text-center"><span class="badge {% if f.state == 'ESTAB' %}badge-green{% elif f.state == 'CLOSE-WAIT' %}badge-red{% else %}badge-gray{% endif %}">{{ f.state }}</span></td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
</details>
{% endif %}
{% if app_map %}
<details class="card mt-4">
<summary class="p-3 cursor-pointer text-sm text-gray-400">Carte applicative ({{ app_map|length }} applis)</summary>
<div class="grid grid-cols-2 gap-3 p-3">
{% for app_name, app in app_map.items() %}
<div class="card p-3">
<span class="text-sm font-bold text-cyber-yellow">{{ app_name }}</span>
<span class="badge badge-blue ml-2">{{ app.servers|length }}</span>
{% if app.ports %}<span class="text-xs text-gray-500 ml-2">ports: {{ app.ports|join(', ') }}</span>{% endif %}
<div class="mt-1">{% for s in app.servers %}<span class="text-xs font-mono text-cyber-accent">{{ s.hostname }}</span>{% if not loop.last %}, {% endif %}{% endfor %}</div>
</div>
{% endfor %}
</div>
</details>
{% endif %}
<script>
// Donnees flux depuis le serveur — uniquement inter-serveurs (dest_hostname connu), pas de self-loop, dedupliques
var rawFlows = [
{% for f in flows %}{% if f.dest_hostname and f.source_hostname != f.dest_hostname %}
{src:"{{f.source_hostname}}",dst:"{{f.dest_hostname}}",port:{{f.dest_port}},proc:"{{f.process_name}}",state:"{{f.state}}",cnt:{{f.cnt}}},
{% endif %}{% endfor %}
];
// Deduplication: une seule fleche par paire src->dst (agglomerer les ports)
var linkMap = {};
rawFlows.forEach(function(f) {
var key = f.src + ">" + f.dst;
if (!linkMap[key]) {
linkMap[key] = {src: f.src, dst: f.dst, ports: [], procs: [], cnt: 0};
}
if (linkMap[key].ports.indexOf(f.port) === -1) linkMap[key].ports.push(f.port);
if (f.proc && linkMap[key].procs.indexOf(f.proc) === -1) linkMap[key].procs.push(f.proc);
linkMap[key].cnt += f.cnt;
});
var links = Object.values(linkMap);
// Noeuds uniques
var nodeSet = {};
links.forEach(function(l) { nodeSet[l.src] = true; nodeSet[l.dst] = true; });
var nodes = Object.keys(nodeSet).map(function(name, i) {
return {id: name, x: 400 + Math.random() * 600, y: 200 + Math.random() * 400, vx: 0, vy: 0};
});
var nodeIdx = {};
nodes.forEach(function(n, i) { nodeIdx[n.id] = i; });
document.getElementById("stats-nodes").textContent = nodes.length + " serveurs";
document.getElementById("stats-links").textContent = links.length + " flux";
// Force-directed layout
var W = document.getElementById("map-container").clientWidth;
var H = 700;
var repulsion = 800;
var attraction = 0.005;
var damping = 0.85;
var iterations = 300;
for (var iter = 0; iter < iterations; iter++) {
// Repulsion entre noeuds
for (var i = 0; i < nodes.length; i++) {
for (var j = i + 1; j < nodes.length; j++) {
var dx = nodes[i].x - nodes[j].x;
var dy = nodes[i].y - nodes[j].y;
var dist = Math.sqrt(dx * dx + dy * dy) || 1;
var force = repulsion / (dist * dist);
var fx = dx / dist * force;
var fy = dy / dist * force;
nodes[i].vx += fx; nodes[i].vy += fy;
nodes[j].vx -= fx; nodes[j].vy -= fy;
}
}
// Attraction via liens
links.forEach(function(l) {
var a = nodes[nodeIdx[l.src]], b = nodes[nodeIdx[l.dst]];
if (!a || !b) return;
var dx = b.x - a.x, dy = b.y - a.y;
var dist = Math.sqrt(dx * dx + dy * dy) || 1;
var force = dist * attraction;
a.vx += dx / dist * force; a.vy += dy / dist * force;
b.vx -= dx / dist * force; b.vy -= dy / dist * force;
});
// Appliquer + damping
nodes.forEach(function(n) {
n.x += n.vx; n.y += n.vy;
n.vx *= damping; n.vy *= damping;
n.x = Math.max(60, Math.min(W - 60, n.x));
n.y = Math.max(40, Math.min(H - 40, n.y));
});
}
// Rendu SVG
var svgRoot = document.getElementById("svg-root");
var linksLayer = document.getElementById("links-layer");
var nodesLayer = document.getElementById("nodes-layer");
var tooltip = document.getElementById("tooltip");
function render() {
linksLayer.innerHTML = "";
nodesLayer.innerHTML = "";
links.forEach(function(l) {
var a = nodes[nodeIdx[l.src]], b = nodes[nodeIdx[l.dst]];
if (!a || !b || a.hidden || b.hidden) return;
var line = document.createElementNS("http://www.w3.org/2000/svg", "line");
line.setAttribute("x1", a.x); line.setAttribute("y1", a.y);
line.setAttribute("x2", b.x); line.setAttribute("y2", b.y);
line.setAttribute("stroke", "#22c55e");
line.setAttribute("stroke-width", Math.min(3, 0.5 + l.cnt * 0.1));
line.setAttribute("stroke-opacity", "0.5");
line.setAttribute("marker-end", "url(#arrow)");
line.onmouseenter = function(e) {
tooltip.style.display = "block";
tooltip.style.left = e.offsetX + 10 + "px";
tooltip.style.top = e.offsetY - 10 + "px";
tooltip.innerHTML = "<b>" + l.src + "</b> &rarr; <b>" + l.dst + "</b><br>Ports: " + l.ports.join(", ") + "<br>Process: " + l.procs.join(", ") + "<br>Connexions: " + l.cnt;
};
line.onmouseleave = function() { tooltip.style.display = "none"; };
linksLayer.appendChild(line);
});
nodes.forEach(function(n) {
if (n.hidden) return;
var g = document.createElementNS("http://www.w3.org/2000/svg", "g");
g.setAttribute("transform", "translate(" + n.x + "," + n.y + ")");
g.style.cursor = "pointer";
var isTarget = (serverFilter && n.id === serverFilter);
var circle = document.createElementNS("http://www.w3.org/2000/svg", "circle");
circle.setAttribute("r", isTarget ? "10" : "6");
circle.setAttribute("fill", isTarget ? "#facc15" : "#22c55e");
circle.setAttribute("stroke", isTarget ? "#facc15" : "#0a0e17");
circle.setAttribute("stroke-width", "2");
g.appendChild(circle);
var text = document.createElementNS("http://www.w3.org/2000/svg", "text");
text.setAttribute("x", "9"); text.setAttribute("y", "4");
text.setAttribute("fill", "#94a3b8");
text.setAttribute("font-size", "9");
text.setAttribute("font-family", "monospace");
text.textContent = n.id;
g.appendChild(text);
g.onclick = function() { location.href = "/audit-full?q=" + n.id; };
g.onmouseenter = function() { circle.setAttribute("r", "8"); circle.setAttribute("fill", "#facc15"); };
g.onmouseleave = function() { circle.setAttribute("r", "6"); circle.setAttribute("fill", "#22c55e"); };
nodesLayer.appendChild(g);
});
}
render();
// Pan + Zoom
var svg = document.getElementById("flow-svg");
var viewBox = {x: 0, y: 0, w: W, h: H};
svg.setAttribute("viewBox", viewBox.x + " " + viewBox.y + " " + viewBox.w + " " + viewBox.h);
var isPanning = false, startX, startY;
svg.onmousedown = function(e) { isPanning = true; startX = e.clientX; startY = e.clientY; svg.style.cursor = "grabbing"; };
svg.onmousemove = function(e) {
if (!isPanning) return;
var dx = (e.clientX - startX) * viewBox.w / svg.clientWidth;
var dy = (e.clientY - startY) * viewBox.h / svg.clientHeight;
viewBox.x -= dx; viewBox.y -= dy;
svg.setAttribute("viewBox", viewBox.x + " " + viewBox.y + " " + viewBox.w + " " + viewBox.h);
startX = e.clientX; startY = e.clientY;
};
svg.onmouseup = function() { isPanning = false; svg.style.cursor = "grab"; };
svg.onmouseleave = function() { isPanning = false; svg.style.cursor = "grab"; };
svg.onwheel = function(e) {
e.preventDefault();
var scale = e.deltaY > 0 ? 1.1 : 0.9;
var mx = e.offsetX / svg.clientWidth * viewBox.w + viewBox.x;
var my = e.offsetY / svg.clientHeight * viewBox.h + viewBox.y;
viewBox.w *= scale; viewBox.h *= scale;
viewBox.x = mx - (mx - viewBox.x) * scale;
viewBox.y = my - (my - viewBox.y) * scale;
svg.setAttribute("viewBox", viewBox.x + " " + viewBox.y + " " + viewBox.w + " " + viewBox.h);
};
function resetZoom() {
viewBox = {x: 0, y: 0, w: W, h: H};
svg.setAttribute("viewBox", "0 0 " + W + " " + H);
}
// Highlight du serveur filtre
var serverFilter = "{{ server_filter }}";
if (serverFilter && nodeIdx[serverFilter] !== undefined) {
var targetNode = nodes[nodeIdx[serverFilter]];
// Centrer la vue sur ce noeud
viewBox.x = targetNode.x - W / 2;
viewBox.y = targetNode.y - H / 2;
svg.setAttribute("viewBox", viewBox.x + " " + viewBox.y + " " + viewBox.w + " " + viewBox.h);
}
</script>
{% endblock %}

View File

@ -1,155 +0,0 @@
{% extends 'base.html' %}
{% block title %}Audit complet{% endblock %}
{% block content %}
<div class="flex justify-between items-center mb-4">
<div>
<h2 class="text-xl font-bold text-cyber-accent">Audit complet serveurs</h2>
<p class="text-xs text-gray-500 mt-1">Applicatif + réseau + corrélation — import JSON depuis le standalone</p>
</div>
<div class="flex gap-2 items-center">
<form method="POST" action="/audit-full/import" enctype="multipart/form-data" class="flex gap-2 items-center">
<input type="file" name="file" accept=".json" class="text-xs" required>
<button type="submit" class="btn-primary px-4 py-2 text-sm" data-loading="Import en cours...|Insertion des données">Importer JSON</button>
</form>
<a href="/audit-full/export-csv{% if filter %}?filter={{ filter }}{% endif %}{% if search %}&q={{ search }}{% endif %}{% if domain %}&domain={{ domain }}{% endif %}" class="btn-sm bg-cyber-green text-black px-4 py-2">Exporter CSV</a>
<a href="/audit-full/flow-map" class="btn-sm bg-cyber-border text-cyber-accent px-4 py-2">Carte flux</a>
</div>
</div>
{% if msg %}
<div class="mb-3 p-2 rounded text-sm {% if 'error' in msg %}bg-red-900/30 text-cyber-red{% else %}bg-green-900/30 text-cyber-green{% endif %}">
{% if msg.startswith('imported_') %}
{% set parts = msg.split('_') %}
{{ parts[1] }} serveur(s) importé(s){% if parts[2]|int > 0 %}, {{ parts[2] }} erreur(s){% endif %}.
{% elif msg.startswith('error_') %}Erreur: {{ msg[6:] }}{% endif %}
</div>
{% endif %}
{% if kpis %}
<div class="flex flex-nowrap gap-2 mb-4" style="display:flex;flex-wrap:nowrap;">
<a href="/audit-full" class="card p-2 text-center flex-1 hover:bg-cyber-hover {% if not filter %}ring-1 ring-cyber-accent{% endif %}" style="min-width:0">
<div class="text-base font-bold text-cyber-accent">{{ kpis.total }}</div>
<div class="text-xs text-gray-500">Total</div>
</a>
<a href="/audit-full?filter=reboot" class="card p-2 text-center flex-1 hover:bg-cyber-hover {% if filter == 'reboot' %}ring-1 ring-cyber-red{% endif %}" style="min-width:0">
<div class="text-base font-bold {% if kpis.needs_reboot > 0 %}text-cyber-red{% else %}text-cyber-green{% endif %}">{{ kpis.needs_reboot }}</div>
<div class="text-xs text-gray-500">Reboot</div>
</a>
<a href="/audit-full?filter=disk_critical" class="card p-2 text-center flex-1 hover:bg-cyber-hover {% if filter == 'disk_critical' %}ring-1 ring-cyber-red{% endif %}" style="min-width:0">
<div class="text-base font-bold {% if kpis.disk_critical > 0 %}text-cyber-red{% else %}text-cyber-green{% endif %}">{{ kpis.disk_critical }}</div>
<div class="text-xs text-gray-500">Disque >= 90%</div>
</a>
<a href="/audit-full?filter=disk_warning" class="card p-2 text-center flex-1 hover:bg-cyber-hover {% if filter == 'disk_warning' %}ring-1 ring-cyber-yellow{% endif %}" style="min-width:0">
<div class="text-base font-bold {% if kpis.disk_warning > 0 %}text-cyber-yellow{% else %}text-cyber-green{% endif %}">{{ kpis.disk_warning }}</div>
<div class="text-xs text-gray-500">Disque >= 80%</div>
</a>
<a href="/audit-full?filter=uptime" class="card p-2 text-center flex-1 hover:bg-cyber-hover {% if filter == 'uptime' %}ring-1 ring-cyber-yellow{% endif %}" style="min-width:0">
<div class="text-base font-bold {% if kpis.uptime_long > 0 %}text-cyber-yellow{% else %}text-cyber-green{% endif %}">{{ kpis.uptime_long }}</div>
<div class="text-xs text-gray-500">Uptime > 4m</div>
</a>
</div>
<!-- KPIs applicatifs -->
<div style="display:flex;flex-wrap:nowrap;gap:4px;margin-bottom:12px;">
{% for key, label, icon in [
('app_oracle','Oracle','db'),('app_postgres','PostgreSQL','db'),('app_mariadb','MariaDB/MySQL','db'),('app_hana','SAP HANA','db'),
('app_httpd','Apache','web'),('app_nginx','Nginx','web'),('app_haproxy','HAProxy','web'),
('app_tomcat','Tomcat','app'),('app_java','Java','app'),('app_nodejs','Node.js','app'),
('app_redis','Redis','db'),('app_mongodb','MongoDB','db'),('app_elastic','Elastic','db'),('app_container','Docker/Podman','app')
] %}
{% set val = kpis[key]|default(0) %}
{% if val > 0 %}
<a href="/audit-full?filter={{ key }}" class="card px-2 py-1 text-center hover:bg-cyber-hover {% if filter == key %}ring-1 ring-cyber-accent{% endif %}" style="min-width:0;flex:0 1 auto;">
<div class="text-sm font-bold {% if icon == 'db' %}text-blue-400{% elif icon == 'web' %}text-green-400{% else %}text-purple-400{% endif %}">{{ val }}</div>
<div style="font-size:10px;" class="text-gray-500 whitespace-nowrap">{{ label }}</div>
</a>
{% endif %}
{% endfor %}
</div>
{% if filter %}
<div class="mb-3 text-xs text-gray-400">Filtre actif : <span class="text-cyber-accent">{{ filter }}</span><a href="/audit-full" class="text-cyber-accent underline">Tout voir</a></div>
{% endif %}
{% endif %}
<!-- Recherche + filtre domaine -->
<div class="card p-3 mb-4 flex gap-3 items-center flex-wrap">
<form method="GET" action="/audit-full" class="flex gap-2 items-center flex-1">
{% if filter %}<input type="hidden" name="filter" value="{{ filter }}">{% endif %}
<input type="text" name="q" value="{{ search }}" placeholder="Rechercher un serveur..." class="text-xs py-1 px-3 flex-1 min-w-[200px] font-mono">
<select name="domain" class="text-xs py-1 px-2" onchange="this.form.submit()">
<option value="">Tous</option>
<optgroup label="Zones">
{% for z in all_zones %}<option value="{{ z.code }}" {% if domain == z.code %}selected{% endif %}>{{ z.name }}</option>{% endfor %}
</optgroup>
<optgroup label="Domaines">
{% for d in all_domains %}<option value="{{ d.code }}" {% if domain == d.code %}selected{% endif %}>{{ d.name }}</option>{% endfor %}
</optgroup>
</select>
<button type="submit" class="btn-primary px-3 py-1 text-xs">Filtrer</button>
{% if search or domain %}<a href="/audit-full{% if filter %}?filter={{ filter }}{% endif %}" class="text-xs text-gray-400 hover:text-cyber-accent">Reset</a>{% endif %}
</form>
<span class="text-xs text-gray-500">{{ total_filtered }} serveur(s)</span>
</div>
{% if audits %}
<div class="card overflow-x-auto">
<table class="w-full table-cyber text-xs">
<thead><tr>
<th class="text-left p-2"><a href="/audit-full?sort=hostname&dir={% if sort == 'hostname' and sort_dir == 'asc' %}desc{% else %}asc{% endif %}{% if filter %}&filter={{ filter }}{% endif %}{% if search %}&q={{ search }}{% endif %}{% if domain %}&domain={{ domain }}{% endif %}" class="hover:text-cyber-accent">Hostname {% if sort == 'hostname' %}{{ '▲' if sort_dir == 'asc' else '▼' }}{% endif %}</a></th>
<th class="p-2">OS</th>
<th class="p-2">Kernel</th>
<th class="p-2"><a href="/audit-full?sort=uptime&dir={% if sort == 'uptime' and sort_dir == 'desc' %}asc{% else %}desc{% endif %}{% if filter %}&filter={{ filter }}{% endif %}{% if search %}&q={{ search }}{% endif %}{% if domain %}&domain={{ domain }}{% endif %}" class="hover:text-cyber-accent">Uptime {% if sort == 'uptime' %}{{ '▲' if sort_dir == 'asc' else '▼' }}{% endif %}</a></th>
<th class="p-2">Services</th>
<th class="p-2">Process</th>
<th class="p-2">Ports</th>
<th class="p-2">Conn</th>
<th class="p-2"><a href="/audit-full?sort=reboot&dir={% if sort == 'reboot' and sort_dir == 'desc' %}asc{% else %}desc{% endif %}{% if filter %}&filter={{ filter }}{% endif %}{% if search %}&q={{ search }}{% endif %}{% if domain %}&domain={{ domain }}{% endif %}" class="hover:text-cyber-accent">Reboot {% if sort == 'reboot' %}{{ '▲' if sort_dir == 'asc' else '▼' }}{% endif %}</a></th>
<th class="p-2"><a href="/audit-full?sort=patch&dir={% if sort == 'patch' and sort_dir == 'desc' %}asc{% else %}desc{% endif %}{% if filter %}&filter={{ filter }}{% endif %}{% if search %}&q={{ search }}{% endif %}{% if domain %}&domain={{ domain }}{% endif %}" class="hover:text-cyber-accent">Dernier patch {% if sort == 'patch' %}{{ '▲' if sort_dir == 'asc' else '▼' }}{% endif %}</a></th>
<th class="p-2">Date</th>
</tr></thead>
<tbody>
{% for a in audits %}
<tr class="hover:bg-cyber-hover cursor-pointer" onclick="location.href='/audit-full/{{ a.id }}'">
<td class="p-2 font-mono text-cyber-accent font-bold">{{ a.hostname }}</td>
<td class="p-2 text-center text-gray-400">{{ (a.os_release or '')[:30] }}</td>
<td class="p-2 text-center font-mono text-gray-500">{{ (a.kernel or '')[:25] }}</td>
<td class="p-2 text-center text-gray-400">{{ (a.uptime or '')[:20] }}</td>
<td class="p-2 text-center">{{ a.svc_count }}</td>
<td class="p-2 text-center">{{ a.proc_count }}</td>
<td class="p-2 text-center">{{ a.port_count }}</td>
<td class="p-2 text-center">{{ a.conn_count }}</td>
<td class="p-2 text-center">{% if a.reboot_required %}<span class="text-cyber-red">Oui</span>{% else %}<span class="text-cyber-green">Non</span>{% endif %}</td>
<td class="p-2 text-center font-mono {% if not a.last_patch_week %}text-cyber-red{% elif a.last_patch_year == 2026 %}text-cyber-green{% else %}text-cyber-yellow{% endif %}">{% if a.last_patch_date %}{{ a.last_patch_date }}{% elif a.last_patch_week %}{{ a.last_patch_week }} {{ a.last_patch_year }}{% else %}-{% endif %}</td>
<td class="p-2 text-center text-gray-500">{{ a.audit_date.strftime('%d/%m %H:%M') if a.audit_date else '-' }}</td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
<!-- Pagination -->
{% if total_pages > 1 %}
{% set qs %}{% if filter %}&filter={{ filter }}{% endif %}{% if search %}&q={{ search }}{% endif %}{% if domain %}&domain={{ domain }}{% endif %}{% if sort %}&sort={{ sort }}&dir={{ sort_dir }}{% endif %}{% endset %}
<div class="flex justify-between items-center p-3 border-t border-cyber-border">
<span class="text-xs text-gray-500">Page {{ page }}/{{ total_pages }} ({{ total_filtered }} serveurs)</span>
<div class="flex gap-1">
{% if page > 1 %}
<a href="/audit-full?page=1{{ qs }}" class="btn-sm bg-cyber-border text-gray-400 px-2 py-1 text-xs">1</a>
{% if page > 2 %}<a href="/audit-full?page={{ page - 1 }}{{ qs }}" class="btn-sm bg-cyber-border text-gray-400 px-2 py-1 text-xs">&lt;</a>{% endif %}
{% endif %}
<span class="btn-sm bg-cyber-accent text-black px-2 py-1 text-xs font-bold">{{ page }}</span>
{% if page < total_pages %}
<a href="/audit-full?page={{ page + 1 }}{{ qs }}" class="btn-sm bg-cyber-border text-gray-400 px-2 py-1 text-xs">&gt;</a>
<a href="/audit-full?page={{ total_pages }}{{ qs }}" class="btn-sm bg-cyber-border text-gray-400 px-2 py-1 text-xs">{{ total_pages }}</a>
{% endif %}
</div>
</div>
{% endif %}
</div>
{% else %}
<div class="card p-8 text-center text-gray-500">
<p class="text-sm">Aucun audit{% if search or domain or filter %} correspondant aux filtres{% endif %}.</p>
{% if not search and not domain and not filter %}
<p class="text-xs mt-2">Lancez le standalone sur vos serveurs puis importez le JSON ici.</p>
{% endif %}
</div>
{% endif %}
{% endblock %}

View File

@ -1,214 +0,0 @@
{% extends 'base.html' %}
{% block title %}Patching {{ year }}{% endblock %}
{% block content %}
<div class="flex justify-between items-center mb-4">
<div>
<a href="/audit-full" class="text-xs text-gray-500 hover:text-gray-300">< Audit complet</a>
<h2 class="text-xl font-bold text-cyber-accent">Patching {{ year }}</h2>
</div>
<div class="flex gap-2 items-center">
<a href="/audit-full/patching?year=2025" class="btn-sm {% if year == 2025 %}bg-cyber-accent text-black{% else %}bg-cyber-border text-gray-400{% endif %} px-3 py-1">2025</a>
<a href="/audit-full/patching?year=2026" class="btn-sm {% if year == 2026 %}bg-cyber-accent text-black{% else %}bg-cyber-border text-gray-400{% endif %} px-3 py-1">2026</a>
<span class="text-gray-600 mx-1">|</span>
<a href="/audit-full/patching?year={{ year }}" class="btn-sm {% if not scope %}bg-cyber-accent text-black{% else %}bg-cyber-border text-gray-400{% endif %} px-3 py-1">Tous</a>
<a href="/audit-full/patching?year={{ year }}&scope=secops" class="btn-sm {% if scope == 'secops' %}bg-cyber-green text-black{% else %}bg-cyber-border text-gray-400{% endif %} px-3 py-1">SecOps</a>
<a href="/audit-full/patching?year={{ year }}&scope=other" class="btn-sm {% if scope == 'other' %}bg-cyber-yellow text-black{% else %}bg-cyber-border text-gray-400{% endif %} px-3 py-1">Hors SecOps</a>
<span class="text-gray-600 mx-1">|</span>
<a href="/audit-full/patching/export-csv?year={{ year }}{% if scope %}&scope={{ scope }}{% endif %}{% if search %}&q={{ search }}{% endif %}{% if domain %}&domain={{ domain }}{% endif %}" class="btn-sm bg-cyber-green text-black px-3 py-1">CSV</a>
</div>
</div>
<!-- KPIs par perimetre -->
{% if kpis_secops and kpis_other %}
<div style="display:flex;flex-wrap:nowrap;gap:6px;margin-bottom:8px;">
<a href="/audit-full/patching?year={{ year }}&scope=secops" class="card p-2 text-center hover:bg-cyber-hover {% if scope == 'secops' %}ring-1 ring-cyber-green{% endif %}" style="flex:1;min-width:0;background:#111827;">
<div class="text-xs text-gray-500 mb-1">SecOps</div>
<div class="text-lg font-bold text-cyber-green">{{ kpis_secops.patched }}<span class="text-gray-500 text-xs">/{{ kpis_secops.total }}</span></div>
{% set pct_s = (kpis_secops.patched / kpis_secops.total * 100)|int if kpis_secops.total > 0 else 0 %}
<div style="height:4px;background:#1f2937;border-radius:2px;margin-top:4px;"><div style="height:100%;width:{{ pct_s }}%;background:#22c55e;border-radius:2px;"></div></div>
<div style="font-size:10px;" class="{% if pct_s >= 80 %}text-cyber-green{% else %}text-cyber-yellow{% endif %} mt-1">{{ pct_s }}%</div>
</a>
<a href="/audit-full/patching?year={{ year }}&scope=other" class="card p-2 text-center hover:bg-cyber-hover {% if scope == 'other' %}ring-1 ring-cyber-yellow{% endif %}" style="flex:1;min-width:0;background:#111827;">
<div class="text-xs text-gray-500 mb-1">Hors SecOps</div>
<div class="text-lg font-bold text-cyber-yellow">{{ kpis_other.patched }}<span class="text-gray-500 text-xs">/{{ kpis_other.total }}</span></div>
{% set pct_o = (kpis_other.patched / kpis_other.total * 100)|int if kpis_other.total > 0 else 0 %}
<div style="height:4px;background:#1f2937;border-radius:2px;margin-top:4px;"><div style="height:100%;width:{{ pct_o }}%;background:#eab308;border-radius:2px;"></div></div>
<div style="font-size:10px;" class="{% if pct_o >= 80 %}text-cyber-green{% else %}text-cyber-yellow{% endif %} mt-1">{{ pct_o }}%</div>
</a>
</div>
{% endif %}
<!-- KPIs globaux -->
{% if kpis %}
{% set pct = (kpis.patched / kpis.total * 100)|int if kpis.total > 0 else 0 %}
<div style="display:flex;flex-wrap:nowrap;gap:6px;margin-bottom:12px;">
<div class="card p-2 text-center" style="flex:1;min-width:0"><div class="text-xl font-bold text-cyber-accent">{{ kpis.total }}</div><div style="font-size:10px;" class="text-gray-500">Total</div></div>
<div class="card p-2 text-center" style="flex:1;min-width:0"><div class="text-xl font-bold text-cyber-green">{{ kpis.patched }}</div><div style="font-size:10px;" class="text-gray-500">Patchés</div></div>
<div class="card p-2 text-center" style="flex:1;min-width:0"><div class="text-xl font-bold text-green-300">{{ kpis.once }}</div><div style="font-size:10px;" class="text-gray-500">1 fois</div></div>
<div class="card p-2 text-center" style="flex:1;min-width:0"><div class="text-xl font-bold text-blue-400">{{ kpis.twice }}</div><div style="font-size:10px;" class="text-gray-500">2+ fois</div></div>
<div class="card p-2 text-center" style="flex:1;min-width:0"><div class="text-xl font-bold text-purple-400">{{ kpis.thrice }}</div><div style="font-size:10px;" class="text-gray-500">3+ fois</div></div>
<div class="card p-2 text-center" style="flex:1;min-width:0"><div class="text-xl font-bold text-cyber-red">{{ kpis.never }}</div><div style="font-size:10px;" class="text-gray-500">Jamais</div></div>
<div class="card p-2 text-center" style="flex:2;min-width:0">
<div class="text-xl font-bold {% if pct >= 80 %}text-cyber-green{% elif pct >= 50 %}text-cyber-yellow{% else %}text-cyber-red{% endif %}">{{ pct }}%</div>
<div style="font-size:10px;" class="text-gray-500">Couverture</div>
<div style="height:4px;background:#1f2937;border-radius:2px;margin-top:4px;">
<div style="height:100%;width:{{ pct }}%;background:{% if pct >= 80 %}#22c55e{% elif pct >= 50 %}#eab308{% else %}#ef4444{% endif %};border-radius:2px;"></div>
</div>
</div>
</div>
<!-- Comparaison Y-1 -->
{% if compare and year == 2026 %}
{% set pct_current = (compare.current_patched / compare.current_total * 100)|int if compare.current_total > 0 else 0 %}
{% set pct_prev_same = (compare.prev_at_same_week / compare.prev_total * 100)|int if compare.prev_total > 0 else 0 %}
{% set pct_prev_total = (compare.prev_year_total / compare.current_total * 100)|int if compare.current_total > 0 else 0 %}
{% set diff_same = pct_current - pct_prev_same %}
<div class="card p-3 mb-4">
<div class="text-xs text-gray-500 mb-2">
Comparaison à même semaine (S{{ compare.compare_week }})
{% if not compare.prev_data_ok %}<span class="text-cyber-yellow ml-2">Données 2025 incomplètes</span>{% endif %}
</div>
<div style="display:flex;gap:12px;align-items:center;">
<!-- 2026 en cours -->
<div style="flex:1;">
<div class="flex justify-between text-xs mb-1">
<span class="text-cyber-accent font-bold">2026 (S{{ compare.compare_week }})</span>
<span class="font-bold {% if pct_current >= 80 %}text-cyber-green{% elif pct_current >= 50 %}text-cyber-yellow{% else %}text-cyber-red{% endif %}">{{ compare.current_patched }} / {{ compare.current_total }} ({{ pct_current }}%)</span>
</div>
<div style="height:10px;background:#1f2937;border-radius:4px;overflow:hidden;">
<div style="height:100%;width:{{ pct_current }}%;background:#22c55e;border-radius:4px;"></div>
</div>
</div>
<!-- 2025 meme semaine -->
<div style="flex:1;">
<div class="flex justify-between text-xs mb-1">
<span class="text-gray-400">2025 (S{{ compare.compare_week }})</span>
<span class="text-gray-400">{{ compare.prev_at_same_week }} / {{ compare.prev_total }} ({{ pct_prev_same }}%)</span>
</div>
<div style="height:10px;background:#1f2937;border-radius:4px;overflow:hidden;">
<div style="height:100%;width:{{ pct_prev_same }}%;background:#6b7280;border-radius:4px;"></div>
</div>
</div>
<!-- Ecart -->
<div style="min-width:110px;text-align:center;">
<div class="text-lg font-bold {% if diff_same >= 0 %}text-cyber-green{% else %}text-cyber-red{% endif %}">
{% if diff_same >= 0 %}+{% endif %}{{ diff_same }} pts
</div>
<div style="font-size:10px;" class="text-gray-500">vs 2025 même semaine</div>
</div>
</div>
<!-- Ligne 2025 total -->
<div class="mt-2 flex justify-between text-xs text-gray-500">
<span>2025 année complète : {{ compare.prev_year_total }} patchés ({{ pct_prev_total }}%)</span>
<span>Objectif 2026 : dépasser {{ compare.prev_year_total }}</span>
</div>
</div>
{% endif %}
<!-- Graphe + domaines -->
<div class="grid grid-cols-2 gap-4 mb-4">
{% if patch_weekly %}
<div class="card p-3">
<div class="text-xs text-gray-500 mb-2">Serveurs par semaine <span class="text-cyber-green">vert=patché</span> <span class="text-cyber-red">rouge=annulé/reporté</span></div>
<div style="display:flex;align-items:flex-end;gap:2px;height:100px;">
{% set max_cnt = patch_weekly|map(attribute='patched')|map('int')|max %}
{% for w in patch_weekly %}
{% set total = (w.patched|int) + (w.cancelled|int) %}
{% set max_total = max_cnt if max_cnt > 0 else 1 %}
<div style="flex:1;display:flex;flex-direction:column;align-items:center;justify-content:flex-end;height:100%;" title="{{ w.week }}: {{ w.patched }} patchés, {{ w.cancelled }} annulés">
<div style="font-size:8px;color:#94a3b8;">{{ total }}</div>
<div style="width:100%;display:flex;flex-direction:column;justify-content:flex-end;height:{{ (total / max_total * 100)|int }}%;min-height:2px;">
{% if w.cancelled|int > 0 %}<div style="width:100%;background:#ef4444;min-height:2px;height:{{ (w.cancelled|int / total * 100)|int }}%;opacity:0.8;border-radius:2px 2px 0 0;"></div>{% endif %}
<div style="width:100%;background:#22c55e;min-height:2px;flex:1;opacity:0.8;{% if w.cancelled|int == 0 %}border-radius:2px 2px 0 0;{% endif %}"></div>
</div>
<div style="font-size:7px;color:#6b7280;margin-top:2px;transform:rotate(-45deg);white-space:nowrap;">{{ w.week }}</div>
</div>
{% endfor %}
</div>
</div>
{% endif %}
<div class="card p-3">
<div class="text-xs text-gray-500 mb-2">Par domaine</div>
<table class="w-full text-xs">
<thead><tr><th class="text-left p-1">Domaine</th><th class="p-1">Total</th><th class="p-1">OK</th><th class="p-1">2x</th><th class="p-1">Jamais</th><th class="p-1">%</th></tr></thead>
<tbody>
{% for d in patch_by_domain %}
{% set dp = (d.patched / d.total * 100)|int if d.total > 0 else 0 %}
<tr>
<td class="p-1"><a href="/audit-full/patching?year={{ year }}&domain={{ d.code }}" class="hover:text-cyber-accent">{{ d.domain }}</a></td>
<td class="p-1 text-center"><a href="/audit-full/patching?year={{ year }}&domain={{ d.code }}" class="hover:text-cyber-accent">{{ d.total }}</a></td>
<td class="p-1 text-center"><a href="/audit-full/patching?year={{ year }}&domain={{ d.code }}&sort=count&dir=desc" class="text-cyber-green hover:underline">{{ d.patched }}</a></td>
<td class="p-1 text-center"><a href="/audit-full/patching?year={{ year }}&domain={{ d.code }}&sort=count&dir=desc" class="text-blue-400 hover:underline">{{ d.twice }}</a></td>
<td class="p-1 text-center"><a href="/audit-full/patching?year={{ year }}&domain={{ d.code }}&sort=count&dir=asc" class="text-cyber-red hover:underline">{{ d.never }}</a></td>
<td class="p-1 text-center">
<div style="display:inline-block;width:40px;height:6px;background:#1f2937;border-radius:3px;vertical-align:middle;">
<div style="height:100%;width:{{ dp }}%;background:{% if dp >= 80 %}#22c55e{% elif dp >= 50 %}#eab308{% else %}#ef4444{% endif %};border-radius:3px;"></div>
</div>
<span class="{% if dp >= 80 %}text-cyber-green{% elif dp >= 50 %}text-cyber-yellow{% else %}text-cyber-red{% endif %} font-bold ml-1">{{ dp }}%</span>
</td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
</div>
{% endif %}
<!-- Filtres -->
<div class="card p-3 mb-4 flex gap-3 items-center flex-wrap">
<form method="GET" action="/audit-full/patching" class="flex gap-2 items-center flex-1">
<input type="hidden" name="year" value="{{ year }}">
{% if scope %}<input type="hidden" name="scope" value="{{ scope }}">{% endif %}
<input type="text" name="q" value="{{ search }}" placeholder="Rechercher..." class="text-xs py-1 px-2 flex-1 font-mono">
<select name="domain" class="text-xs py-1 px-2" onchange="this.form.submit()">
<option value="">Tous</option>
<optgroup label="Zones">{% for z in all_zones %}<option value="{{ z.code }}" {% if domain == z.code %}selected{% endif %}>{{ z.name }}</option>{% endfor %}</optgroup>
<optgroup label="Domaines">{% for d in all_domains %}<option value="{{ d.code }}" {% if domain == d.code %}selected{% endif %}>{{ d.name }}</option>{% endfor %}</optgroup>
</select>
<button type="submit" class="btn-primary px-3 py-1 text-xs">Filtrer</button>
{% if search or domain %}<a href="/audit-full/patching?year={{ year }}" class="text-xs text-gray-400">Reset</a>{% endif %}
</form>
<span class="text-xs text-gray-500">{{ total_filtered }} serveur(s)</span>
</div>
<!-- Tableau serveurs -->
{% set qs %}{% if search %}&q={{ search }}{% endif %}{% if domain %}&domain={{ domain }}{% endif %}{% if scope %}&scope={{ scope }}{% endif %}{% endset %}
<div class="card overflow-x-auto">
<table class="w-full table-cyber text-xs">
<thead><tr>
<th class="text-left p-2"><a href="/audit-full/patching?year={{ year }}&sort=hostname&dir={% if sort == 'hostname' and sort_dir == 'asc' %}desc{% else %}asc{% endif %}{{ qs }}" class="hover:text-cyber-accent">Hostname {% if sort == 'hostname' %}{{ '▲' if sort_dir == 'asc' else '▼' }}{% endif %}</a></th>
<th class="p-2">Domaine</th>
<th class="p-2">Env</th>
<th class="p-2">Zone</th>
<th class="p-2"><a href="/audit-full/patching?year={{ year }}&sort=count&dir={% if sort == 'count' and sort_dir == 'desc' %}asc{% else %}desc{% endif %}{{ qs }}" class="hover:text-cyber-accent">Nb patchs {% if sort == 'count' %}{{ '▲' if sort_dir == 'asc' else '▼' }}{% endif %}</a></th>
<th class="text-left p-2">Semaines</th>
<th class="p-2"><a href="/audit-full/patching?year={{ year }}&sort=last&dir={% if sort == 'last' and sort_dir == 'desc' %}asc{% else %}desc{% endif %}{{ qs }}" class="hover:text-cyber-accent">Dernier {% if sort == 'last' %}{{ '▲' if sort_dir == 'asc' else '▼' }}{% endif %}</a></th>
</tr></thead>
<tbody>
{% for s in servers %}
<tr class="hover:bg-cyber-hover cursor-pointer" onclick="location.href='/audit-full/{{ s.id }}'">
<td class="p-2 font-mono text-cyber-accent font-bold">{{ s.hostname }}</td>
<td class="p-2 text-center text-gray-400">{{ s.domain or '-' }}</td>
<td class="p-2 text-center"><span class="badge {% if s.env == 'Production' %}badge-green{% elif s.env == 'Recette' %}badge-yellow{% else %}badge-gray{% endif %}">{{ (s.env or '-')[:6] }}</span></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 font-bold {% if (s.patch_count or 0) >= 2 %}text-cyber-green{% elif (s.patch_count or 0) == 1 %}text-green-300{% else %}text-cyber-red{% endif %}">{{ s.patch_count or 0 }}</td>
<td class="p-2 font-mono text-gray-400">{% if s.patch_weeks %}{% for w in s.patch_weeks.split(',') %}<span class="inline-block px-1 rounded text-xs {% if w == 'S15' %}bg-green-900/30 text-cyber-green{% else %}bg-cyber-border text-gray-400{% endif %} mr-1">{{ w }}</span>{% endfor %}{% else %}-{% endif %}</td>
<td class="p-2 text-center font-mono {% if s.last_patch_year == year %}text-cyber-green{% elif s.last_patch_date %}text-cyber-yellow{% else %}text-cyber-red{% endif %}">{% if s.last_patch_date %}{{ s.last_patch_date }}{% elif s.last_patch_week %}{{ s.last_patch_week }}{% else %}-{% endif %}</td>
</tr>
{% endfor %}
</tbody>
</table>
{% if total_pages > 1 %}
{% set pqs %}&year={{ year }}{% if search %}&q={{ search }}{% endif %}{% if domain %}&domain={{ domain }}{% endif %}{% if scope %}&scope={{ scope }}{% endif %}{% if sort %}&sort={{ sort }}&dir={{ sort_dir }}{% endif %}{% endset %}
<div class="flex justify-between items-center p-3 border-t border-cyber-border">
<span class="text-xs text-gray-500">Page {{ page }}/{{ total_pages }}</span>
<div class="flex gap-1">
{% if page > 1 %}<a href="/audit-full/patching?page=1{{ pqs }}" class="btn-sm bg-cyber-border text-gray-400 px-2 py-1 text-xs">1</a>{% if page > 2 %}<a href="/audit-full/patching?page={{ page-1 }}{{ pqs }}" class="btn-sm bg-cyber-border text-gray-400 px-2 py-1 text-xs">&lt;</a>{% endif %}{% endif %}
<span class="btn-sm bg-cyber-accent text-black px-2 py-1 text-xs font-bold">{{ page }}</span>
{% if page < total_pages %}<a href="/audit-full/patching?page={{ page+1 }}{{ pqs }}" class="btn-sm bg-cyber-border text-gray-400 px-2 py-1 text-xs">&gt;</a><a href="/audit-full/patching?page={{ total_pages }}{{ pqs }}" class="btn-sm bg-cyber-border text-gray-400 px-2 py-1 text-xs">{{ total_pages }}</a>{% endif %}
</div>
</div>
{% endif %}
</div>
{% endblock %}

View File

@ -0,0 +1,126 @@
{% extends 'base.html' %}
{% block title %}Audit en cours{% endblock %}
{% block content %}
<div class="flex justify-between items-center mb-4">
<div>
<a href="/audit" class="text-xs text-gray-500 hover:text-gray-300">&larr; Retour audit</a>
<h2 class="text-xl font-bold text-cyber-accent" id="page-title">Audit en cours...</h2>
</div>
<div class="flex gap-2" id="actions-zone" style="display:none">
<form method="POST" action="/audit/realtime/save">
<button class="btn-primary px-4 py-2 text-sm" onclick="return confirm('Mettre à jour la base avec ces résultats ?')">Mettre à jour la base</button>
</form>
</div>
</div>
<!-- KPIs -->
<div style="display:flex;gap:8px;margin-bottom:16px">
<div class="card p-3 text-center" style="flex:1">
<div class="text-2xl font-bold text-cyber-accent" id="kpi-total">{{ total }}</div>
<div class="text-xs text-gray-500">Total</div>
</div>
<div class="card p-3 text-center" style="flex:1">
<div class="text-2xl font-bold text-cyber-green" id="kpi-ok">0</div>
<div class="text-xs text-gray-500">Connectés</div>
</div>
<div class="card p-3 text-center" style="flex:1">
<div class="text-2xl font-bold text-cyber-red" id="kpi-fail">0</div>
<div class="text-xs text-gray-500">Échoués</div>
</div>
<div class="card p-3 text-center" style="flex:1">
<div class="text-2xl font-bold text-gray-400 font-mono" id="kpi-timer">0s</div>
<div class="text-xs text-gray-500">Temps</div>
</div>
</div>
<!-- Barre de progression -->
<div class="card p-4 mb-4">
<div class="flex justify-between items-center mb-2">
<span class="text-xs text-gray-400" id="progress-text">0 / {{ total }}</span>
<span class="text-xs text-gray-500" id="progress-pct">0%</span>
</div>
<div style="background:#1e293b;border-radius:4px;height:10px;overflow:hidden">
<div id="progress-bar" style="height:100%;background:#00ffc8;width:0%;transition:width 0.5s ease"></div>
</div>
</div>
<!-- Tableau progression par serveur -->
<div class="card overflow-hidden">
<table class="w-full table-cyber text-xs">
<thead><tr>
<th class="p-2 text-left">Hostname</th>
<th class="p-2">Étape</th>
<th class="p-2 text-left">Détail</th>
</tr></thead>
<tbody id="progress-body"></tbody>
</table>
</div>
<script>
var JOB_ID = '{{ job_id }}';
var TOTAL = {{ total }};
var _pollTimer = null;
var STAGE_MAP = {
'pending': {label: 'En attente', cls: 'badge-gray', icon: '&#9679;'},
'resolving': {label: 'Résolution DNS', cls: 'badge-yellow', icon: '&#8635;'},
'connecting': {label: 'Connexion SSH', cls: 'badge-yellow', icon: '&#8635;'},
'auditing': {label: 'Audit', cls: 'badge-yellow', icon: '&#8635;'},
'success': {label: 'OK', cls: 'badge-green', icon: '&#10003;'},
'failed': {label: 'Échec', cls: 'badge-red', icon: '&#10007;'},
};
function stageBadge(stage) {
var s = STAGE_MAP[stage] || {label: stage, cls: 'badge-gray', icon: '?'};
var anim = (stage !== 'pending' && stage !== 'success' && stage !== 'failed')
? ' style="animation:pulse 1.5s ease-in-out infinite"' : '';
return '<span class="badge ' + s.cls + '"' + anim + '>' + s.icon + ' ' + s.label + '</span>';
}
function poll() {
fetch('/audit/realtime/status/' + JOB_ID, {credentials: 'same-origin'})
.then(function(r){ return r.json(); })
.then(function(data){
if (!data.ok) return;
var pct = TOTAL > 0 ? Math.round((data.done / TOTAL) * 100) : 0;
document.getElementById('progress-bar').style.width = pct + '%';
document.getElementById('progress-text').textContent = data.done + ' / ' + TOTAL;
document.getElementById('progress-pct').textContent = pct + '%';
document.getElementById('kpi-timer').textContent = data.elapsed + 's';
var ok = 0, fail = 0;
var hostnames = Object.keys(data.servers).sort();
var rows = '';
hostnames.forEach(function(hn){
var s = data.servers[hn];
if (s.stage === 'success') ok++;
else if (s.stage === 'failed') fail++;
rows += '<tr class="border-t border-cyber-border/30">';
rows += '<td class="p-2 font-mono">' + hn + '</td>';
rows += '<td class="p-2 text-center">' + stageBadge(s.stage) + '</td>';
rows += '<td class="p-2 text-gray-400 text-xs">' + (s.detail || '') + '</td>';
rows += '</tr>';
});
document.getElementById('progress-body').innerHTML = rows;
document.getElementById('kpi-ok').textContent = ok;
document.getElementById('kpi-fail').textContent = fail;
if (data.finished) {
if (_pollTimer) { clearInterval(_pollTimer); _pollTimer = null; }
document.getElementById('page-title').innerHTML =
'Audit terminé — <span class="text-cyber-green">' + ok + ' OK</span> / <span class="text-cyber-red">' + fail + ' échec(s)</span>';
document.getElementById('progress-bar').style.background = fail > 0 ? '#ff3366' : '#00ffc8';
document.getElementById('actions-zone').style.display = 'flex';
}
})
.catch(function(){});
}
poll();
_pollTimer = setInterval(poll, 2000);
</script>
<style>
@keyframes pulse { 0%,100%{opacity:1} 50%{opacity:0.5} }
</style>
{% endblock %}

View File

@ -36,6 +36,7 @@
.inline-edit:focus { background: #0a0e17; border-color: #00d4ff; }
.toast { position: fixed; bottom: 20px; right: 20px; padding: 12px 24px; border-radius: 8px; z-index: 1000; animation: fadeIn 0.3s; }
@keyframes fadeIn { from { opacity: 0; transform: translateY(10px); } to { opacity: 1; transform: translateY(0); } }
[x-cloak] { display: none !important; }
</style>
</head>
<body class="min-h-screen" hx-headers='{"X-Requested-With": "htmx"}'>
@ -49,31 +50,111 @@
<h1 class="text-cyber-accent font-bold text-lg mt-1">PatchCenter</h1>
<p class="text-xs text-gray-500">v2.0 — SecOps</p>
</div>
<nav class="flex-1 p-3 space-y-1">
<nav class="flex-1 p-3 space-y-1" x-data='{
open: localStorage.getItem("menu_open") || "",
subOpen: localStorage.getItem("menu_sub_open") || "",
toggle(k){ this.open = (this.open === k) ? "" : k; this.subOpen = ""; localStorage.setItem("menu_open", this.open); localStorage.setItem("menu_sub_open", ""); },
toggleSub(k){ this.subOpen = (this.subOpen === k) ? "" : k; localStorage.setItem("menu_sub_open", this.subOpen); }
}'>
{% set p = perms if perms is defined else request.state.perms %}
{% if p.servers or p.qualys or p.audit or p.planning %}<a href="/dashboard" class="block px-3 py-2 rounded-md text-sm hover:bg-cyber-border/30 {% if 'dashboard' in request.url.path %}bg-cyber-border/30 text-cyber-accent{% else %}text-gray-400{% endif %}">Dashboard</a>{% endif %}
{% if p.servers %}<a href="/servers" class="block px-3 py-2 rounded-md text-sm hover:bg-cyber-border/30 {% if request.url.path == '/servers' or '/servers/' in request.url.path %}bg-cyber-border/30 text-cyber-accent{% else %}text-gray-400{% endif %}">Serveurs</a>{% endif %}
{% set path = request.url.path %}
{% if p.campaigns %}<a href="/campaigns" class="block px-3 py-2 rounded-md text-sm hover:bg-cyber-border/30 {% if 'campaigns' in request.url.path and 'assignments' not in request.url.path %}bg-cyber-border/30 text-cyber-accent{% else %}text-gray-400{% endif %}">Campagnes</a>{% endif %}
{% if p.campaigns in ('edit', 'admin') %}<a href="/assignments" class="block px-3 py-2 rounded-md text-sm hover:bg-cyber-border/30 {% if 'assignments' in request.url.path %}bg-cyber-border/30 text-cyber-accent{% else %}text-gray-400{% endif %} pl-6 text-xs">Assignations</a>{% endif %}
{% if p.qualys %}<a href="/qualys/search" class="block px-3 py-2 rounded-md text-sm hover:bg-cyber-border/30 {% if '/qualys/' in request.url.path and 'tags' not in request.url.path and 'decoder' not in request.url.path %}bg-cyber-border/30 text-cyber-accent{% else %}text-gray-400{% endif %}">Qualys</a>{% endif %}
{% if p.qualys %}<a href="/qualys/tags" class="block px-3 py-2 rounded-md text-sm hover:bg-cyber-border/30 {% if '/qualys/tags' in request.url.path %}bg-cyber-border/30 text-cyber-accent{% else %}text-gray-400{% endif %} pl-6 text-xs">Tags</a>{% endif %}
{% if p.qualys %}<a href="/qualys/decoder" class="block px-3 py-2 rounded-md text-sm hover:bg-cyber-border/30 {% if 'decoder' in request.url.path %}bg-cyber-border/30 text-cyber-accent{% else %}text-gray-400{% endif %} pl-6 text-xs">Décodeur</a>{% endif %}
{% if p.qualys %}<a href="/qualys/agents" class="block px-3 py-2 rounded-md text-sm hover:bg-cyber-border/30 {% if 'agents' in request.url.path and 'deploy' not in request.url.path %}bg-cyber-border/30 text-cyber-accent{% else %}text-gray-400{% endif %} pl-6 text-xs">Agents</a>{% endif %}
{% if p.qualys in ('edit', 'admin') %}<a href="/qualys/deploy" class="block px-3 py-2 rounded-md text-sm hover:bg-cyber-border/30 {% if 'deploy' in request.url.path %}bg-cyber-border/30 text-cyber-accent{% else %}text-gray-400{% endif %} pl-6 text-xs">Déployer Agent</a>{% endif %}
{% if p.campaigns %}<a href="/safe-patching" class="block px-3 py-2 rounded-md text-sm hover:bg-cyber-border/30 {% if 'safe-patching' in request.url.path %}bg-cyber-border/30 text-cyber-accent{% else %}text-gray-400{% endif %}">Safe Patching</a>{% endif %}
{% if p.campaigns or p.quickwin %}<a href="/quickwin" class="block px-3 py-2 rounded-md text-sm hover:bg-cyber-border/30 {% if 'quickwin' in request.url.path and 'safe' not in request.url.path and 'config' not in request.url.path and 'correspondance' not in request.url.path %}bg-cyber-border/30 text-cyber-accent{% else %}text-gray-400{% endif %}">QuickWin</a>{% endif %}
{% if p.campaigns or p.quickwin %}<a href="/quickwin/config" class="block px-3 py-2 rounded-md text-sm hover:bg-cyber-border/30 {% if '/quickwin/config' in request.url.path %}bg-cyber-border/30 text-cyber-accent{% else %}text-gray-400{% endif %} pl-6 text-xs">Config exclusions</a>{% endif %}
{% if p.campaigns or p.quickwin %}<a href="/quickwin/correspondance" class="block px-3 py-2 rounded-md text-sm hover:bg-cyber-border/30 {% if 'correspondance' in request.url.path %}bg-cyber-border/30 text-cyber-accent{% else %}text-gray-400{% endif %} pl-6 text-xs">Correspondance</a>{% endif %}
{% if p.planning %}<a href="/planning" class="block px-3 py-2 rounded-md text-sm hover:bg-cyber-border/30 {% if 'planning' in request.url.path %}bg-cyber-border/30 text-cyber-accent{% else %}text-gray-400{% endif %}">Planning</a>{% endif %}
{% if p.audit %}<a href="/audit" class="block px-3 py-2 rounded-md text-sm hover:bg-cyber-border/30 {% if request.url.path == '/audit' %}bg-cyber-border/30 text-cyber-accent{% else %}text-gray-400{% endif %}">Audit</a>{% endif %}
{% if p.audit in ('edit', 'admin') %}<a href="/audit/specific" class="block px-3 py-2 rounded-md text-sm hover:bg-cyber-border/30 {% if 'specific' in request.url.path %}bg-cyber-border/30 text-cyber-accent{% else %}text-gray-400{% endif %} pl-6 text-xs">Spécifique</a>{% endif %}
{% if p.audit %}<a href="/audit-full" class="block px-3 py-2 rounded-md text-sm hover:bg-cyber-border/30 {% if request.url.path == '/audit-full' or (request.url.path.startswith('/audit-full/') and 'patching' not in request.url.path and 'flow-map' not in request.url.path) %}bg-cyber-border/30 text-cyber-accent{% else %}text-gray-400{% endif %} pl-6 text-xs">Complet</a>{% endif %}
{% if p.audit %}<a href="/audit-full/patching" class="block px-3 py-2 rounded-md text-sm hover:bg-cyber-border/30 {% if 'patching' in request.url.path %}bg-cyber-border/30 text-cyber-accent{% else %}text-gray-400{% endif %} pl-6 text-xs">Patching</a>{% endif %}
{% if p.servers %}<a href="/contacts" class="block px-3 py-2 rounded-md text-sm hover:bg-cyber-border/30 {% if 'contacts' in request.url.path %}bg-cyber-border/30 text-cyber-accent{% else %}text-gray-400{% endif %}">Contacts</a>{% endif %}
{% if p.users %}<a href="/users" class="block px-3 py-2 rounded-md text-sm hover:bg-cyber-border/30 {% if 'users' in request.url.path %}bg-cyber-border/30 text-cyber-accent{% else %}text-gray-400{% endif %}">Utilisateurs</a>{% endif %}
{% if p.settings %}<a href="/settings" class="block px-3 py-2 rounded-md text-sm hover:bg-cyber-border/30 {% if 'settings' in request.url.path %}bg-cyber-border/30 text-cyber-accent{% else %}text-gray-400{% endif %}">Settings</a>{% endif %}
{% if p.settings %}<a href="/referentiel" class="block px-3 py-2 rounded-md text-sm hover:bg-cyber-border/30 {% if 'referentiel' in request.url.path %}bg-cyber-border/30 text-cyber-accent{% else %}text-gray-400{% endif %} pl-6 text-xs">R&eacute;f&eacute;rentiel</a>{% endif %}
{# Dashboard principal #}
{% if p.servers or p.qualys or p.audit or p.planning %}<a href="/dashboard" class="block px-3 py-2 rounded-md text-sm hover:bg-cyber-border/30 {% if 'dashboard' in path %}bg-cyber-border/30 text-cyber-accent{% else %}text-gray-400{% endif %}">Dashboard</a>{% endif %}
{# Serveurs (groupe repliable avec Correspondance) #}
{% if p.servers %}
<div>
<button @click="toggle('servers')" class="w-full flex justify-between items-center px-3 py-2 rounded-md text-sm hover:bg-cyber-border/30 text-cyber-accent font-bold">
<span>Serveurs</span>
<span x-text="open === 'servers' ? '▾' : '▸'" class="text-xs"></span>
</button>
<div x-show="open === 'servers'" x-cloak class="space-y-1 pl-1">
<a href="/servers" class="block px-3 py-1.5 rounded-md text-xs hover:bg-cyber-border/30 {% if path == '/servers' or path.startswith('/servers/') %}bg-cyber-border/30 text-cyber-accent{% else %}text-gray-400{% endif %} pl-6">Liste</a>
{% if p.campaigns or p.quickwin %}<a href="/patching/correspondance" class="block px-3 py-1.5 rounded-md text-xs hover:bg-cyber-border/30 {% if '/patching/correspondance' in path %}bg-cyber-border/30 text-cyber-accent{% else %}text-gray-400{% endif %} pl-6">Correspondance prod ↔ hors-prod</a>{% endif %}
</div>
</div>
{% endif %}
{# ===== PATCHING (groupe repliable) ===== #}
{% if p.campaigns or p.planning or p.quickwin %}
<div>
<button @click="toggle('patching')" class="w-full flex justify-between items-center px-3 py-2 rounded-md text-sm hover:bg-cyber-border/30 text-cyber-accent font-bold">
<span>Patching</span>
<span x-text="open === 'patching' ? '▾' : '▸'" class="text-xs"></span>
</button>
<div x-show="open === 'patching'" x-cloak class="space-y-1 pl-1">
{% if p.planning %}<a href="/planning" class="block px-3 py-1.5 rounded-md text-xs hover:bg-cyber-border/30 {% if 'planning' in path %}bg-cyber-border/30 text-cyber-accent{% else %}text-gray-400{% endif %} pl-6">Planning</a>{% endif %}
{% if p.campaigns in ('edit', 'admin') %}<a href="/assignments" class="block px-3 py-1.5 rounded-md text-xs hover:bg-cyber-border/30 {% if 'assignments' in path %}bg-cyber-border/30 text-cyber-accent{% else %}text-gray-400{% endif %} pl-6">Assignation</a>{% endif %}
{% if p.campaigns %}<a href="/campaigns" class="block px-3 py-1.5 rounded-md text-xs hover:bg-cyber-border/30 {% if 'campaigns' in path and 'assignments' not in path %}bg-cyber-border/30 text-cyber-accent{% else %}text-gray-400{% endif %} pl-6">Campagnes</a>{% endif %}
{% if p.servers in ('edit','admin') or p.campaigns in ('edit','admin') or p.quickwin in ('edit','admin') %}<a href="/patching/config-exclusions" class="block px-3 py-1.5 rounded-md text-xs hover:bg-cyber-border/30 {% if 'config-exclusions' in path %}bg-cyber-border/30 text-cyber-accent{% else %}text-gray-400{% endif %} pl-6">Config exclusions</a>{% endif %}
{% if p.campaigns in ('edit','admin') or p.quickwin in ('edit','admin') %}<a href="/patching/validations" class="block px-3 py-1.5 rounded-md text-xs hover:bg-cyber-border/30 {% if '/patching/validations' in path %}bg-cyber-border/30 text-cyber-accent{% else %}text-gray-400{% endif %} pl-6">Validations</a>{% endif %}
{# Quickwin sous-groupe #}
{% if p.campaigns or p.quickwin %}
<div>
<button @click="toggleSub('quickwin')" class="w-full flex justify-between items-center px-3 py-1.5 rounded-md text-xs hover:bg-cyber-border/30 pl-6 {% if 'quickwin' in path %}text-cyber-accent{% else %}text-gray-400{% endif %}">
<span>QuickWin</span>
<span x-text="subOpen === 'quickwin' ? '▾' : '▸'" class="text-xs opacity-60"></span>
</button>
<div x-show="subOpen === 'quickwin'" x-cloak class="space-y-1">
<a href="/quickwin" style="padding-left:3rem" class="block py-1 pr-3 rounded-md text-xs hover:bg-cyber-border/30 {% if 'quickwin' in path and 'config' not in path and 'correspondance' not in path %}bg-cyber-border/30 text-cyber-accent{% else %}text-gray-500{% endif %}">Vue d'ensemble</a>
{% 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>
{% endif %}
</div>
</div>
{% endif %}
{# ===== AUDIT (au meme niveau que Patching, repliable) ===== #}
{% if p.audit %}
<div>
<button @click="toggle('audit')" class="w-full flex justify-between items-center px-3 py-2 rounded-md text-sm hover:bg-cyber-border/30 text-cyber-accent font-bold">
<span>Audit</span>
<span x-text="open === 'audit' ? '▾' : '▸'" class="text-xs"></span>
</button>
<div x-show="open === 'audit'" x-cloak class="space-y-1 pl-1">
<a href="/audit" class="block px-3 py-1.5 rounded-md text-xs hover:bg-cyber-border/30 {% if path == '/audit' %}bg-cyber-border/30 text-cyber-accent{% else %}text-gray-400{% endif %} pl-6">Audit global</a>
{% if p.audit in ('edit', 'admin') %}<a href="/audit/specific" class="block px-3 py-1.5 rounded-md text-xs hover:bg-cyber-border/30 {% if 'specific' in path %}bg-cyber-border/30 text-cyber-accent{% else %}text-gray-400{% endif %} pl-6">Spécifique</a>{% endif %}
</div>
</div>
{% endif %}
{# ===== QUALYS (groupe repliable) ===== #}
{% if p.qualys %}
<div>
<button @click="toggle('qualys')" class="w-full flex justify-between items-center px-3 py-2 rounded-md text-sm hover:bg-cyber-border/30 text-cyber-accent font-bold">
<span>Qualys</span>
<span x-text="open === 'qualys' ? '▾' : '▸'" class="text-xs"></span>
</button>
<div x-show="open === 'qualys'" x-cloak class="space-y-1 pl-1">
<a href="/qualys/search" class="block px-3 py-1.5 rounded-md text-xs hover:bg-cyber-border/30 {% if '/qualys/search' in path %}bg-cyber-border/30 text-cyber-accent{% else %}text-gray-400{% endif %} pl-6">Recherche</a>
<a href="/qualys/tags" class="block px-3 py-1.5 rounded-md text-xs hover:bg-cyber-border/30 {% if '/qualys/tags' in path %}bg-cyber-border/30 text-cyber-accent{% else %}text-gray-400{% endif %} pl-6">Tags</a>
<a href="/qualys/agents" class="block px-3 py-1.5 rounded-md text-xs hover:bg-cyber-border/30 {% if 'agents' in path and 'deploy' not in path %}bg-cyber-border/30 text-cyber-accent{% else %}text-gray-400{% endif %} pl-6">Agents</a>
{% if p.qualys in ('edit', 'admin') %}<a href="/qualys/deploy" class="block px-3 py-1.5 rounded-md text-xs hover:bg-cyber-border/30 {% if 'deploy' in path %}bg-cyber-border/30 text-cyber-accent{% else %}text-gray-400{% endif %} pl-6">Déployer Agent</a>{% endif %}
</div>
</div>
{% endif %}
{# ===== ADMIN (groupe repliable) ===== #}
{% if p.users or p.settings or p.servers or p.contacts %}
<div>
<button @click="toggle('admin')" class="w-full flex justify-between items-center px-3 py-2 rounded-md text-sm hover:bg-cyber-border/30 text-cyber-accent font-bold">
<span>Administration</span>
<span x-text="open === 'admin' ? '▾' : '▸'" class="text-xs"></span>
</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.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>
</div>
{% endif %}
</nav>
</aside>
<main class="flex-1 flex flex-col overflow-hidden">

View File

@ -213,7 +213,7 @@ function showForm(id, type) {
<span class="badge {% if s.status == 'patched' %}badge-green{% elif s.status == 'failed' %}badge-red{% elif s.status == 'excluded' %}badge-gray{% elif s.status == 'in_progress' %}badge-yellow{% else %}badge-gray{% endif %}">{{ s.status }}</span>
{% if s.exclusion_reason %}
<div class="text-[9px] text-gray-500" title="{{ s.exclusion_detail or '' }}">
{% if s.exclusion_reason == 'eol' %}EOL{% elif s.exclusion_reason == 'creneau_inadequat' %}Prereq KO{% elif s.exclusion_reason == 'non_patchable' %}Non patchable{% else %}{{ s.exclusion_reason }}{% endif %}
{% if s.exclusion_reason == 'obsolete' %}EOL{% elif s.exclusion_reason == 'creneau_inadequat' %}Prereq KO{% elif s.exclusion_reason == 'non_patchable' %}Non patchable{% else %}{{ s.exclusion_reason }}{% endif %}
{% if s.excluded_by %}<span class="text-gray-600">({{ s.excluded_by }})</span>{% endif %}
</div>
{% endif %}

View File

@ -0,0 +1,54 @@
<!DOCTYPE html>
<html lang="fr">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>{{ app_name }} - Changement de mot de passe</title>
<link rel="icon" type="image/svg+xml" href="/static/favicon.svg">
<link rel="stylesheet" href="/static/css/tailwind.css">
<style>
body { background: #0a0e17; color: #e2e8f0; font-family: 'Segoe UI', system-ui, sans-serif; }
.card { background: #111827; border: 1px solid #1e3a5f; border-radius: 8px; }
.btn-primary { background: #00d4ff; color: #0a0e17; font-weight: 600; border-radius: 6px; }
.btn-primary:hover { background: #00b8e6; }
input { background: #0a0e17; border: 1px solid #1e3a5f; color: #e2e8f0; border-radius: 6px; padding: 6px 12px; font-size: 0.85rem; }
input:focus { outline: none; border-color: #00d4ff; box-shadow: 0 0 0 2px #00d4ff33; }
</style>
</head>
<body class="min-h-screen">
<div class="min-h-screen flex items-center justify-center">
<div class="card p-8 w-96">
<div class="text-center mb-6">
<img src="/static/logo_sanef.jpg" alt="SANEF" class="h-12 mx-auto mb-3 rounded" style="opacity:0.9">
<h1 class="text-xl font-bold text-cyber-accent">Changement de mot de passe requis</h1>
<p class="text-xs text-gray-500 mt-1">Bienvenue <b>{{ user.sub }}</b></p>
<p class="text-xs text-gray-500 mt-1">Vous devez définir un nouveau mot de passe avant de continuer.</p>
</div>
{% if error %}
<div class="bg-cyber-red/20 text-cyber-red text-sm p-3 rounded mb-4">{{ error }}</div>
{% endif %}
<form method="POST" action="/me/change-password" class="space-y-4">
<div>
<label class="text-xs text-gray-400 block mb-1">Mot de passe actuel</label>
<input type="password" name="current_password" required autofocus class="w-full">
</div>
<div>
<label class="text-xs text-gray-400 block mb-1">Nouveau mot de passe <span class="text-gray-600">(min 8 caractères)</span></label>
<input type="password" name="new_password" required minlength="8" class="w-full">
</div>
<div>
<label class="text-xs text-gray-400 block mb-1">Confirmer le nouveau mot de passe</label>
<input type="password" name="confirm_password" required minlength="8" class="w-full">
</div>
<button type="submit" class="btn-primary w-full py-2 rounded-md">Enregistrer</button>
</form>
<div class="mt-4 text-center">
<a href="/logout" class="text-xs text-gray-500 hover:text-cyber-accent">Se déconnecter</a>
</div>
</div>
</div>
</body>
</html>

View File

@ -51,7 +51,7 @@
<th class="p-2">Rôle</th>
<th class="text-left p-2">Scopes</th>
<th class="p-2">Actif</th>
<th class="p-2">Actions</th>
{% if can_edit_contacts %}<th class="p-2">Actions</th>{% endif %}
</tr></thead>
<tbody>
{% for c in contacts %}
@ -61,6 +61,7 @@
<td class="p-2 text-center"><span class="badge {% if 'responsable' in c.role %}badge-blue{% elif 'referent' in c.role %}badge-yellow{% elif 'ra_' in c.role %}badge-green{% else %}badge-gray{% endif %}">{{ c.role }}</span></td>
<td class="p-2 text-xs text-gray-400" style="max-width:300px">{{ (c.scopes_summary or '-')[:80] }}</td>
<td class="p-2 text-center"><span class="badge {% if c.is_active %}badge-green{% else %}badge-red{% endif %}">{{ 'Oui' if c.is_active else 'Non' }}</span></td>
{% if can_edit_contacts %}
<td class="p-2 text-center">
<div class="flex gap-1 justify-center">
<button class="btn-sm bg-cyber-border text-cyber-accent"
@ -71,12 +72,14 @@
</form>
</div>
</td>
{% endif %}
</tr>
{% endfor %}
</tbody>
</table>
</div>
{% if can_edit_contacts %}
<!-- Ajouter -->
<div class="card p-4 mt-4">
<h4 class="text-sm font-bold text-cyber-accent mb-3">Ajouter un contact</h4>
@ -98,4 +101,5 @@
<button type="submit" class="btn-primary px-4 py-1 text-sm">Ajouter</button>
</form>
</div>
{% endif %}
{% endblock %}

View File

@ -31,6 +31,21 @@
</label>
</div>
</div>
{% if ldap_enabled %}
<div>
<label class="text-xs text-gray-400 block mb-1">Méthode</label>
<div class="flex gap-4">
<label class="flex items-center gap-1 text-sm text-gray-300 cursor-pointer">
<input type="radio" name="auth_method" value="local" checked class="accent-cyan-500"> Locale
</label>
<label class="flex items-center gap-1 text-sm text-gray-300 cursor-pointer">
<input type="radio" name="auth_method" value="ldap" class="accent-cyan-500"> LDAP/AD
</label>
</div>
</div>
{% else %}
<input type="hidden" name="auth_method" value="local">
{% endif %}
<button type="submit" class="btn-primary w-full py-2 rounded-md">Connexion</button>
</form>
<p class="text-center text-xs text-gray-600 mt-4">SANEF — Direction des Systèmes d'Information</p>

View File

@ -28,7 +28,7 @@
</tr></thead>
<tbody>
{% for s in servers %}
<tr class="{% if s.licence_support == 'eol' %}bg-red-900/10{% elif s.licence_support == 'els' %}bg-yellow-900/10{% endif %}">
<tr class="{% if s.licence_support == 'obsolete' %}bg-red-900/10{% elif s.licence_support == 'els' %}bg-yellow-900/10{% endif %}">
<td class="p-1 text-center"><input type="checkbox" name="include_{{ s.id }}" checked></td>
<td class="p-1 font-mono text-cyber-accent">{{ s.hostname }}</td>
<td class="p-1 text-center">{{ s.domaine or '-' }}</td>
@ -36,7 +36,7 @@
<td class="p-1 text-center">{{ s.os_family or '-' }}</td>
<td class="p-1 text-center"><span class="badge {% if s.tier == 'tier0' %}badge-red{% elif s.tier == 'tier1' %}badge-yellow{% else %}badge-blue{% endif %}">{{ s.tier }}</span></td>
<td class="p-1 text-center">{{ s.ssh_method or '-' }}</td>
<td class="p-1 text-center"><span class="badge {% if s.licence_support == 'active' %}badge-green{% elif s.licence_support == 'eol' %}badge-red{% else %}badge-yellow{% endif %}">{{ s.licence_support }}</span></td>
<td class="p-1 text-center"><span class="badge {% if s.licence_support == 'active' %}badge-green{% elif s.licence_support == 'obsolete' %}badge-red{% else %}badge-yellow{% endif %}">{{ s.licence_support }}</span></td>
</tr>
{% endfor %}
</tbody>

View File

@ -51,7 +51,7 @@
<div class="flex justify-between"><span class="text-gray-500">Environnement</span><span class="badge {% if s.environnement == 'Production' %}badge-green{% else %}badge-yellow{% endif %}">{{ s.environnement }}</span></div>
<div class="flex justify-between"><span class="text-gray-500">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 == 'en_production' %}badge-green{% elif s.etat == 'decommissionne' %}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 == 'obsolete' %}badge-red{% else %}badge-yellow{% endif %}">{{ s.etat }}</span></div>
</div>
</div>
@ -61,7 +61,7 @@
<div class="space-y-1 text-sm">
<div class="flex justify-between"><span class="text-gray-500">OS</span><span>{{ s.os_family or '-' }}</span></div>
<div class="text-xs text-gray-400 mt-1">{{ s.os_version or '' }}</div>
<div class="flex justify-between"><span class="text-gray-500">Licence</span><span class="badge {% if s.licence_support == 'active' %}badge-green{% elif s.licence_support == 'eol' %}badge-red{% else %}badge-yellow{% endif %}">{{ s.licence_support }}</span></div>
<div class="flex justify-between"><span class="text-gray-500">Licence</span><span class="badge {% if s.licence_support == 'active' %}badge-green{% elif s.licence_support == 'obsolete' %}badge-red{% else %}badge-yellow{% endif %}">{{ s.licence_support }}</span></div>
</div>
</div>
@ -88,6 +88,46 @@
</div>
</div>
<!-- Correspondance prod ↔ hors-prod -->
{% if links and (links.as_prod or links.as_nonprod) %}
<div class="mb-4">
<h4 class="text-xs text-cyber-accent font-bold uppercase mb-2 border-b border-cyber-border pb-1">Correspondance Prod ↔ Hors-Prod</h4>
{% if links.as_prod %}
<div class="text-xs mb-2">
<div class="text-cyber-green font-bold mb-1">Ce serveur est un PROD — hors-prod liés :</div>
<ul class="ml-2 space-y-1">
{% for l in links.as_prod %}
<li class="flex gap-2 items-center">
<span class="font-mono text-cyber-accent">{{ l.hostname }}</span>
<span class="badge badge-yellow" style="font-size:9px">{{ l.env_name or l.environment_code or '?' }}</span>
<span class="text-gray-600" style="font-size:9px">{{ l.source }}</span>
</li>
{% endfor %}
</ul>
</div>
{% endif %}
{% if links.as_nonprod %}
<div class="text-xs mb-2">
<div class="text-cyber-yellow font-bold mb-1">Ce serveur est un HORS-PROD — prod(s) lié(s) :</div>
<ul class="ml-2 space-y-1">
{% for l in links.as_nonprod %}
<li class="flex gap-2 items-center">
<span class="font-mono text-cyber-accent">{{ l.hostname }}</span>
<span class="badge badge-green" style="font-size:9px">{{ l.env_name or 'Production' }}</span>
</li>
{% endfor %}
</ul>
</div>
{% endif %}
<a href="/patching/correspondance?search={{ s.hostname }}" class="text-xs text-cyber-accent hover:underline">Gérer dans le builder →</a>
</div>
{% else %}
<div class="mb-4">
<h4 class="text-xs text-cyber-accent font-bold uppercase mb-2 border-b border-cyber-border pb-1">Correspondance</h4>
<p class="text-xs text-gray-500">Aucune correspondance prod ↔ hors-prod définie. <a href="/patching/correspondance?search={{ s.hostname }}" class="text-cyber-accent hover:underline">Créer →</a></p>
</div>
{% endif %}
{% if s.mode_operatoire %}
<div class="mb-4">
<h4 class="text-xs text-cyber-accent font-bold uppercase mb-2 border-b border-cyber-border pb-1">Mode operatoire</h4>

View File

@ -58,7 +58,7 @@
<div>
<label class="text-xs text-gray-500">Etat</label>
<select name="etat" class="w-full">
{% for e in ['en_production','en_implementation','en_decommissionnement','decommissionne'] %}<option value="{{ e }}" {% if e == s.etat %}selected{% endif %}>{{ e }}</option>{% endfor %}
{% for e in ['production','implementation','stock','obsolete'] %}<option value="{{ e }}" {% if e == s.etat %}selected{% endif %}>{{ e }}</option>{% endfor %}
</select>
</div>
<div>
@ -67,6 +67,13 @@
{% for o in ['secops','ipop','editeur','tiers','na','a_definir'] %}<option value="{{ o }}" {% if o == s.patch_os_owner %}selected{% endif %}>{{ o }}</option>{% endfor %}
</select>
</div>
<div>
<label class="text-xs text-gray-500">Solution applicative (iTop) <span class="text-gray-600">— modifie aussi iTop</span></label>
<select name="application_id" class="w-full">
<option value="">-- Aucune --</option>
{% for a in applications %}<option value="{{ a.id }}" {% if a.id == s.application_id %}selected{% endif %}>{{ a.nom_court }}</option>{% endfor %}
</select>
</div>
<div>
<label class="text-xs text-gray-500">Responsable</label>
<input type="text" name="responsable_nom" value="{{ s.responsable_nom or '' }}" class="w-full">

View File

@ -0,0 +1,225 @@
{% extends 'base.html' %}
{% block title %}Patching — Config exclusions{% endblock %}
{% block content %}
<div class="flex justify-between items-center mb-4">
<div>
<h2 class="text-xl font-bold text-cyber-accent">Config exclusions — par serveur</h2>
<p class="text-xs text-gray-500 mt-1">Exclusions de packages lors du <code>yum update</code>. Stockées dans iTop (champ <code>patch_excludes</code>) et poussées en temps réel.</p>
</div>
<div class="flex gap-2">
<a href="/quickwin/config" class="btn-sm bg-cyber-border text-gray-300 px-3 py-2">Packages reboot (QuickWin)</a>
</div>
</div>
{% if msg %}
<div class="mb-3 p-2 rounded text-sm bg-green-900/30 text-cyber-green">{{ msg }}</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_servers }}</div><div class="text-xs text-gray-500">Serveurs total</div></div>
<div class="card p-3 text-center" style="flex:1"><div class="text-2xl font-bold text-cyber-green">{{ stats.with_excludes }}</div><div class="text-xs text-gray-500">Avec exclusions</div></div>
<div class="card p-3 text-center" style="flex:1"><div class="text-2xl font-bold text-cyber-yellow">{{ stats.total_servers - stats.with_excludes }}</div><div class="text-xs text-gray-500">Sans exclusions</div></div>
<div class="card p-3 text-center" style="flex:1"><div class="text-2xl font-bold text-cyber-blue">{{ total }}</div><div class="text-xs text-gray-500">Filtrés</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="Hostname..." class="text-xs py-1 px-2" style="width:200px">
<select name="domain" class="text-xs py-1 px-2" style="width:150px">
<option value="">Tous domaines</option>
{% for d in domains %}<option value="{{ d.code }}" {% if filters.domain == d.code %}selected{% endif %}>{{ d.name }}</option>{% endfor %}
</select>
<select name="env" class="text-xs py-1 px-2" style="width:130px">
<option value="">Tous envs</option>
{% for e in envs %}<option value="{{ e.code }}" {% if filters.env == e.code %}selected{% endif %}>{{ e.name }}</option>{% endfor %}
</select>
<select name="zone" class="text-xs py-1 px-2" style="width:100px">
<option value="">Toutes zones</option>
{% for z in zones %}<option value="{{ z }}" {% if filters.zone == z %}selected{% endif %}>{{ z }}</option>{% endfor %}
</select>
<select name="tier" class="text-xs py-1 px-2" style="width:90px">
<option value="">Tier</option>
{% for t in ['tier0','tier1','tier2','tier3'] %}<option value="{{ t }}" {% if filters.tier == t %}selected{% endif %}>{{ t }}</option>{% endfor %}
</select>
<select name="os" class="text-xs py-1 px-2" style="width:100px">
<option value="">Tous OS</option>
<option value="linux" {% if filters.os == 'linux' %}selected{% endif %}>Linux</option>
<option value="windows" {% if filters.os == 'windows' %}selected{% endif %}>Windows</option>
</select>
<select name="application" class="text-xs py-1 px-2" style="width:220px">
<option value="">Toutes solutions applicatives</option>
{% for a in applications %}<option value="{{ a.application_name }}" {% if filters.application == a.application_name %}selected{% endif %}>{{ a.application_name }} ({{ a.c }})</option>{% endfor %}
</select>
<select name="has_excludes" class="text-xs py-1 px-2" style="width:140px">
<option value="">Tous</option>
<option value="yes" {% if filters.has_excludes == 'yes' %}selected{% endif %}>Avec exclusions</option>
<option value="no" {% if filters.has_excludes == 'no' %}selected{% endif %}>Sans exclusions</option>
</select>
<button type="submit" class="btn-primary px-3 py-1 text-xs">Filtrer</button>
<a href="/patching/config-exclusions" class="text-xs text-gray-500 hover:text-cyber-accent">Reset</a>
</form>
</div>
<!-- Bulk actions -->
<div id="bulk-bar" class="card p-3 mb-2" style="display:none">
<div class="flex gap-2 items-center flex-wrap mb-2">
<span class="text-xs text-gray-400"><b id="bulk-count">0</b> sélectionné(s)</span>
<span class="text-xs text-gray-500 font-bold ml-2">Exclusions :</span>
<input type="text" id="bulk-pattern" placeholder="pattern (ex: oracle*, nginx*)" class="text-xs py-1 px-2" style="width:220px">
<button onclick="bulkAction('add')" class="btn-sm bg-cyber-green text-black">Ajouter</button>
<button onclick="bulkAction('remove')" class="btn-sm bg-red-900/40 text-cyber-red">Retirer</button>
<button onclick="bulkAction('replace')" class="btn-sm bg-cyber-border text-cyber-accent">Remplacer tout</button>
</div>
<div class="flex gap-2 items-center flex-wrap">
<span class="text-xs text-gray-500 font-bold">Solution applicative :</span>
<select id="bulk-app" class="text-xs py-1 px-2" style="max-width:260px">
<option value="">-- Aucune (désassocier) --</option>
{% for a in all_apps %}<option value="{{ a.id }}">{{ a.nom_court }}</option>{% endfor %}
</select>
<button onclick="bulkChangeApp()" class="btn-sm bg-cyber-blue text-black">Appliquer à tous les sélectionnés</button>
</div>
<div id="bulk-result" class="text-xs text-gray-400 mt-2"></div>
</div>
<!-- Table -->
<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="text-left p-2">Hostname</th>
<th class="text-left p-2">Solution applicative</th>
<th class="p-2">Domaine</th>
<th class="p-2">Env</th>
<th class="p-2">Zone</th>
<th class="p-2">Tier</th>
<th class="p-2">OS</th>
<th class="text-left p-2" style="min-width:300px">Exclusions</th>
<th class="p-2">Action</th>
</tr></thead>
<tbody>
{% for s in servers %}
<tr id="row-{{ s.id }}" class="border-t border-cyber-border/30">
<td class="p-2 text-center"><input type="checkbox" class="srv-check" value="{{ s.id }}" onchange="updateBulk()"></td>
<td class="p-2 font-mono text-cyber-accent">{{ s.hostname }}</td>
<td class="p-2 text-xs text-gray-300" title="{{ s.application_name or '' }}">{{ (s.application_name or '-')[:40] }}</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-center">{{ s.zone_name or '-' }}</td>
<td class="p-2 text-center">{{ s.tier or '-' }}</td>
<td class="p-2 text-center">{{ s.os_family or '-' }}</td>
<td class="p-2">
<textarea id="excl-{{ s.id }}" rows="2" class="w-full font-mono text-xs" placeholder="ex: oracle* tomcat* (un ou plusieurs patterns séparés par espace ou retour ligne)" style="resize:vertical;min-height:36px">{{ s.patch_excludes or '' }}</textarea>
</td>
<td class="p-2 text-center">
<button onclick="saveExcl({{ s.id }})" class="btn-sm bg-cyber-border text-cyber-accent">Sauver</button>
<span id="status-{{ s.id }}" class="text-xs ml-1"></span>
</td>
</tr>
{% endfor %}
</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').forEach(c => c.checked = cb.checked);
updateBulk();
}
function updateBulk() {
const ids = Array.from(document.querySelectorAll('.srv-check:checked')).map(c => parseInt(c.value));
const bar = document.getElementById('bulk-bar');
const count = document.getElementById('bulk-count');
count.textContent = ids.length;
bar.style.display = ids.length > 0 ? 'flex' : 'none';
window._selectedIds = ids;
}
function saveExcl(id) {
const inp = document.getElementById('excl-' + id);
const status = document.getElementById('status-' + id);
status.textContent = '…'; status.className = 'text-xs ml-1 text-gray-400';
const fd = new FormData();
fd.append('patch_excludes', inp.value);
fetch('/patching/config-exclusions/' + id + '/save', {method: 'POST', credentials: 'same-origin', body: fd})
.then(r => r.json())
.then(d => {
if (d.ok) {
status.textContent = d.itop && d.itop.pushed ? '✓ iTop' : '⚠ local';
status.className = 'text-xs ml-1 ' + (d.itop && d.itop.pushed ? 'text-cyber-green' : 'text-cyber-yellow');
status.title = d.itop ? d.itop.msg : '';
} else {
status.textContent = '✗';
status.className = 'text-xs ml-1 text-cyber-red';
status.title = d.msg || '';
}
})
.catch(e => { status.textContent = '✗'; status.className = 'text-xs ml-1 text-cyber-red'; status.title = e.message; });
}
function bulkChangeApp() {
const ids = window._selectedIds || [];
const app = document.getElementById('bulk-app').value;
const appName = document.getElementById('bulk-app').selectedOptions[0].text;
if (!ids.length) return alert('Aucun serveur sélectionné');
if (!confirm('Changer solution applicative vers "' + appName + '" sur ' + ids.length + ' serveur(s) ?')) return;
const res = document.getElementById('bulk-result');
res.textContent = 'En cours...'; res.className = 'text-xs text-gray-400 mt-2';
fetch('/patching/config-exclusions/bulk-application', {
method: 'POST', credentials: 'same-origin',
headers: {'Content-Type': 'application/json'},
body: JSON.stringify({server_ids: ids, application_id: app})
})
.then(r => r.json())
.then(d => {
if (d.ok) {
res.innerHTML = '✓ ' + d.updated + ' serveurs → ' + d.app_name + ' — 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 mt-2';
setTimeout(() => location.reload(), 2000);
} else {
res.textContent = '✗ ' + (d.msg || 'Erreur'); res.className = 'text-xs text-cyber-red mt-2';
}
})
.catch(e => { res.textContent = '✗ ' + e.message; res.className = 'text-xs text-cyber-red mt-2'; });
}
function bulkAction(action) {
const ids = window._selectedIds || [];
const pattern = document.getElementById('bulk-pattern').value.trim();
if (!ids.length) return alert('Aucun serveur sélectionné');
if (action !== 'replace' && !pattern) return alert('Saisir un pattern');
const label = action === 'add' ? 'Ajouter' : action === 'remove' ? 'Retirer' : 'Remplacer tout par';
if (!confirm(label + ' "' + pattern + '" sur ' + ids.length + ' serveur(s) ?')) return;
const res = document.getElementById('bulk-result');
res.textContent = 'En cours...'; res.className = 'text-xs text-gray-400 ml-2';
fetch('/patching/config-exclusions/bulk', {
method: 'POST', credentials: 'same-origin',
headers: {'Content-Type': 'application/json'},
body: JSON.stringify({server_ids: ids, pattern: pattern, action: action})
})
.then(r => r.json())
.then(d => {
if (d.ok) {
res.innerHTML = '✓ ' + d.updated + ' serveurs mis à jour — 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(), 2000);
} else {
res.textContent = '✗ ' + (d.msg || 'Erreur'); res.className = 'text-xs text-cyber-red ml-2';
}
})
.catch(e => { res.textContent = '✗ ' + e.message; res.className = 'text-xs text-cyber-red ml-2'; });
}
</script>
{% endblock %}

View File

@ -0,0 +1,302 @@
{% extends 'base.html' %}
{% block title %}Builder correspondance prod ↔ hors-prod{% endblock %}
{% block content %}
<div class="flex justify-between items-center mb-4">
<div>
<h2 class="text-xl font-bold text-cyber-accent">Builder correspondance Prod ↔ Hors-Prod</h2>
<p class="text-xs text-gray-500 mt-1">Filtrer les serveurs, les désigner comme <b class="text-cyber-green">Prod</b> ou <b class="text-cyber-yellow">Non-Prod</b>, puis générer les liens en masse.</p>
</div>
<div class="flex gap-2">
<a href="/patching/validations" class="btn-sm bg-cyber-border text-cyber-accent px-4 py-2">Validations</a>
</div>
</div>
<!-- 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-blue">{{ stats.total_links }}</div><div class="text-xs text-gray-500">Liens existants (toutes apps)</div></div>
<div class="card p-3 text-center" style="flex:1"><div class="text-2xl font-bold text-cyber-accent">{{ stats.filtered }}</div><div class="text-xs text-gray-500">Serveurs filtrés</div></div>
<div class="card p-3 text-center" style="flex:1"><div class="text-2xl font-bold text-cyber-green" id="selected-prod-count">0</div><div class="text-xs text-gray-500">Marqués PROD</div></div>
<div class="card p-3 text-center" style="flex:1"><div class="text-2xl font-bold text-cyber-yellow" id="selected-nonprod-count">0</div><div class="text-xs text-gray-500">Marqués NON-PROD</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="{{ search }}" placeholder="Hostname..." class="text-xs py-1 px-2" style="width:180px">
<select name="application" class="text-xs py-1 px-2" style="max-width:260px">
<option value="">Toutes applications</option>
{% for a in applications %}<option value="{{ a.application_name }}" {% if application == a.application_name %}selected{% endif %}>{{ a.application_name }}</option>{% endfor %}
</select>
<select name="domain" class="text-xs py-1 px-2" style="width:160px">
<option value="">Tous domaines</option>
{% for d in domains %}<option value="{{ d }}" {% if 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 %}<option value="{{ e }}" {% if env == e %}selected{% endif %}>{{ e }}</option>{% endfor %}
</select>
<button type="submit" class="btn-primary px-3 py-1 text-xs">Filtrer</button>
<a href="/patching/correspondance" class="text-xs text-gray-500 hover:text-cyber-accent">Reset</a>
</form>
</div>
{% if can_edit %}
<!-- Barre actions bulk -->
<div class="card p-3 mb-2" id="bulk-bar" style="display:none">
<div class="flex gap-2 items-center flex-wrap mb-2">
<span class="text-xs text-gray-400"><b id="bulk-count">0</b> sélectionné(s)</span>
</div>
<!-- Section 1: Normaliser iTop (env + app) -->
<div class="flex gap-2 items-center flex-wrap mb-2" style="border-left:3px solid #00d4ff;padding-left:8px">
<span class="text-xs text-cyber-accent font-bold">Normaliser iTop :</span>
<select id="bulk-env" class="text-xs py-1 px-2" style="width:180px">
<option value="">-- Changer env vers --</option>
{% for e in envs %}<option value="{{ e }}">{{ e }}</option>{% endfor %}
</select>
<button onclick="bulkChangeEnv()" class="btn-sm bg-cyber-blue text-black">Appliquer env</button>
<span class="text-gray-600">|</span>
<select id="bulk-app" class="text-xs py-1 px-2" style="max-width:260px">
<option value="">-- Changer solution app. vers --</option>
<option value="__none__">Aucune (désassocier)</option>
{% for a in all_apps %}<option value="{{ a.id }}">{{ a.nom_court }}</option>{% endfor %}
</select>
<button onclick="bulkChangeApp()" class="btn-sm bg-cyber-blue text-black">Appliquer app.</button>
</div>
<!-- Section 2: Correspondance -->
<div class="flex gap-2 items-center flex-wrap" style="border-left:3px solid #00ff88;padding-left:8px">
<span class="text-xs text-cyber-green font-bold">Marquer pour correspondance :</span>
<button onclick="markSelected('prod')" class="btn-sm bg-cyber-green text-black">Marquer PROD</button>
<button onclick="markSelected('nonprod')" class="btn-sm bg-cyber-yellow text-black">Marquer NON-PROD</button>
<button onclick="markSelected('none')" class="btn-sm bg-cyber-border text-gray-300">Démarquer</button>
</div>
<div id="bulk-result" class="text-xs mt-2"></div>
</div>
<!-- Générer correspondances -->
<div class="card p-4 mb-4" style="border-color:#00d4ff55">
<div class="flex gap-3 items-center">
<div style="flex:1">
<b class="text-cyber-accent">Générer correspondances</b>
<p class="text-xs text-gray-400 mt-1">
<span class="text-cyber-green" id="preview-prod">0 prod</span> ×
<span class="text-cyber-yellow" id="preview-nonprod">0 non-prod</span> =
<b class="text-cyber-accent" id="preview-links">0 liens</b>
</p>
</div>
<button onclick="generateCorrespondances()" class="btn-primary px-4 py-2 text-sm" id="btn-generate" disabled>Créer les correspondances</button>
</div>
<div id="gen-result" class="text-xs mt-2"></div>
</div>
{% endif %}
<!-- Tableau -->
<div class="card overflow-x-auto">
<table class="w-full table-cyber text-xs">
<thead><tr>
{% if can_edit %}<th class="p-2 w-8"><input type="checkbox" onchange="toggleAll(this)"></th>{% endif %}
<th class="p-2 text-left">Hostname</th>
<th class="p-2">Env</th>
<th class="p-2 text-left">Application</th>
<th class="p-2">Domaine</th>
<th class="p-2">Zone</th>
<th class="p-2">Liens existants</th>
{% if can_edit %}<th class="p-2">Rôle</th>{% endif %}
</tr></thead>
<tbody>
{% for s in servers %}
<tr class="border-t border-cyber-border/30" id="row-{{ s.id }}" data-env="{{ s.env_name or '' }}">
{% if can_edit %}<td class="p-2 text-center"><input type="checkbox" class="srv-check" value="{{ s.id }}" onchange="updateBulk()"></td>{% endif %}
<td class="p-2 font-mono text-cyber-accent">{{ s.hostname }}</td>
<td class="p-2 text-center">
{% if s.env_name == 'Production' %}<span class="badge badge-green">{{ s.env_name }}</span>
{% elif s.env_name %}<span class="badge badge-yellow">{{ s.env_name }}</span>
{% else %}<span class="text-gray-600">-</span>{% endif %}
</td>
<td class="p-2 text-xs text-gray-300" title="{{ s.application_name or '' }}">{{ (s.application_name or '-')[:35] }}</td>
<td class="p-2 text-center text-gray-400">{{ s.domain_name or '-' }}</td>
<td class="p-2 text-center text-gray-400">{{ s.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>
{% if can_edit %}
<td class="p-2 text-center">
<span id="role-{{ s.id }}" class="badge badge-gray" style="font-size:9px"></span>
</td>
{% endif %}
</tr>
{% endfor %}
{% if not servers %}
<tr><td colspan="8" class="p-6 text-center text-gray-500">Aucun serveur pour ces filtres</td></tr>
{% endif %}
</tbody>
</table>
</div>
{% if can_edit %}
<script>
const markedProd = new Set();
const markedNonProd = new Set();
function toggleAll(cb) {
document.querySelectorAll('.srv-check').forEach(c => c.checked = cb.checked);
updateBulk();
}
function updateBulk() {
const checked = Array.from(document.querySelectorAll('.srv-check:checked')).map(c => parseInt(c.value));
const bar = document.getElementById('bulk-bar');
bar.style.display = checked.length > 0 ? 'block' : 'none';
document.getElementById('bulk-count').textContent = checked.length;
window._selectedIds = checked;
}
function markSelected(role) {
const ids = window._selectedIds || [];
if (!ids.length) return;
// Contrôle de cohérence
const warnings = [];
ids.forEach(id => {
const row = document.getElementById('row-' + id);
const env = row ? (row.dataset.env || '') : '';
if (role === 'prod' && env !== 'Production') {
warnings.push('⚠ ' + row.querySelector('.font-mono').textContent.trim() + ' (env: ' + (env || 'aucun') + ') n\'est pas en Production');
} else if (role === 'nonprod' && env === 'Production') {
warnings.push('⚠ ' + row.querySelector('.font-mono').textContent.trim() + ' est en Production, pas hors-prod');
}
});
if (warnings.length > 0) {
if (!confirm('Incohérence détectée :\n\n' + warnings.join('\n') +
'\n\nVoulez-vous continuer quand même ?\n(Recommandé : corriger d\'abord l\'environnement via "Normaliser iTop")')) return;
}
ids.forEach(id => {
const badge = document.getElementById('role-' + id);
const row = document.getElementById('row-' + id);
if (role === 'prod') {
markedProd.add(id); markedNonProd.delete(id);
badge.className = 'badge badge-green';
badge.textContent = 'PROD';
row.style.background = 'rgba(0, 255, 136, 0.05)';
} else if (role === 'nonprod') {
markedNonProd.add(id); markedProd.delete(id);
badge.className = 'badge badge-yellow';
badge.textContent = 'NON-PROD';
row.style.background = 'rgba(255, 204, 0, 0.05)';
} else {
markedProd.delete(id); markedNonProd.delete(id);
badge.className = 'badge badge-gray'; badge.style.fontSize = '9px';
badge.textContent = '—';
row.style.background = '';
}
});
updateCounters();
// Décocher
document.querySelectorAll('.srv-check').forEach(c => c.checked = false);
document.querySelector('thead input[type=checkbox]').checked = false;
updateBulk();
}
function updateCounters() {
const np = markedProd.size, nn = markedNonProd.size;
document.getElementById('selected-prod-count').textContent = np;
document.getElementById('selected-nonprod-count').textContent = nn;
document.getElementById('preview-prod').textContent = np + ' prod';
document.getElementById('preview-nonprod').textContent = nn + ' non-prod';
document.getElementById('preview-links').textContent = (np * nn) + ' liens';
document.getElementById('btn-generate').disabled = (np === 0 || nn === 0);
}
function bulkChangeEnv() {
const ids = window._selectedIds || [];
const env = document.getElementById('bulk-env').value;
if (!ids.length) return alert('Aucun serveur sélectionné');
if (!env) return alert('Choisir un environnement');
if (!confirm('Changer l\'environnement vers "' + env + '" sur ' + ids.length + ' serveur(s) ?\n(PatchCenter + push iTop)')) return;
const res = document.getElementById('bulk-result');
res.textContent = 'Changement env en cours...'; res.className = 'text-xs mt-2 text-gray-400';
fetch('/patching/correspondance/bulk-env', {
method: 'POST', credentials: 'same-origin',
headers: {'Content-Type': 'application/json'},
body: JSON.stringify({server_ids: ids, env_name: env})
})
.then(r => r.json())
.then(d => {
if (d.ok) {
res.innerHTML = '✓ ' + d.updated + ' → ' + d.env_name + ' — 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 mt-2';
setTimeout(() => location.reload(), 1500);
} else {
res.textContent = '✗ ' + (d.msg || 'Erreur'); res.className = 'text-xs mt-2 text-cyber-red';
}
});
}
function bulkChangeApp() {
const ids = window._selectedIds || [];
let app = document.getElementById('bulk-app').value;
if (!ids.length) return alert('Aucun serveur sélectionné');
if (!app) return alert('Choisir une application');
const appText = document.getElementById('bulk-app').selectedOptions[0].text;
if (!confirm('Changer solution applicative vers "' + appText + '" sur ' + ids.length + ' serveur(s) ?\n(PatchCenter + push iTop)')) return;
if (app === '__none__') app = '';
const res = document.getElementById('bulk-result');
res.textContent = 'Changement app en cours...'; res.className = 'text-xs mt-2 text-gray-400';
fetch('/patching/correspondance/bulk-application', {
method: 'POST', credentials: 'same-origin',
headers: {'Content-Type': 'application/json'},
body: JSON.stringify({server_ids: ids, application_id: app})
})
.then(r => r.json())
.then(d => {
if (d.ok) {
res.innerHTML = '✓ ' + d.updated + ' → ' + d.app_name + ' — 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 mt-2';
setTimeout(() => location.reload(), 1500);
} else {
res.textContent = '✗ ' + (d.msg || 'Erreur'); res.className = 'text-xs mt-2 text-cyber-red';
}
});
}
function generateCorrespondances() {
if (markedProd.size === 0 || markedNonProd.size === 0) return;
const n = markedProd.size * markedNonProd.size;
if (!confirm('Créer ' + n + ' correspondances ?')) return;
// Récupérer les env labels des non-prod depuis le data-env de chaque row
const envLabels = {};
markedNonProd.forEach(id => {
const row = document.getElementById('row-' + id);
if (row) envLabels[id] = row.dataset.env || '';
});
const res = document.getElementById('gen-result');
res.textContent = 'En cours...'; res.className = 'text-xs mt-2 text-gray-400';
fetch('/patching/correspondance/bulk-create', {
method: 'POST', credentials: 'same-origin',
headers: {'Content-Type': 'application/json'},
body: JSON.stringify({
prod_ids: Array.from(markedProd),
nonprod_ids: Array.from(markedNonProd),
env_labels: envLabels,
})
})
.then(r => r.json())
.then(d => {
if (d.ok) {
res.innerHTML = '✓ ' + d.created + ' liens créés, ' + d.skipped + ' déjà existants';
res.className = 'text-xs mt-2 text-cyber-green';
setTimeout(() => location.reload(), 2000);
} else {
res.textContent = '✗ ' + (d.msg || 'Erreur');
res.className = 'text-xs mt-2 text-cyber-red';
}
})
.catch(e => { res.textContent = '✗ ' + e.message; res.className = 'text-xs mt-2 text-cyber-red'; });
}
</script>
{% endif %}
{% endblock %}

View File

@ -0,0 +1,179 @@
{% extends 'base.html' %}
{% block title %}Validations post-patching{% endblock %}
{% block content %}
<div class="flex justify-between items-center mb-4">
<div>
<h2 class="text-xl font-bold text-cyber-accent">Validations post-patching</h2>
<p class="text-xs text-gray-500 mt-1">Serveurs patchés en attente de validation par les responsables applicatifs.</p>
</div>
<a href="/patching/correspondance" class="btn-sm bg-cyber-border text-cyber-accent px-4 py-2">Correspondance</a>
</div>
<!-- KPIs -->
<div class="flex gap-2 mb-4">
<a href="?status=en_attente" class="card p-3 text-center hover:border-cyber-accent" style="flex:1">
<div class="text-2xl font-bold text-cyber-yellow">{{ stats.en_attente }}</div>
<div class="text-xs text-gray-500">En attente</div>
</a>
<a href="?status=validated_ok" class="card p-3 text-center hover:border-cyber-accent" style="flex:1">
<div class="text-2xl font-bold text-cyber-green">{{ stats.validated_ok }}</div>
<div class="text-xs text-gray-500">Validés OK</div>
</a>
<a href="?status=validated_ko" class="card p-3 text-center hover:border-cyber-accent" style="flex:1">
<div class="text-2xl font-bold text-cyber-red">{{ stats.validated_ko }}</div>
<div class="text-xs text-gray-500">KO</div>
</a>
<a href="?status=forced" class="card p-3 text-center hover:border-cyber-accent" style="flex:1">
<div class="text-2xl font-bold text-cyber-blue">{{ stats.forced }}</div>
<div class="text-xs text-gray-500">Forcés</div>
</a>
</div>
<!-- Filtres -->
<div class="card p-3 mb-4">
<form method="GET" class="flex gap-2 items-center flex-wrap">
<select name="status" class="text-xs py-1 px-2">
<option value="en_attente" {% if status == 'en_attente' %}selected{% endif %}>En attente</option>
<option value="validated_ok" {% if status == 'validated_ok' %}selected{% endif %}>Validés OK</option>
<option value="validated_ko" {% if status == 'validated_ko' %}selected{% endif %}>Validés KO</option>
<option value="forced" {% if status == 'forced' %}selected{% endif %}>Forcés</option>
</select>
<select name="env" class="text-xs py-1 px-2">
<option value="">Tous environnements</option>
{% for e in envs %}<option value="{{ e }}" {% if env == e %}selected{% endif %}>{{ e }}</option>{% endfor %}
</select>
{% if campaign_id %}<input type="hidden" name="campaign_id" value="{{ campaign_id }}">{% endif %}
<button type="submit" class="btn-primary px-3 py-1 text-xs">Filtrer</button>
<a href="/patching/validations" class="text-xs text-gray-500 hover:text-cyber-accent">Reset</a>
{% if campaign_id %}<span class="text-xs text-cyber-accent">Campagne #{{ campaign_id }}</span>{% endif %}
</form>
</div>
<!-- Bulk bar -->
<div id="bulk-bar" class="card p-3 mb-2 flex gap-2 items-center flex-wrap" style="display:none">
<span class="text-xs text-gray-400"><b id="bulk-count">0</b> sélectionné(s)</span>
<button onclick="openValidateModal('validated_ok')" class="btn-sm bg-cyber-green text-black">Marquer OK</button>
<button onclick="openValidateModal('validated_ko')" class="btn-sm bg-red-900/40 text-cyber-red">Marquer KO</button>
{% if can_force %}<button onclick="openValidateModal('forced')" class="btn-sm bg-cyber-yellow text-black">Forcer</button>{% endif %}
</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 text-left">Application</th>
<th class="p-2">Env</th>
<th class="p-2">Domaine</th>
<th class="p-2">Patched</th>
<th class="p-2">Jours</th>
<th class="p-2">Statut</th>
<th class="p-2">Validé par</th>
<th class="p-2">Action</th>
</tr></thead>
<tbody>
{% for v in validations %}
<tr class="border-t border-cyber-border/30">
<td class="p-2 text-center"><input type="checkbox" class="val-check" value="{{ v.id }}" onchange="updateBulk()"></td>
<td class="p-2 font-mono text-cyber-accent">{{ v.hostname }}</td>
<td class="p-2 text-xs text-gray-300">{{ (v.application_name or '-')[:30] }}</td>
<td class="p-2 text-center">{{ v.env_name or '-' }}</td>
<td class="p-2 text-center text-gray-400">{{ v.domain_name or '-' }}</td>
<td class="p-2 text-center text-gray-400">{% if v.patch_date %}{{ v.patch_date.strftime('%Y-%m-%d %H:%M') }}{% endif %}</td>
<td class="p-2 text-center {% if v.days_pending and v.days_pending > 7 %}text-cyber-red font-bold{% endif %}">{{ v.days_pending|int if v.days_pending else '-' }}</td>
<td class="p-2 text-center">
{% if v.status == 'en_attente' %}<span class="badge badge-yellow">En attente</span>
{% elif v.status == 'validated_ok' %}<span class="badge badge-green">✓ OK</span>
{% elif v.status == 'validated_ko' %}<span class="badge badge-red">✗ KO</span>
{% elif v.status == 'forced' %}<span class="badge badge-yellow" title="{{ v.forced_reason }}">Forcé</span>
{% endif %}
</td>
<td class="p-2 text-xs text-gray-300">
{% if v.validated_by_name %}{{ v.validated_by_name }}{% if v.validated_at %}<div class="text-gray-500" style="font-size:10px">{{ v.validated_at.strftime('%Y-%m-%d') }}</div>{% endif %}
{% else %}—{% endif %}
</td>
<td class="p-2 text-center">
<a href="/patching/validations/history/{{ v.server_id }}" class="text-xs text-cyber-accent hover:underline">Historique</a>
</td>
</tr>
{% endfor %}
{% if not validations %}
<tr><td colspan="10" class="p-6 text-center text-gray-500">Aucune validation dans ce filtre</td></tr>
{% endif %}
</tbody>
</table>
</div>
<!-- Modal validation -->
<div id="validate-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:420px;max-width:95vw">
<h3 class="text-sm font-bold text-cyber-accent mb-3" id="validate-title">Valider</h3>
<div id="validator-zone">
<label class="text-xs text-gray-500 block mb-1">Validé par (contact iTop obligatoire)</label>
<select id="val-contact-id" class="w-full text-xs">
<option value="">-- Sélectionner --</option>
{% for c in contacts %}<option value="{{ c.id }}">{{ c.name }} ({{ c.team or c.role }})</option>{% endfor %}
</select>
</div>
<div id="forced-zone" style="display:none" class="mt-3">
<label class="text-xs text-gray-500 block mb-1">Raison du forçage (obligatoire)</label>
<textarea id="val-reason" rows="3" class="w-full text-xs"></textarea>
</div>
<div class="mt-3">
<label class="text-xs text-gray-500 block mb-1">Notes (optionnel)</label>
<textarea id="val-notes" rows="2" class="w-full text-xs"></textarea>
</div>
<div class="flex gap-2 mt-4 justify-end">
<button onclick="document.getElementById('validate-modal').style.display='none'" class="btn-sm bg-cyber-border text-gray-300">Annuler</button>
<button onclick="submitValidate()" class="btn-primary px-3 py-1 text-xs">Enregistrer</button>
</div>
</div>
</div>
<script>
let _valStatus = null;
function toggleAll(cb) {
document.querySelectorAll('.val-check').forEach(c => c.checked = cb.checked);
updateBulk();
}
function updateBulk() {
const ids = Array.from(document.querySelectorAll('.val-check:checked')).map(c => parseInt(c.value));
const bar = document.getElementById('bulk-bar');
bar.style.display = ids.length > 0 ? 'flex' : 'none';
document.getElementById('bulk-count').textContent = ids.length;
window._selectedValIds = ids;
}
function openValidateModal(status) {
_valStatus = status;
const titles = {'validated_ok':'Marquer OK', 'validated_ko':'Marquer KO', 'forced':'Forcer'};
document.getElementById('validate-title').textContent = titles[status] + ' — ' + (window._selectedValIds || []).length + ' serveur(s)';
document.getElementById('forced-zone').style.display = (status === 'forced') ? 'block' : 'none';
document.getElementById('validator-zone').style.display = (status === 'forced') ? 'none' : 'block';
document.getElementById('val-reason').value = '';
document.getElementById('val-notes').value = '';
document.getElementById('validate-modal').style.display = 'flex';
}
function submitValidate() {
const ids = window._selectedValIds || [];
if (!ids.length) return alert('Aucun sélectionné');
const payload = {
validation_ids: ids,
status: _valStatus,
contact_id: document.getElementById('val-contact-id').value || null,
forced_reason: document.getElementById('val-reason').value,
notes: document.getElementById('val-notes').value,
};
fetch('/patching/validations/mark', {
method: 'POST', credentials: 'same-origin',
headers: {'Content-Type': 'application/json'},
body: JSON.stringify(payload)
})
.then(r => r.json())
.then(d => {
if (d.ok) { alert(d.updated + ' marqué(s)'); location.reload(); }
else alert('Erreur: ' + (d.msg || ''));
});
}
</script>
{% endblock %}

View File

@ -0,0 +1,48 @@
{% extends 'base.html' %}
{% block title %}Historique validations — {{ server.hostname }}{% endblock %}
{% block content %}
<div class="flex justify-between items-center mb-4">
<div>
<a href="/patching/validations" class="text-xs text-gray-500 hover:text-gray-300">&larr; Validations</a>
<h2 class="text-xl font-bold text-cyber-accent">Historique — <span class="font-mono">{{ server.hostname }}</span></h2>
</div>
</div>
<div class="card overflow-x-auto">
<table class="w-full table-cyber text-xs">
<thead><tr>
<th class="p-2">Patched</th>
<th class="p-2">Campagne</th>
<th class="p-2">Statut</th>
<th class="p-2">Validé par</th>
<th class="p-2">Le</th>
<th class="p-2 text-left">Raison forcée</th>
<th class="p-2 text-left">Notes</th>
<th class="p-2">Marqué par</th>
</tr></thead>
<tbody>
{% for h in history %}
<tr class="border-t border-cyber-border/30">
<td class="p-2 text-center">{{ h.patch_date.strftime('%Y-%m-%d %H:%M') if h.patch_date }}</td>
<td class="p-2 text-center text-gray-400">{{ h.campaign_type or '-' }} #{{ h.campaign_id or '' }}</td>
<td class="p-2 text-center">
{% if h.status == 'en_attente' %}<span class="badge badge-yellow">En attente</span>
{% elif h.status == 'validated_ok' %}<span class="badge badge-green">OK</span>
{% elif h.status == 'validated_ko' %}<span class="badge badge-red">KO</span>
{% elif h.status == 'forced' %}<span class="badge badge-yellow">Forcé</span>
{% endif %}
</td>
<td class="p-2 text-center">{{ h.validated_by_name or '—' }}</td>
<td class="p-2 text-center text-gray-400">{{ h.validated_at.strftime('%Y-%m-%d %H:%M') if h.validated_at else '—' }}</td>
<td class="p-2 text-gray-300">{{ h.forced_reason or '' }}</td>
<td class="p-2 text-gray-300">{{ h.notes or '' }}</td>
<td class="p-2 text-center text-gray-400">{{ h.marked_by or '—' }}</td>
</tr>
{% endfor %}
{% if not history %}
<tr><td colspan="8" class="p-6 text-center text-gray-500">Aucun historique</td></tr>
{% endif %}
</tbody>
</table>
</div>
{% endblock %}

View File

@ -7,26 +7,71 @@
<p class="text-xs text-gray-500 mt-1">Activation keys et versions des agents déployés</p>
</div>
<div style="display:flex;gap:8px">
<form method="POST" action="/qualys/agents/refresh" style="display:inline">
<button type="submit" class="btn-primary px-4 py-2 text-sm"
onclick="this.disabled=true;this.textContent='Rafraîchissement...'">
Rafraîchir depuis Qualys
</button>
</form>
<button id="btn-refresh" class="btn-primary px-4 py-2 text-sm" onclick="refreshAgents()">
Rafraîchir depuis Qualys
</button>
<a href="/qualys/deploy" class="btn-sm bg-cyber-border text-gray-300 px-4 py-2">Déployer</a>
<a href="/qualys/search" class="btn-sm bg-cyber-border text-gray-300 px-4 py-2">Recherche</a>
</div>
</div>
{% if 'refresh_ok' in msg %}
<div style="background:#1a5a2e;color:#8f8;padding:8px 16px;border-radius:6px;margin-bottom:12px;font-size:0.85rem">
Données rafraîchies depuis Qualys.
<!-- Overlay chargement -->
<div id="refresh-overlay" 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-6 text-center" style="min-width:320px">
<div style="margin-bottom:12px">
<svg style="display:inline;animation:spin 1s linear infinite;width:36px;height:36px" viewBox="0 0 24 24" fill="none" stroke="#00ffc8" stroke-width="2"><circle cx="12" cy="12" r="10" stroke-opacity="0.3"/><path d="M12 2a10 10 0 0 1 10 10" stroke-linecap="round"/></svg>
</div>
<div class="text-cyber-accent font-bold text-sm" id="refresh-title">Rafraîchissement en cours...</div>
<div class="text-gray-400 text-xs mt-2" id="refresh-detail">Synchronisation des agents depuis l'API Qualys</div>
<div class="text-gray-500 text-xs mt-3" id="refresh-timer">0s</div>
</div>
</div>
{% elif msg == 'refresh_error' %}
<div style="background:#5a1a1a;color:#ff3366;padding:8px 16px;border-radius:6px;margin-bottom:12px;font-size:0.85rem">
Erreur lors du rafraîchissement.
</div>
{% endif %}
<style>@keyframes spin{to{transform:rotate(360deg)}}</style>
<!-- Message résultat -->
<div id="refresh-msg" style="display:none;padding:8px 16px;border-radius:6px;margin-bottom:12px;font-size:0.85rem"></div>
<script>
function refreshAgents() {
var btn = document.getElementById('btn-refresh');
var overlay = document.getElementById('refresh-overlay');
var timer = document.getElementById('refresh-timer');
var msgDiv = document.getElementById('refresh-msg');
btn.disabled = true;
overlay.style.display = 'flex';
msgDiv.style.display = 'none';
var t0 = Date.now();
var iv = setInterval(function(){ timer.textContent = Math.floor((Date.now()-t0)/1000) + 's'; }, 1000);
fetch('/qualys/agents/refresh', {method:'POST', credentials:'same-origin'})
.then(function(r){ return r.json().then(function(d){ return {ok:r.ok, data:d}; }); })
.then(function(res){
clearInterval(iv);
overlay.style.display = 'none';
btn.disabled = false;
if(res.ok && res.data.ok){
msgDiv.style.background = '#1a5a2e';
msgDiv.style.color = '#8f8';
msgDiv.textContent = 'Données rafraîchies : ' + res.data.msg;
msgDiv.style.display = 'block';
setTimeout(function(){ location.reload(); }, 1500);
} else {
msgDiv.style.background = '#5a1a1a';
msgDiv.style.color = '#ff3366';
msgDiv.textContent = 'Erreur : ' + (res.data.msg || 'Erreur inconnue');
msgDiv.style.display = 'block';
}
})
.catch(function(err){
clearInterval(iv);
overlay.style.display = 'none';
btn.disabled = false;
msgDiv.style.background = '#5a1a1a';
msgDiv.style.color = '#ff3366';
msgDiv.textContent = 'Erreur réseau : ' + err.message;
msgDiv.style.display = 'block';
});
}
</script>
<!-- KPIs agents -->
<div style="display:flex;flex-wrap:nowrap;gap:8px;margin-bottom:16px;">
@ -156,7 +201,7 @@
<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 == 'en_production' %}badge-green{% elif s.etat == 'decommissionne' %}badge-red{% elif s.etat == 'eteint' %}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 == 'obsolete' %}badge-red{% elif s.etat == 'stock' %}badge-gray{% else %}badge-yellow{% endif %}">{{ (s.etat or '-')[:8] }}</span></td>
</tr>
{% endfor %}
</tbody>

View File

@ -78,28 +78,20 @@
</div>
<!-- Actions -->
<div style="display:flex;gap:8px">
<form method="POST" action="/qualys/deploy/run" id="deployForm">
<input type="hidden" name="server_ids" :value="selectedIds.join(',')">
<input type="hidden" name="activation_id" :value="document.getElementById('activation_id').value">
<input type="hidden" name="customer_id" :value="document.getElementById('customer_id').value">
<input type="hidden" name="server_uri" :value="document.getElementById('server_uri').value">
<input type="hidden" name="package_deb" :value="document.getElementById('package_deb').value">
<input type="hidden" name="package_rpm" :value="document.getElementById('package_rpm').value">
<button type="submit" class="btn-primary px-4 py-2 text-sm"
:disabled="selectedIds.length === 0"
onclick="if(!confirm('Déployer l\'agent sur ' + selectedIds.length + ' serveur(s) ?')) return false; this.textContent='Déploiement en cours...'">
Déployer l'agent
</button>
</form>
<form method="POST" action="/qualys/deploy/check">
<input type="hidden" name="server_ids" :value="selectedIds.join(',')">
<button type="submit" style="padding:8px 16px;font-size:0.85rem;background:#334155;color:#e2e8f0;border:1px solid #475569;border-radius:6px;cursor:pointer"
:disabled="selectedIds.length === 0"
onclick="this.textContent='Vérification...'">
Vérifier l'agent
</button>
</form>
<div style="display:flex;gap:8px;align-items:center">
<button id="btn-deploy" class="btn-primary px-4 py-2 text-sm"
:disabled="selectedIds.length === 0"
@click="deployAgent(selectedIds)">
Déployer l'agent
</button>
<button id="btn-check" style="padding:8px 16px;font-size:0.85rem;background:#334155;color:#e2e8f0;border:1px solid #475569;border-radius:6px;cursor:pointer"
:disabled="selectedIds.length === 0"
@click="checkAgent(selectedIds)">
Vérifier l'agent
</button>
<label class="text-xs text-gray-400 ml-4" style="display:flex;align-items:center;gap:4px">
<input type="checkbox" id="force_downgrade"> Forcer le downgrade
</label>
</div>
<!-- Serveurs -->
@ -109,10 +101,11 @@
<th class="p-2 w-8"><input type="checkbox" @change="selectAll = $event.target.checked; selectedIds = selectAll ? servers.map(s => s.id) : []"
x-init="servers = {{ servers | tojson }}"></th>
<th class="p-2 text-left">Hostname</th>
<th class="p-2">OS</th>
<th class="p-2">OS / Version</th>
<th class="p-2">Domaine</th>
<th class="p-2">Env</th>
<th class="p-2">État</th>
<th class="p-2">Agent installé</th>
<th class="p-2">SSH</th>
</tr></thead>
<tbody>
@ -129,13 +122,20 @@
<td class="p-2 text-center">
{% if s.os_family == 'linux' %}<span class="badge badge-green">Linux</span>
{% else %}<span class="badge badge-blue">{{ s.os_family or '?' }}</span>{% endif %}
{% if s.os_version %}<div class="text-gray-400 mt-1" style="font-size:10px">{{ s.os_version }}</div>{% endif %}
</td>
<td class="p-2 text-center text-gray-400">{{ s.domain or '-' }}</td>
<td class="p-2 text-center">{{ s.env or '-' }}</td>
<td class="p-2 text-center">
{% if s.etat == 'en_production' %}<span class="badge badge-green">Prod</span>
{% if s.etat == 'production' %}<span class="badge badge-green">Prod</span>
{% else %}{{ s.etat or '-' }}{% endif %}
</td>
<td class="p-2 text-center">
{% if s.agent_version %}
<span class="font-mono text-cyber-green">{{ s.agent_version }}</span>
<span class="ml-1 badge {% if s.agent_status and 'ACTIVE' in (s.agent_status|upper) and 'INACTIVE' not in (s.agent_status|upper) %}badge-green{% elif s.agent_status and 'INACTIVE' in (s.agent_status|upper) %}badge-red{% else %}badge-gray{% endif %}" style="font-size:9px">{{ s.agent_status or '?' }}</span>
{% else %}<span class="text-gray-600"></span>{% endif %}
</td>
<td class="p-2 text-center text-gray-500">{{ s.ssh_user or 'root' }}:{{ s.ssh_port or 22 }}</td>
</tr>
{% endfor %}
@ -143,4 +143,248 @@
</table>
</div>
</div>
<!-- Zone progression / résultats -->
<div id="progress-zone" style="display:none" class="mt-4 space-y-4">
<div class="card p-4">
<div class="flex justify-between items-center mb-3">
<h3 class="text-sm font-bold text-cyber-accent" id="progress-title">Déploiement en cours...</h3>
<span class="text-xs text-gray-400 font-mono" id="progress-timer">0s</span>
</div>
<!-- Barre de progression globale -->
<div style="background:#1e293b;border-radius:4px;height:8px;margin-bottom:16px;overflow:hidden">
<div id="progress-bar" style="height:100%;background:#00ffc8;width:0%;transition:width 0.5s ease"></div>
</div>
<div class="text-xs text-gray-400 mb-3" id="progress-summary"></div>
<!-- Tableau progression par serveur -->
<table class="w-full table-cyber text-xs">
<thead><tr>
<th class="p-2 text-left">Hostname</th>
<th class="p-2">Étape</th>
<th class="p-2 text-left">Détail</th>
</tr></thead>
<tbody id="progress-body"></tbody>
</table>
</div>
<!-- Log détaillé (affiché une fois terminé) -->
<div id="progress-log" class="card p-4" style="display:none">
<h3 class="text-sm font-bold text-cyber-accent mb-2">Log détaillé</h3>
<div id="progress-log-content" style="background:#0a0a23;border-radius:6px;padding:12px;max-height:400px;overflow-y:auto;font-family:monospace;font-size:0.75rem;color:#00ff88;white-space:pre-wrap"></div>
</div>
</div>
<!-- Overlay vérification (check reste synchrone, c'est rapide) -->
<div id="check-overlay" style="display:none;position:fixed;inset:0;background:rgba(0,0,0,0.75);z-index:9999;justify-content:center;align-items:center">
<div class="card p-6 text-center" style="min-width:360px">
<div style="margin-bottom:12px">
<svg style="display:inline;animation:opspin 1s linear infinite;width:40px;height:40px" viewBox="0 0 24 24" fill="none" stroke="#00ffc8" stroke-width="2"><circle cx="12" cy="12" r="10" stroke-opacity="0.3"/><path d="M12 2a10 10 0 0 1 10 10" stroke-linecap="round"/></svg>
</div>
<div class="text-cyber-accent font-bold text-sm" id="check-title">Vérification en cours...</div>
<div class="text-gray-400 text-xs mt-2" id="check-detail"></div>
<div class="text-gray-500 text-xs mt-3 font-mono" id="check-timer">0s</div>
</div>
</div>
<style>@keyframes opspin{to{transform:rotate(360deg)}}</style>
<!-- Zone résultats check -->
<div id="check-results" style="display:none" class="mt-4 space-y-4">
<div id="check-kpis" style="display:flex;gap:8px;margin-bottom:16px"></div>
<div id="check-table" class="card overflow-hidden"></div>
</div>
<script>
var _pollTimer = null;
var _checkTimer = null;
var STAGE_LABELS = {
'pending': {label: 'En attente', cls: 'badge-gray', icon: '&#9679;'},
'connecting': {label: 'Connexion SSH', cls: 'badge-yellow', icon: '&#8635;'},
'checking': {label: 'Vérification', cls: 'badge-yellow', icon: '&#8635;'},
'copying': {label: 'Copie package', cls: 'badge-yellow', icon: '&#8635;'},
'installing': {label: 'Installation', cls: 'badge-yellow', icon: '&#8635;'},
'activating': {label: 'Activation', cls: 'badge-yellow', icon: '&#8635;'},
'restarting': {label: 'Redémarrage', cls: 'badge-yellow', icon: '&#8635;'},
'verifying': {label: 'Vérification', cls: 'badge-yellow', icon: '&#8635;'},
'success': {label: 'Succès', cls: 'badge-green', icon: '&#10003;'},
'already_installed': {label: 'Déjà installé', cls: 'badge-blue', icon: '&#10003;'},
'downgrade_refused': {label: 'Downgrade refusé', cls: 'badge-yellow', icon: '&#9888;'},
'partial': {label: 'Partiel', cls: 'badge-yellow', icon: '&#9888;'},
'failed': {label: 'Échec', cls: 'badge-red', icon: '&#10007;'},
};
function _stageBadge(stage) {
var s = STAGE_LABELS[stage] || {label: stage, cls: 'badge-gray', icon: '?'};
var anim = (stage !== 'pending' && stage !== 'success' && stage !== 'failed' && stage !== 'partial' && stage !== 'already_installed')
? ' style="animation:pulse 1.5s ease-in-out infinite"' : '';
return '<span class="badge ' + s.cls + '"' + anim + '>' + s.icon + ' ' + s.label + '</span>';
}
function deployAgent(ids) {
if (!ids.length) return;
if (!confirm('Déployer l\'agent sur ' + ids.length + ' serveur(s) ?\n\nLe déploiement se fait en arrière-plan.')) return;
document.getElementById('btn-deploy').disabled = true;
fetch('/qualys/deploy/run', {
method: 'POST', credentials: 'same-origin',
headers: {'Content-Type': 'application/json'},
body: JSON.stringify({
server_ids: ids.join(','),
activation_id: document.getElementById('activation_id').value,
customer_id: document.getElementById('customer_id').value,
server_uri: document.getElementById('server_uri').value,
package_deb: document.getElementById('package_deb').value,
package_rpm: document.getElementById('package_rpm').value,
force_downgrade: document.getElementById('force_downgrade').checked,
})
})
.then(function(r){
var ct = r.headers.get('content-type') || '';
if (ct.indexOf('json') === -1) throw new Error('Erreur serveur (HTTP ' + r.status + ')');
return r.json();
})
.then(function(data){
if (data.ok && data.job_id) {
startPolling(data.job_id, data.total);
} else {
alert('Erreur: ' + (data.msg || 'Erreur inconnue'));
document.getElementById('btn-deploy').disabled = false;
}
})
.catch(function(err){
alert('Erreur: ' + err.message);
document.getElementById('btn-deploy').disabled = false;
});
}
function startPolling(jobId, total) {
var zone = document.getElementById('progress-zone');
var title = document.getElementById('progress-title');
var timer = document.getElementById('progress-timer');
var bar = document.getElementById('progress-bar');
var summary = document.getElementById('progress-summary');
var tbody = document.getElementById('progress-body');
zone.style.display = 'block';
zone.scrollIntoView({behavior: 'smooth'});
title.textContent = 'Déploiement en cours... (' + total + ' serveur(s))';
function poll() {
fetch('/qualys/deploy/status/' + jobId, {credentials: 'same-origin'})
.then(function(r){ return r.json(); })
.then(function(data){
if (!data.ok) return;
timer.textContent = data.elapsed + 's';
var pct = data.total > 0 ? Math.round((data.done / data.total) * 100) : 0;
bar.style.width = pct + '%';
summary.textContent = data.done + ' / ' + data.total + ' terminé(s)';
// Build rows
var rows = '';
var hostnames = Object.keys(data.servers).sort();
hostnames.forEach(function(hn){
var s = data.servers[hn];
rows += '<tr class="border-t border-cyber-border/30">';
rows += '<td class="p-2 font-mono">' + hn + '</td>';
rows += '<td class="p-2 text-center">' + _stageBadge(s.stage) + '</td>';
rows += '<td class="p-2 text-gray-400 text-xs">' + (s.detail || '') + '</td>';
rows += '</tr>';
});
tbody.innerHTML = rows;
if (data.finished) {
if (_pollTimer) { clearInterval(_pollTimer); _pollTimer = null; }
var ok = 0, fail = 0;
hostnames.forEach(function(hn){
var st = data.servers[hn].stage;
if (st === 'success' || st === 'already_installed') ok++;
else if (st === 'failed') fail++;
});
title.innerHTML = 'Déploiement terminé — <span class="text-cyber-green">' + ok + ' OK</span> / <span class="text-cyber-red">' + fail + ' échec(s)</span>';
bar.style.background = fail > 0 ? '#ff3366' : '#00ffc8';
document.getElementById('btn-deploy').disabled = false;
// Show log
if (data.log && data.log.length > 0) {
document.getElementById('progress-log-content').textContent = data.log.join('\n');
document.getElementById('progress-log').style.display = 'block';
}
}
})
.catch(function(){});
}
poll();
_pollTimer = setInterval(poll, 2000);
}
// === Check (reste synchrone, c'est rapide ~5-15s par serveur) ===
function checkAgent(ids) {
if (!ids.length) return;
var ov = document.getElementById('check-overlay');
var timer = document.getElementById('check-timer');
document.getElementById('check-title').textContent = 'Vérification de ' + ids.length + ' serveur(s)...';
document.getElementById('check-detail').textContent = 'Connexion SSH et vérification du statut';
timer.textContent = '0s';
ov.style.display = 'flex';
var t0 = Date.now();
if (_checkTimer) clearInterval(_checkTimer);
_checkTimer = setInterval(function(){ timer.textContent = Math.floor((Date.now()-t0)/1000) + 's'; }, 1000);
fetch('/qualys/deploy/check', {
method: 'POST', credentials: 'same-origin',
headers: {'Content-Type': 'application/json'},
body: JSON.stringify({server_ids: ids.join(',')})
})
.then(function(r){
var ct = r.headers.get('content-type') || '';
if (ct.indexOf('json') === -1) throw new Error('Erreur serveur (HTTP ' + r.status + ')');
return r.json();
})
.then(function(data){
clearInterval(_checkTimer);
ov.style.display = 'none';
if (data.ok) showCheckResults(data);
else alert('Erreur: ' + (data.msg || ''));
})
.catch(function(err){
clearInterval(_checkTimer);
ov.style.display = 'none';
alert('Erreur: ' + err.message);
});
}
function showCheckResults(data) {
var zone = document.getElementById('check-results');
var kpis = document.getElementById('check-kpis');
var tbl = document.getElementById('check-table');
kpis.innerHTML =
'<div class="card p-3 text-center" style="flex:1"><div class="text-2xl font-bold text-cyber-accent">' + data.total + '</div><div class="text-xs text-gray-500">Total</div></div>' +
'<div class="card p-3 text-center" style="flex:1"><div class="text-2xl font-bold text-cyber-green">' + (data.active||0) + '</div><div class="text-xs text-gray-500">Actifs</div></div>' +
'<div class="card p-3 text-center" style="flex:1"><div class="text-2xl font-bold text-cyber-yellow">' + (data.not_installed||0) + '</div><div class="text-xs text-gray-500">Non installés</div></div>' +
'<div class="card p-3 text-center" style="flex:1"><div class="text-2xl font-bold text-cyber-red">' + (data.failed||0) + '</div><div class="text-xs text-gray-500">Connexion échouée</div></div>';
var rows = '';
data.results.forEach(function(r){
var cls = r.status==='ACTIVE'?'badge-green': r.status==='NOT_INSTALLED'?'badge-yellow': r.status==='INACTIVE'?'badge-yellow': 'badge-red';
rows += '<tr class="border-t border-cyber-border/30">';
rows += '<td class="p-2 font-mono">' + r.hostname + '</td>';
rows += '<td class="p-2 text-center"><span class="badge ' + cls + '">' + r.status + '</span></td>';
rows += '<td class="p-2 text-gray-400">' + (r.detail||'') + '</td>';
rows += '<td class="p-2 text-center font-mono text-gray-400">' + (r.version||'-') + '</td>';
rows += '<td class="p-2 text-center">' + (r.service_status||'-') + '</td>';
rows += '</tr>';
});
tbl.innerHTML = '<table class="w-full table-cyber text-xs"><thead><tr><th class="p-2 text-left">Hostname</th><th class="p-2">Statut</th><th class="p-2 text-left">Détail</th><th class="p-2">Version</th><th class="p-2">Service</th></tr></thead><tbody>' + rows + '</tbody></table>';
zone.style.display = 'block';
zone.scrollIntoView({behavior:'smooth'});
}
</script>
<style>
@keyframes pulse { 0%,100%{opacity:1} 50%{opacity:0.5} }
</style>
{% endblock %}

View File

@ -1,206 +1,48 @@
{% extends "base.html" %}
{% block title %}QuickWin Config{% endblock %}
{% macro qs(p) -%}
?page={{ p }}&per_page={{ per_page }}&search={{ filters.search or '' }}&env={{ filters.env or '' }}&domain={{ filters.domain or '' }}&zone={{ filters.zone or '' }}
{%- endmacro %}
{% extends 'base.html' %}
{% block title %}QuickWin — Packages avec reboot{% endblock %}
{% block content %}
<div class="flex items-center justify-between mb-6">
<div class="flex justify-between items-center mb-4">
<div>
<a href="/quickwin" class="text-xs text-gray-500 hover:text-gray-300">&larr; Retour QuickWin</a>
<h1 class="text-xl font-bold" style="color:#00d4ff">Exclusions par serveur</h1>
<p class="text-xs text-gray-500">Tous les serveurs Linux en_production / secops &mdash; exclusions g&eacute;n&eacute;rales par d&eacute;faut pr&eacute;-remplies &mdash; pas de reboot n&eacute;cessaire</p>
</div>
<div class="flex gap-2 items-center">
<span class="text-sm text-gray-400">{{ total_count }} serveur(s)</span>
<button onclick="document.getElementById('bulkModal').style.display='flex'" class="btn-primary" style="padding:6px 16px;font-size:0.85rem">Modifier en masse</button>
<h2 class="text-xl font-bold text-cyber-accent">Packages nécessitant un reboot</h2>
<p class="text-xs text-gray-500 mt-1">Liste globale utilisée par QuickWin pour exclure ces packages du <code>yum update</code>.</p>
</div>
<a href="/quickwin" class="btn-sm bg-cyber-border text-cyber-accent px-4 py-2">&larr; Retour QuickWin</a>
</div>
{% if msg %}
<div style="background:#1a5a2e;color:#8f8;padding:8px 16px;border-radius:6px;margin-bottom:12px;font-size:0.85rem">
{% if 'saved' in msg %}Configuration sauvegard&eacute;e{% elif 'deleted' in msg %}Exclusions sp&eacute;cifiques retir&eacute;es{% elif 'added' in msg %}{{ msg.split('_')[1] }} serveur(s) mis &agrave; jour{% elif 'bulk' in msg %}Mise &agrave; jour group&eacute;e OK{% else %}{{ msg }}{% endif %}
</div>
{% if msg == 'saved' %}
<div class="mb-3 p-2 rounded bg-green-900/30 text-cyber-green text-sm">Liste des reboot packages sauvegardée.</div>
{% endif %}
<!-- Filtre -->
<form method="GET" class="card mb-4" style="padding:10px 16px;display:flex;gap:12px;align-items:center">
<input type="text" name="search" value="{{ filters.search or '' }}" placeholder="Recherche serveur..." style="width:200px">
<select name="env" onchange="this.form.submit()" style="width:140px">
<option value="">Tous env.</option>
{% set envs = all_configs|map(attribute='environnement')|select('string')|unique|sort %}
{% for e in envs %}<option value="{{ e }}" {% if filters.env == e %}selected{% endif %}>{{ e }}</option>{% endfor %}
</select>
<select name="domain" onchange="this.form.submit()" style="width:140px">
<option value="">Tous domaines</option>
{% set doms = all_configs|map(attribute='domaine')|select('string')|unique|sort %}
{% for d in doms %}<option value="{{ d }}" {% if filters.domain == d %}selected{% endif %}>{{ d }}</option>{% endfor %}
</select>
<select name="zone" onchange="this.form.submit()" style="width:100px">
<option value="">Zone</option>
{% set zones = all_configs|map(attribute='zone')|select('string')|unique|sort %}
{% for z in zones %}<option value="{{ z }}" {% if filters.zone == z %}selected{% endif %}>{{ z }}</option>{% endfor %}
</select>
<select name="per_page" onchange="this.form.submit()" style="width:140px">
<option value="">Affichage / page</option>
{% for n in [14, 25, 50, 100] %}<option value="{{ n }}" {% if per_page == n %}selected{% endif %}>{{ n }} par page</option>{% endfor %}
</select>
<button type="submit" class="btn-primary" style="padding:4px 14px;font-size:0.8rem">Filtrer</button>
<a href="/quickwin/config" class="text-xs text-gray-500 hover:text-gray-300">Reset</a>
<span class="text-xs text-gray-500">{{ total_count }} serveur(s)</span>
</form>
<!-- Info -->
<div class="card p-3 mb-4 text-xs text-gray-400" style="background:#111827">
<b class="text-cyber-accent">Fonctionnement :</b>
<ul class="list-disc ml-5 mt-2 space-y-1">
<li>Les <b>campagnes QuickWin</b> excluent ces packages + les exclusions par serveur (iTop / <a href="/patching/config-exclusions" class="text-cyber-accent hover:underline">Config exclusions</a>).</li>
<li>Les <b>campagnes standard avec reboot</b> n'utilisent PAS cette liste — uniquement les exclusions par serveur.</li>
<li>Format : patterns yum séparés par espace (ex: <code>kernel* glibc* systemd*</code>).</li>
</ul>
</div>
<!-- Cartouche detail serveur -->
<div id="srvDetail" class="card mb-4" style="display:none;border-left:3px solid #00d4ff;padding:12px 16px">
<div class="flex items-center justify-between mb-2">
<h3 style="color:#00d4ff;font-weight:bold;font-size:0.95rem" id="detailName"></h3>
<button onclick="document.getElementById('srvDetail').style.display='none'" class="text-gray-500 hover:text-gray-300" style="font-size:1.2rem">&times;</button>
</div>
<div class="grid grid-cols-2 gap-4">
<!-- Formulaire -->
<div class="card p-4">
<form method="POST" action="/quickwin/config/save" class="space-y-3">
<div>
<div class="text-xs text-gray-500 mb-1 font-bold">Exclusions g&eacute;n&eacute;rales (OS / reboot)</div>
<pre id="detailGeneral" style="font-size:0.7rem;color:#ffcc00;white-space:pre-wrap;margin:0"></pre>
<label class="text-xs text-gray-500 block mb-1">Patterns de packages à exclure (séparés par espace)</label>
<textarea name="reboot_packages" rows="6" class="w-full font-mono text-xs" style="min-height:120px">{{ reboot_packages }}</textarea>
</div>
<div>
<div class="text-xs text-gray-500 mb-1 font-bold">Exclusions sp&eacute;cifiques (applicatifs &mdash; hors p&eacute;rim&egrave;tre secops)</div>
<pre id="detailSpecific" style="font-size:0.7rem;color:#ff8800;white-space:pre-wrap;margin:0"></pre>
<div class="flex gap-2 items-center">
<button type="submit" class="btn-primary px-4 py-2 text-sm">Sauvegarder</button>
<button type="button" onclick="document.querySelector('textarea[name=reboot_packages]').value = {{ default_packages|tojson }}" class="btn-sm bg-cyber-border text-gray-300 px-3 py-2">Réinitialiser aux valeurs par défaut</button>
</div>
</div>
</form>
</div>
<!-- Tableau serveurs -->
<div class="card">
<div class="table-wrap">
<table class="table-cyber w-full" id="srvTable">
<thead><tr>
<th class="px-2 py-2" style="width:30px"><input type="checkbox" id="checkAll" onchange="toggleAll(this)"></th>
<th class="px-2 py-2">Serveur</th>
<th class="px-2 py-2">Domaine</th>
<th class="px-2 py-2">Env</th>
<th class="px-2 py-2">Zone</th>
<th class="px-2 py-2">Tier</th>
<th class="px-2 py-2">Exclusions g&eacute;n&eacute;rales</th>
<th class="px-2 py-2">Exclusions sp&eacute;cifiques</th>
<th class="px-2 py-2">Notes</th>
<th class="px-2 py-2" style="width:60px">Save</th>
<th class="px-2 py-2" style="width:60px">Cmd</th>
</tr></thead>
<tbody>
{% for s in all_servers %}
<tr>
<td class="px-2 py-2"><input type="checkbox" class="srv-check" value="{{ s.server_id }}"></td>
<td class="px-2 py-2 font-bold" style="color:#00d4ff;cursor:pointer" onclick="showDetail('{{ s.hostname }}', this)">{{ s.hostname }}</td>
<td class="px-2 py-2 text-gray-400 text-xs">{{ s.domaine or '?' }}</td>
<td class="px-2 py-2 text-gray-400 text-xs">{{ s.environnement or '?' }}</td>
<td class="px-2 py-2 text-center"><span class="badge {% if s.zone == 'DMZ' %}badge-red{% else %}badge-blue{% endif %}">{{ s.zone or 'LAN' }}</span></td>
<td class="px-2 py-2 text-gray-400 text-xs">{{ s.tier }}</td>
<td class="px-2 py-2">
<form method="post" action="/quickwin/config/save" class="inline-form" style="display:flex;gap:4px;align-items:center">
<input type="hidden" name="server_id" value="{{ s.server_id }}">
<input type="text" name="general_excludes" value="{{ s.general_excludes }}"
style="width:200px;font-size:0.7rem;padding:2px 6px" title="{{ s.general_excludes }}">
</td>
<td class="px-2 py-2">
<input type="text" name="specific_excludes" value="{{ s.specific_excludes }}"
style="width:150px;font-size:0.7rem;padding:2px 6px" placeholder="sdcss* custom*...">
</td>
<td class="px-2 py-2">
<input type="text" name="notes" value="{{ s.notes }}"
style="width:80px;font-size:0.7rem;padding:2px 6px" placeholder="...">
<button type="submit" class="btn-sm" style="background:#1e3a5f;color:#00d4ff;font-size:0.65rem">OK</button>
</form>
</td>
<td class="px-2 py-2">
<button type="button" class="btn-sm" style="background:#1a3a1a;color:#00ff88;font-size:0.6rem;white-space:nowrap" onclick="showDryRun('{{ s.hostname }}', this)">Dry Run</button>
</td>
</tr>
{% endfor %}
{% if not all_servers %}<tr><td colspan="11" class="px-2 py-8 text-center text-gray-500">Aucun serveur trouv&eacute;</td></tr>{% endif %}
</tbody>
</table>
</div>
<!-- Aperçu de la commande QuickWin -->
<div class="card p-4 mt-4">
<h3 class="text-sm font-bold text-cyber-accent mb-2">Aperçu commande QuickWin générée</h3>
<pre class="text-xs font-mono text-cyber-green bg-cyber-bg p-3 rounded overflow-x-auto" style="white-space:pre-wrap">yum update -y \
{% for pkg in reboot_packages.split() %} --exclude={{ pkg }} \
{% endfor %} &lt;exclusions iTop du serveur&gt;</pre>
<p class="text-xs text-gray-500 mt-2">Les <code>&lt;exclusions iTop du serveur&gt;</code> sont ajoutées depuis le champ <code>patch_excludes</code> de chaque serveur, gérable via <a href="/patching/config-exclusions" class="text-cyber-accent hover:underline">Config exclusions</a>.</p>
</div>
<!-- Pagination -->
<div class="flex justify-between items-center mt-4 text-sm text-gray-500">
<span>Page {{ page }} / {{ total_pages }} &mdash; {{ total_count }} serveurs</span>
<div class="flex gap-2">
{% if page > 1 %}<a href="{{ qs(page - 1) }}" class="btn-sm bg-cyber-border text-gray-300">Pr&eacute;c&eacute;dent</a>{% endif %}
{% if page < total_pages %}<a href="{{ qs(page + 1) }}" class="btn-sm bg-cyber-border text-gray-300">Suivant</a>{% endif %}
</div>
</div>
<!-- Bulk modal -->
<div id="bulkModal" style="display:none;position:fixed;top:0;left:0;right:0;bottom:0;background:rgba(0,0,0,.7);z-index:100;align-items:center;justify-content:center" onclick="if(event.target===this)this.style.display='none'">
<div class="card" style="width:550px;max-width:90vw;padding:24px">
<h3 style="color:#00d4ff;font-weight:bold;margin-bottom:12px">Modification group&eacute;e</h3>
<p class="text-xs text-gray-500 mb-3">Cochez les serveurs dans le tableau, puis appliquez les exclusions.</p>
<form method="post" action="/quickwin/config/bulk-add">
<input type="hidden" name="server_ids" id="bulkIds">
<div class="mb-3">
<label class="text-xs text-gray-400 block mb-1">Exclusions g&eacute;n&eacute;rales</label>
<textarea name="general_excludes" rows="3" style="width:100%;font-size:0.75rem">{{ default_excludes }}</textarea>
</div>
<div class="flex gap-2 justify-end">
<button type="button" class="btn-sm" style="background:#333;color:#ccc;padding:6px 16px" onclick="document.getElementById('bulkModal').style.display='none'">Annuler</button>
<button type="submit" class="btn-primary" style="padding:6px 20px" onclick="collectIds()">Appliquer</button>
</div>
</form>
</div>
</div>
<!-- Dry Run modal -->
<div id="dryRunModal" style="display:none;position:fixed;top:0;left:0;right:0;bottom:0;background:rgba(0,0,0,.7);z-index:100;align-items:center;justify-content:center" onclick="if(event.target===this)this.style.display='none'">
<div class="card" style="width:700px;max-width:90vw;padding:24px">
<div class="flex items-center justify-between mb-3">
<h3 style="color:#00ff88;font-weight:bold" id="dryRunTitle">Dry Run</h3>
<button id="copyBtn" onclick="copyDryRun()" class="btn-sm" style="background:#1e3a5f;color:#00d4ff;font-size:0.75rem;padding:4px 12px">Copier</button>
</div>
<pre id="dryRunCmd" style="background:#0a0e17;border:1px solid #1e3a5f;border-radius:6px;padding:12px;font-size:0.75rem;color:#00ff88;white-space:pre-wrap;word-break:break-all;max-height:400px;overflow-y:auto"></pre>
<div class="flex justify-end mt-3">
<button type="button" class="btn-sm" style="background:#333;color:#ccc;padding:6px 16px" onclick="document.getElementById('dryRunModal').style.display='none'">Fermer</button>
</div>
</div>
</div>
<script>
function showDetail(hostname, td) {
const tr = td.closest('tr');
const ge = tr.querySelector('input[name=general_excludes]').value.trim();
const se = tr.querySelector('input[name=specific_excludes]').value.trim();
document.getElementById('detailName').textContent = hostname;
document.getElementById('detailGeneral').textContent = ge ? ge.split(/\s+/).join('\n') : '(aucune)';
document.getElementById('detailSpecific').textContent = se ? se.split(/\s+/).join('\n') : '(aucune)';
const panel = document.getElementById('srvDetail');
panel.style.display = 'block';
panel.scrollIntoView({behavior: 'smooth', block: 'nearest'});
}
function showDryRun(hostname, btn) {
const tr = btn.closest('tr');
const ge = tr.querySelector('input[name=general_excludes]').value.trim();
const se = tr.querySelector('input[name=specific_excludes]').value.trim();
const all = (ge + ' ' + se).trim().split(/\s+/).filter(x => x);
const excludes = all.map(e => '--exclude=' + e).join(' \\\n ');
const cmd = 'yum update -y \\\n ' + excludes;
document.getElementById('dryRunTitle').textContent = 'Dry Run — ' + hostname;
document.getElementById('dryRunCmd').textContent = cmd;
document.getElementById('dryRunModal').style.display = 'flex';
}
function copyDryRun() {
const text = document.getElementById('dryRunCmd').textContent;
navigator.clipboard.writeText(text).then(() => {
const btn = document.getElementById('copyBtn');
btn.textContent = 'Copi\u00e9 !';
setTimeout(() => btn.textContent = 'Copier', 1500);
});
}
function toggleAll(cb) {
document.querySelectorAll('.srv-check').forEach(c => c.checked = cb.checked);
}
function collectIds() {
const ids = Array.from(document.querySelectorAll('.srv-check:checked')).map(c => c.value);
document.getElementById('bulkIds').value = ids.join(',');
}
</script>
{% endblock %}

View File

@ -26,6 +26,7 @@
</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/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 ?')">
<button class="btn-sm btn-danger" style="padding:4px 12px">Supprimer</button>
@ -232,6 +233,33 @@
{% if run.status == 'patching' %}
{% if prod_ok and stats.prod_total > 0 %}
{# Alerte validations hors-prod non validées avant de patcher prod #}
{% if not validations_ok %}
<div class="card mb-4" style="border-left:3px solid #ff3366;padding:16px;background:#5a1a1a22">
<h3 style="color:#ff3366;font-weight:bold;margin-bottom:8px">⚠ Validations hors-prod requises avant la production</h3>
<p class="text-xs text-gray-300 mb-2">Les non-prod suivants ne sont pas validés. Marquer leur validation dans <a href="/patching/validations?status=en_attente" class="text-cyber-accent hover:underline">/patching/validations</a> avant de patcher les prods correspondants.</p>
<div class="text-xs" style="max-height:180px;overflow-y:auto">
<table class="w-full table-cyber">
<thead><tr><th class="text-left p-1">Prod</th><th class="text-left p-1">Hors-prod bloquant</th><th class="p-1">Statut</th></tr></thead>
<tbody>
{% for b in validations_blockers %}
<tr class="border-t border-cyber-border/30">
<td class="p-1 font-mono text-cyber-green">{{ b.prod_hostname }}</td>
<td class="p-1 font-mono text-cyber-yellow">{{ b.nonprod_hostname }}</td>
<td class="p-1 text-center">
{% if b.status == 'en_attente' %}<span class="badge badge-yellow">En attente</span>
{% elif b.status == 'validated_ko' %}<span class="badge badge-red">KO</span>
{% elif b.status == 'aucun_patching' %}<span class="badge badge-gray">Pas de patching</span>
{% else %}<span class="badge badge-gray">{{ b.status }}</span>{% endif %}
</td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
<p class="text-xs text-gray-500 mt-2">{{ validations_blockers|length }} bloquant(s) — vous pouvez continuer mais il est recommandé d'obtenir les validations d'abord.</p>
</div>
{% endif %}
<!-- Prereq + Snapshot Prod (si pas encore faits) -->
<div class="card mb-4" style="border-left:3px solid #ff8800;padding:16px">
<h3 style="color:#ff8800;font-weight:bold;margin-bottom:8px">Pr&eacute;paration Production</h3>

View File

@ -1,84 +0,0 @@
{% extends 'base.html' %}
{% block title %}Safe Patching{% endblock %}
{% block content %}
<div class="flex justify-between items-center mb-4">
<div>
<h2 class="text-xl font-bold text-cyber-accent">Safe Patching — Quick Win</h2>
<p class="text-xs text-gray-500 mt-1">Patching sans interruption de service : exclut tout ce qui nécessite un reboot ou un restart.</p>
</div>
<div class="flex gap-2">
{% if can_create %}
<button onclick="document.getElementById('create-form').style.display = document.getElementById('create-form').style.display === 'none' ? 'block' : 'none'" class="btn-primary px-4 py-2 text-sm">Nouvelle campagne</button>
{% endif %}
<a href="/planning" class="btn-sm bg-cyber-border text-cyber-accent px-4 py-2">Planning</a>
</div>
</div>
{% if msg %}
<div class="mb-3 p-2 rounded text-sm bg-red-900/30 text-cyber-red">
{% if msg == 'error' %}Erreur à la création (semaine déjà existante ?).{% elif msg == 'deleted' %}Campagne supprimée.{% endif %}
</div>
{% endif %}
<!-- Campagnes Quick Win existantes -->
{% if campaigns %}
<div class="space-y-2 mb-6">
{% for c in campaigns %}
<a href="/safe-patching/{{ c.id }}" class="card p-4 flex items-center justify-between hover:border-cyber-accent/50 block">
<div class="flex items-center gap-4">
<span class="font-bold text-cyber-accent">{{ c.week_code }}</span>
<span class="text-sm text-gray-400">{{ c.label }}</span>
<span class="badge badge-yellow">quickwin</span>
<span class="badge {% if c.status == 'draft' %}badge-gray{% elif c.status == 'in_progress' %}badge-yellow{% elif c.status == 'completed' %}badge-green{% else %}badge-red{% endif %}">{{ c.status }}</span>
</div>
<div class="flex gap-2 text-xs">
<span class="px-2 py-0.5 rounded bg-gray-800 text-gray-400">{{ c.session_count }} srv</span>
<span class="px-2 py-0.5 rounded bg-green-900/30 text-cyber-green">{{ c.patched_count }} ok</span>
</div>
</a>
{% endfor %}
</div>
{% endif %}
<!-- Formulaire création -->
{% if can_create %}
<div id="create-form" class="card p-5 mb-4" style="display:none">
<h3 class="text-sm font-bold text-cyber-accent mb-3">Nouvelle campagne Quick Win</h3>
<form method="POST" action="/safe-patching/create" class="space-y-3">
<div class="grid grid-cols-3 gap-3">
<div>
<label class="text-xs text-gray-500">Label</label>
<input type="text" name="label" id="qw-label" value="Quick Win S{{ '%02d' % current_week }} {{ current_year }}" class="w-full">
</div>
<div>
<label class="text-xs text-gray-500">Semaine</label>
<input type="number" name="week_number" id="qw-week" value="{{ current_week }}" min="1" max="53" class="w-full" required
onchange="document.getElementById('qw-label').value = 'Quick Win S' + String(this.value).padStart(2,'0') + ' ' + document.getElementById('qw-year').value">
</div>
<div>
<label class="text-xs text-gray-500">Année</label>
<input type="number" name="year" id="qw-year" value="{{ current_year }}" class="w-full" required
onchange="document.getElementById('qw-label').value = 'Quick Win S' + String(document.getElementById('qw-week').value).padStart(2,'0') + ' ' + this.value">
</div>
</div>
<div class="grid grid-cols-2 gap-3">
<div>
<label class="text-xs text-gray-500">Opérateur lead</label>
<select name="lead_id" class="w-full" required>
{% for o in operators %}<option value="{{ o.id }}">{{ o.display_name }}</option>{% endfor %}
</select>
</div>
<div>
<label class="text-xs text-gray-500">Assistant (optionnel)</label>
<select name="assistant_id" class="w-full">
<option value="">— Pas d'assistant —</option>
{% for o in operators %}<option value="{{ o.id }}">{{ o.display_name }}</option>{% endfor %}
</select>
</div>
</div>
<p class="text-xs text-gray-600">Tous les serveurs Linux en production (secops) seront inclus. Hors-prod patché en premier (J), prod le lendemain (J+1).</p>
<button type="submit" class="btn-primary px-6 py-2 text-sm">Créer la campagne Quick Win</button>
</form>
</div>
{% endif %}
{% endblock %}

View File

@ -1,215 +0,0 @@
{% extends 'base.html' %}
{% block title %}{{ c.label }}{% endblock %}
{% block content %}
<div class="flex justify-between items-center mb-4">
<div>
<a href="/safe-patching" class="text-xs text-gray-500 hover:text-gray-300">← Safe Patching</a>
<h2 class="text-xl font-bold text-cyber-accent">{{ c.label }}</h2>
<div class="flex items-center gap-3 mt-1">
<span class="badge badge-yellow">quickwin</span>
<span class="badge {% if c.status == 'draft' %}badge-gray{% elif c.status == 'in_progress' %}badge-yellow{% elif c.status == 'completed' %}badge-green{% else %}badge-red{% endif %}">{{ c.status }}</span>
</div>
</div>
{% set p = perms if perms is defined else request.state.perms %}
{% if p.campaigns == 'admin' %}
<form method="POST" action="/safe-patching/{{ c.id }}/delete">
<button class="btn-sm bg-red-900/50 text-cyber-red px-4 py-2" onclick="return confirm('SUPPRIMER définitivement cette campagne Quick Win ?')">Supprimer</button>
</form>
{% endif %}
</div>
{% if msg %}
<div class="mb-3 p-2 rounded text-sm {% if 'error' in msg or 'no_pending' in msg %}bg-red-900/30 text-cyber-red{% else %}bg-green-900/30 text-cyber-green{% endif %}">
{% if msg.startswith('excluded_') %}{{ msg.split('_')[1] }} serveur(s) exclu(s).{% elif msg == 'no_pending' %}Aucun serveur en attente.{% elif msg == 'prereqs_done' %}Prérequis vérifiés.{% endif %}
</div>
{% endif %}
<!-- KPIs par branche -->
<div class="grid grid-cols-2 gap-4 mb-4">
<div class="card p-4">
<h3 class="text-sm font-bold text-cyber-yellow mb-2">Branche 1 — Hors-prod</h3>
<div class="flex gap-3 text-sm">
<span class="text-cyber-accent">{{ qw_stats.hprod_total }} total</span>
<span class="text-cyber-green">{{ qw_stats.hprod_patched }} patchés</span>
<span class="text-cyber-red">{{ qw_stats.hprod_failed }} échoués</span>
</div>
</div>
<div class="card p-4">
<h3 class="text-sm font-bold text-cyber-green mb-2">Branche 2 — Production</h3>
<div class="flex gap-3 text-sm">
<span class="text-cyber-accent">{{ qw_stats.prod_total }} total</span>
<span class="text-cyber-green">{{ qw_stats.prod_patched }} patchés</span>
<span class="text-cyber-red">{{ qw_stats.prod_failed }} échoués</span>
</div>
</div>
</div>
<!-- Steps wizard -->
<div x-data="{ step: '{{ current_step }}' }" class="space-y-3">
<!-- Step nav -->
<div class="flex gap-1 mb-4">
{% for s in ['prereqs','snapshot','execute','postcheck'] %}
<button @click="step = '{{ s }}'" class="px-3 py-1 text-xs rounded"
:class="step === '{{ s }}' ? 'bg-cyber-accent text-black font-bold' : 'bg-cyber-border text-gray-400'">
{{ loop.index }}. {% if s == 'prereqs' %}Prérequis{% elif s == 'snapshot' %}Snapshot{% elif s == 'execute' %}Exécution{% elif s == 'postcheck' %}Post-patch{% endif %}
</button>
{% endfor %}
</div>
<!-- Step 1: Prérequis -->
<div x-show="step === 'prereqs'" class="card overflow-x-auto">
<div class="p-3 border-b border-cyber-border flex justify-between items-center">
<h3 class="text-sm font-bold text-cyber-accent">Step 1 — Vérification prérequis</h3>
<div class="flex gap-2">
<form method="POST" action="/safe-patching/{{ c.id }}/check-prereqs" style="display:inline">
<input type="hidden" name="branch" value="hprod">
<button class="btn-primary px-3 py-1 text-sm" data-loading="Vérification prérequis...|Connexion SSH à chaque serveur">Vérifier hors-prod</button>
</form>
<form method="POST" action="/safe-patching/{{ c.id }}/check-prereqs" style="display:inline">
<input type="hidden" name="branch" value="prod">
<button class="btn-sm bg-cyber-border text-cyber-accent" data-loading="Vérification prérequis...|Connexion SSH à chaque serveur">Vérifier prod</button>
</form>
</div>
</div>
<div id="excl-bar-prereq" class="p-2 border-b border-cyber-border flex gap-2 items-center" style="display:none">
<span class="text-xs text-gray-400" id="excl-count-prereq">0</span>
<form method="POST" action="/safe-patching/{{ c.id }}/bulk-exclude">
<input type="hidden" name="session_ids" id="excl-ids-prereq">
<button class="btn-sm bg-red-900/30 text-cyber-red" onclick="document.getElementById('excl-ids-prereq').value=getCheckedPrereq()">Exclure sélection</button>
</form>
</div>
<table class="w-full table-cyber text-xs">
<thead><tr>
<th class="p-2 w-6"><input type="checkbox" onchange="document.querySelectorAll('.chk-prereq').forEach(function(c){c.checked=this.checked}.bind(this)); updateExclPrereq()"></th>
<th class="text-left p-2">Hostname</th>
<th class="p-2">Env</th>
<th class="p-2">Domaine</th>
<th class="p-2">SSH</th>
<th class="p-2">Disque</th>
<th class="p-2">Satellite</th>
<th class="p-2">État</th>
</tr></thead>
<tbody>
{% for s in sessions %}
{% if s.status != 'excluded' %}
<tr class="{% if s.prereq_validated == false and s.prereq_date %}bg-red-900/10{% endif %}">
<td class="p-2 text-center">{% if s.status == 'pending' %}<input type="checkbox" class="chk-prereq" value="{{ s.id }}" onchange="updateExclPrereq()">{% endif %}</td>
<td class="p-2 font-mono text-cyber-accent">{{ s.hostname }}</td>
<td class="p-2 text-center"><span class="badge {% if s.environnement == 'Production' %}badge-green{% else %}badge-yellow{% endif %}"title="{{ s.environnement or '' }}">{{ (s.environnement or '')[:6] }}</span></td>
<td class="p-2 text-center text-xs">{{ s.domaine or '-' }}</td>
<td class="p-2 text-center">{% if s.prereq_ssh == 'ok' %}<span class="text-cyber-green">OK</span>{% elif s.prereq_ssh == 'ko' %}<span class="text-cyber-red">KO</span>{% else %}<span class="text-gray-600"></span>{% endif %}</td>
<td class="p-2 text-center">{% if s.prereq_disk_ok is true %}<span class="text-cyber-green">OK</span>{% elif s.prereq_disk_ok is false %}<span class="text-cyber-red">KO</span>{% else %}<span class="text-gray-600"></span>{% endif %}</td>
<td class="p-2 text-center">{% if s.prereq_satellite == 'ok' %}<span class="text-cyber-green">OK</span>{% elif s.prereq_satellite == 'ko' %}<span class="text-cyber-red">KO</span>{% elif s.prereq_satellite == 'na' %}<span class="text-gray-500">N/A</span>{% else %}<span class="text-gray-600"></span>{% endif %}</td>
<td class="p-2 text-center">{% if s.prereq_validated %}<span class="badge badge-green">OK</span>{% elif s.prereq_date %}<span class="badge badge-red">KO</span>{% else %}<span class="text-gray-600"></span>{% endif %}</td>
</tr>
{% endif %}
{% endfor %}
</tbody>
</table>
</div>
<!-- Step 2: Snapshot -->
<div x-show="step === 'snapshot'" class="card p-4">
<h3 class="text-sm font-bold text-cyber-accent mb-3">Step 2 — Snapshot vSphere</h3>
<p class="text-xs text-gray-500 mb-3">Créer un snapshot sur toutes les VMs avant patching. Les serveurs physiques sont ignorés.</p>
<form method="POST" action="/safe-patching/{{ c.id }}/snapshot">
<input type="hidden" name="branch" value="hprod">
<button class="btn-primary px-4 py-2 text-sm" data-loading="Création snapshots...|Connexion vSphere en cours">Créer snapshots hors-prod</button>
</form>
</div>
<!-- Step 3: Exécution -->
<div x-show="step === 'execute'" class="card p-4">
<h3 class="text-sm font-bold text-cyber-accent mb-3">Step 3 — Exécution Safe Patching</h3>
<div class="mb-4">
<h4 class="text-xs text-gray-500 mb-1">Commande yum (éditable)</h4>
<textarea id="yum-cmd" rows="3" class="w-full font-mono text-xs">{{ safe_cmd }}</textarea>
<p class="text-xs text-gray-600 mt-1">{{ safe_excludes|length }} packages exclus. Modifiez si besoin avant de lancer.</p>
</div>
<div class="flex gap-3">
<form method="POST" action="/safe-patching/{{ c.id }}/execute">
<input type="hidden" name="branch" value="hprod">
<button class="btn-primary px-4 py-2 text-sm" data-loading="Lancement hors-prod...|Sauvegarde état + patching">Lancer hors-prod</button>
</form>
{% if qw_stats.hprod_total > 0 and qw_stats.hprod_patched == qw_stats.hprod_total %}
<form method="POST" action="/safe-patching/{{ c.id }}/execute">
<input type="hidden" name="branch" value="prod">
<button class="btn-sm bg-cyber-green text-black px-4 py-2" data-loading="Lancement production...|Sauvegarde état + patching" onclick="return confirm('Lancer le patching PRODUCTION ?')">Lancer production</button>
</form>
{% else %}
<span class="text-xs text-gray-500 py-2">Production disponible après hors-prod à 100%</span>
{% endif %}
</div>
</div>
<!-- Step 4: Post-patching -->
<div x-show="step === 'postcheck'" class="card p-4">
<h3 class="text-sm font-bold text-cyber-accent mb-3">Step 4 — Vérification post-patch</h3>
<p class="text-xs text-gray-500 mb-3">Vérifier les services, ports et needs-restarting après patching.</p>
<div class="flex gap-3 mb-4">
<form method="POST" action="/safe-patching/{{ c.id }}/postcheck">
<input type="hidden" name="branch" value="hprod">
<button class="btn-primary px-3 py-1 text-sm" data-loading="Vérification post-patch...|Comparaison services avant/après">Vérifier hors-prod</button>
</form>
<form method="POST" action="/safe-patching/{{ c.id }}/postcheck">
<input type="hidden" name="branch" value="prod">
<button class="btn-sm bg-cyber-border text-cyber-accent" data-loading="Vérification post-patch...|Comparaison services avant/après">Vérifier prod</button>
</form>
<a href="/safe-patching/{{ c.id }}/export" class="btn-sm bg-cyber-green text-black">Export CSV</a>
</div>
<!-- Résultats -->
<table class="w-full table-cyber text-xs">
<thead><tr>
<th class="text-left p-2">Hostname</th>
<th class="p-2">Env</th>
<th class="p-2">Statut</th>
<th class="p-2">Packages</th>
<th class="p-2">Reboot</th>
<th class="p-2">Services</th>
</tr></thead>
<tbody>
{% for s in sessions %}
{% if s.status in ('patched', 'failed') %}
<tr class="{% if s.status == 'failed' %}bg-red-900/10{% endif %}">
<td class="p-2 font-mono text-cyber-accent">{{ s.hostname }}</td>
<td class="p-2 text-center"><span class="badge {% if s.environnement == 'Production' %}badge-green{% else %}badge-yellow{% endif %}"title="{{ s.environnement or '' }}">{{ (s.environnement or '')[:6] }}</span></td>
<td class="p-2 text-center"><span class="badge {% if s.status == 'patched' %}badge-green{% else %}badge-red{% endif %}">{{ s.status }}</span></td>
<td class="p-2 text-center text-gray-400">{{ s.packages_updated or 0 }}</td>
<td class="p-2 text-center">{% if s.reboot_required %}<span class="text-cyber-red">Oui</span>{% else %}<span class="text-cyber-green">Non</span>{% endif %}</td>
<td class="p-2 text-center">{% if s.postcheck_services == 'ok' %}<span class="text-cyber-green">OK</span>{% elif s.postcheck_services == 'ko' %}<span class="text-cyber-red">KO</span>{% else %}<span class="text-gray-600"></span>{% endif %}</td>
</tr>
{% endif %}
{% endfor %}
</tbody>
</table>
</div>
</div>
{% if excluded %}
<details class="card mt-4">
<summary class="p-3 cursor-pointer text-sm text-gray-500">{{ excluded|length }} serveur(s) exclu(s)</summary>
<div class="p-3 text-xs text-gray-600 font-mono">
{% for s in excluded %}{{ s.hostname }}{% if not loop.last %}, {% endif %}{% endfor %}
</div>
</details>
{% endif %}
<script>
function getCheckedPrereq() {
return Array.from(document.querySelectorAll('.chk-prereq:checked')).map(function(c){return c.value}).join(',');
}
function updateExclPrereq() {
var count = document.querySelectorAll('.chk-prereq:checked').length;
var bar = document.getElementById('excl-bar-prereq');
bar.style.display = count > 0 ? 'flex' : 'none';
document.getElementById('excl-count-prereq').textContent = count + ' sélectionné(s)';
}
</script>
{% endblock %}

View File

@ -1,79 +0,0 @@
{% extends 'base.html' %}
{% block title %}Terminal — {{ c.label }}{% endblock %}
{% block content %}
<div class="flex justify-between items-center mb-4">
<div>
<a href="/safe-patching/{{ c.id }}" class="text-xs text-gray-500 hover:text-gray-300">← Retour campagne</a>
<h2 class="text-xl font-bold text-cyber-accent">{{ c.label }} — Exécution {{ 'Hors-prod' if branch == 'hprod' else 'Production' }}</h2>
</div>
<div class="flex items-center gap-2">
<span id="status-badge" class="badge badge-yellow">En cours</span>
<span id="counter" class="text-xs text-gray-500">0 traité(s)</span>
</div>
</div>
<!-- Terminal -->
<div class="card" style="background:#0d1117; border-color:#1e3a5f">
<div class="p-2 border-b border-cyber-border flex items-center gap-2">
<span class="w-3 h-3 rounded-full bg-cyber-red"></span>
<span class="w-3 h-3 rounded-full bg-cyber-yellow"></span>
<span class="w-3 h-3 rounded-full bg-cyber-green"></span>
<span class="text-xs text-gray-500 ml-2">PatchCenter Terminal — Safe Patching</span>
</div>
<div id="terminal" class="p-4 font-mono text-xs overflow-y-auto" style="height:500px; line-height:1.6">
<div class="text-gray-500">Connexion au stream...</div>
</div>
</div>
<div class="flex gap-2 mt-4">
<a href="/safe-patching/{{ c.id }}" class="btn-primary px-4 py-2 text-sm" id="btn-back" style="display:none">Voir les résultats</a>
</div>
<script>
var terminal = document.getElementById('terminal');
var counter = 0;
var source = new EventSource('/safe-patching/{{ c.id }}/stream');
source.onmessage = function(e) {
var data = JSON.parse(e.data);
if (data.level === 'done') {
source.close();
document.getElementById('status-badge').textContent = 'Terminé';
document.getElementById('status-badge').className = 'badge badge-green';
document.getElementById('btn-back').style.display = '';
return;
}
var line = document.createElement('div');
var color = {
'header': 'color:#00d4ff; font-weight:bold',
'server': 'color:#00d4ff; font-weight:bold; margin-top:4px',
'step': 'color:#94a3b8',
'ok': 'color:#00ff88',
'error': 'color:#ff3366',
'success': 'color:#00ff88; font-weight:bold',
'info': 'color:#e2e8f0',
}[data.level] || 'color:#94a3b8';
if (data.msg === '') {
line.innerHTML = '&nbsp;';
} else {
line.innerHTML = '<span style="color:#4a5568">[' + data.ts + ']</span> <span style="' + color + '">' + data.msg + '</span>';
}
terminal.appendChild(line);
terminal.scrollTop = terminal.scrollHeight;
if (data.level === 'success') counter++;
document.getElementById('counter').textContent = counter + ' traité(s)';
};
source.onerror = function() {
var line = document.createElement('div');
line.innerHTML = '<span style="color:#ff3366">Connexion perdue. Rafraîchir la page.</span>';
terminal.appendChild(line);
source.close();
};
</script>
{% endblock %}

View File

@ -36,7 +36,7 @@
{% for t in ['tier0','tier1','tier2','tier3'] %}<option value="{{ t }}" {% if filters.tier == t %}selected{% endif %}>{{ t }}</option>{% endfor %}
</select>
<select name="etat" onchange="this.form.submit()"><option value="">Etat</option>
{% for e in ['en_production','en_implementation','en_decommissionnement','decommissionne','eteint','eol'] %}<option value="{{ e }}" {% if filters.etat == e %}selected{% endif %}>{{ e.replace("en_","En ").replace("_"," ").title() }}</option>{% endfor %}
{% for e,l in [('production','Production'),('implementation','Implementation'),('stock','Stock'),('obsolete','Obsolete')] %}<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>
@ -47,10 +47,14 @@
<option value="ipop" {% if filters.owner == 'ipop' %}selected{% endif %}>ipop</option>
<option value="na" {% if filters.owner == 'na' %}selected{% endif %}>na</option>
</select>
<select name="application" onchange="this.form.submit()" style="max-width:200px"><option value="">Solution app.</option>
{% for a in applications_list %}<option value="{{ a.application_name }}" {% if filters.application == a.application_name %}selected{% endif %}>{{ a.application_name }} ({{ a.c }})</option>{% endfor %}
</select>
<button type="submit" class="btn-primary px-3 py-1 text-sm">Filtrer</button>
<a href="/servers" class="text-xs text-gray-500 hover:text-gray-300">Reset</a>
</form>
{% if can_edit_servers %}
<!-- Actions groupées -->
<div id="bulk-bar" class="card p-3 mb-2 flex gap-3 items-center flex-wrap" style="display:none">
<span class="text-xs text-gray-400" id="bulk-count">0 sélectionné(s)</span>
@ -71,15 +75,16 @@
<button type="submit" class="btn-primary px-3 py-1 text-xs">Appliquer</button>
</form>
</div>
{% endif %}
<script>
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:"en_production",l:"En production"},{v:"en_implementation",l:"En implémentation"},{v:"en_decommissionnement",l:"En décommissionnement"},{v:"decommissionne",l:"Décommissionné"},{v:"eteint",l:"Éteint"},{v:"eol",l:"EOL"}],
etat: [{v:"production",l:"Production"},{v:"implementation",l:"Implementation"},{v:"stock",l:"Stock"},{v:"obsolete",l:"Obsolete"}],
patch_os_owner: [{v:"secops",l:"secops"},{v:"ipop",l:"ipop"},{v:"na",l:"na"}],
licence_support: [{v:"active",l:"active"},{v:"eol",l:"eol"},{v:"els",l:"els"}],
licence_support: [{v:"active",l:"active"},{v:"obsolete",l:"obsolete"},{v:"els",l:"els"}],
};
document.getElementById('bulk-field').addEventListener('change', function() {
const sel = document.getElementById('bulk-value');
@ -103,7 +108,7 @@ function updateBulk() {
<div id="server-table" class="card overflow-x-auto">
<table class="w-full table-cyber">
<thead><tr>
<th class="p-2 w-8"><input type="checkbox" id="check-all"></th>
{% if can_edit_servers %}<th class="p-2 w-8"><input type="checkbox" id="check-all"></th>{% endif %}
<th class="text-left p-2"><a href="{{ sort_url('hostname') }}" class="hover:text-cyber-accent">Hostname {{ sort_icon('hostname') }}</a></th>
<th class="p-2"><a href="{{ sort_url('domaine') }}" class="hover:text-cyber-accent">Domaine {{ sort_icon('domaine') }}</a></th>
<th class="p-2"><a href="{{ sort_url('env') }}" class="hover:text-cyber-accent">Env {{ sort_icon('env') }}</a></th>
@ -114,24 +119,43 @@ function updateBulk() {
<th class="p-2"><a href="{{ sort_url('tier') }}" class="hover:text-cyber-accent">Tier {{ sort_icon('tier') }}</a></th>
<th class="p-2"><a href="{{ sort_url('etat') }}" class="hover:text-cyber-accent">Etat {{ sort_icon('etat') }}</a></th>
<th class="p-2"><a href="{{ sort_url('owner') }}" class="hover:text-cyber-accent">Owner {{ sort_icon('owner') }}</a></th>
<th class="p-2 text-left">Solution applicative</th>
<th class="p-2 text-left">Équivalent(s)</th>
<th class="p-2">Actions</th>
</tr></thead>
<tbody>
{% for s in servers %}
<tr id="row-{{ s.id }}" class="group" hx-get="/servers/{{ s.id }}/detail" hx-target="#detail-panel" hx-swap="innerHTML" onclick="openPanel()">
<td class="p-2" onclick="event.stopPropagation()"><input type="checkbox" name="srv" value="{{ s.id }}" onchange="updateBulk()"></td>
{% if can_edit_servers %}<td class="p-2" onclick="event.stopPropagation()"><input type="checkbox" name="srv" value="{{ s.id }}" onchange="updateBulk()"></td>{% endif %}
<td class="p-2 font-mono text-sm text-cyber-accent">{{ s.hostname }}</td>
<td class="p-2 text-center text-xs">{{ s.domaine or '-' }}</td>
<td class="p-2 text-center"><span class="badge {% if s.environnement == 'Production' %}badge-green{% elif s.environnement == 'Recette' %}badge-yellow{% else %}badge-gray{% endif %}"title="{{ s.environnement or '' }}">{{ (s.environnement or '-')[:6] }}</span></td>
<td class="p-2 text-center"><span class="badge {% if s.zone == 'DMZ' %}badge-red{% elif s.zone == 'EMV' %}badge-yellow{% else %}badge-blue{% endif %}">{{ s.zone or 'LAN' }}</span></td>
<td class="p-2 text-center text-xs">{{ s.os_family or '-' }}</td>
<td class="p-2 text-center text-xs text-gray-400" title="{{ s.os_version or '' }}">{{ s.os_short or '-' }}</td>
<td class="p-2 text-center"><span class="badge {% if s.licence_support == 'active' %}badge-green{% elif s.licence_support == 'eol' %}badge-red{% elif s.licence_support == 'els' %}badge-yellow{% else %}badge-gray{% endif %}">{{ s.licence_support }}</span></td>
<td class="p-2 text-center"><span class="badge {% if s.licence_support == 'active' %}badge-green{% elif s.licence_support == 'obsolete' %}badge-red{% elif s.licence_support == 'els' %}badge-yellow{% else %}badge-gray{% endif %}">{{ s.licence_support }}</span></td>
<td class="p-2 text-center"><span class="badge {% if s.tier == 'tier0' %}badge-red{% elif s.tier == 'tier1' %}badge-yellow{% elif s.tier == 'tier2' %}badge-blue{% else %}badge-green{% endif %}">{{ s.tier }}</span></td>
<td class="p-2 text-center"><span class="badge {% if s.etat == 'en_production' %}badge-green{% elif s.etat == 'decommissionne' %}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 == 'obsolete' %}badge-red{% else %}badge-yellow{% endif %}"title="{{ s.etat or '' }}">{{ (s.etat or '')[:8] }}</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">
{% set link = links.get(s.id, {}) %}
{% if 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.as_nonprod %}
<span class="text-cyber-yellow" style="font-size:10px">→ prod :</span>
{% for l in link.as_nonprod %}<span class="font-mono text-gray-300">{{ l.hostname }}{% if not loop.last %}, {% endif %}</span>{% endfor %}
{% else %}
<span class="text-gray-600"></span>
{% endif %}
</td>
<td class="p-2 text-center" onclick="event.stopPropagation()">
{% if can_edit_servers %}
<button class="btn-sm bg-cyber-border text-cyber-accent" hx-get="/servers/{{ s.id }}/edit" hx-target="#detail-panel" hx-swap="innerHTML" onclick="openPanel()">Edit</button>
{% else %}
<span class="text-xs text-gray-600"></span>
{% endif %}
</td>
</tr>
{% endfor %}

View File

@ -463,5 +463,79 @@
</div>
{% endif %}
<!-- iTop Contacts (filtre des teams à synchroniser) -->
{% if visible.itop_contacts %}
<div class="card overflow-hidden">
<button @click="open = open === 'itop_contacts' ? '' : 'itop_contacts'" class="w-full flex items-center justify-between p-4 hover:bg-cyber-border/20 transition-colors">
<div class="flex items-center gap-3">
<span class="text-gray-400 font-bold">iTop Contacts — Périmètre</span>
<span class="badge badge-blue">Filtre des teams</span>
</div>
<span class="text-gray-500 text-lg" x-text="open === 'itop_contacts' ? '&#9660;' : '&#9654;'"></span>
</button>
<div x-show="open === 'itop_contacts'" class="border-t border-cyber-border p-4">
<form method="POST" action="/settings/itop_contacts" class="space-y-3">
{% for key, label, is_secret in sections.itop_contacts %}
<div>
<label class="text-xs text-gray-500">{{ label }}</label>
<input type="text" name="{{ key }}" value="{{ vals[key] }}" placeholder="SecOps, iPOP, Externe, DSI, Admin DSI" class="w-full font-mono text-xs" {% if not editable.itop_contacts %}disabled{% endif %}>
</div>
{% endfor %}
<div class="text-xs text-gray-600 mt-2">
Seuls les contacts appartenant à ces teams iTop seront synchronisés dans PatchCenter. Si vide, défaut : SecOps, iPOP, Externe, DSI, Admin DSI.
</div>
{% if editable.itop_contacts %}<button type="submit" class="btn-primary px-4 py-2 text-sm">Sauvegarder</button>{% endif %}
</form>
</div>
</div>
{% endif %}
<!-- LDAP/AD -->
{% if visible.ldap %}
<div class="card overflow-hidden">
<button @click="open = open === 'ldap' ? '' : 'ldap'" class="w-full flex items-center justify-between p-4 hover:bg-cyber-border/20 transition-colors">
<div class="flex items-center gap-3">
<span class="text-gray-400 font-bold">LDAP / Active Directory</span>
<span class="badge {% if vals.ldap_enabled == 'true' %}badge-green{% else %}badge-gray{% endif %}">{{ 'Activé' if vals.ldap_enabled == 'true' else 'Désactivé' }}</span>
<span class="text-xs text-gray-500">{{ vals.ldap_server or '' }}</span>
</div>
<span class="text-gray-500 text-lg" x-text="open === 'ldap' ? '&#9660;' : '&#9654;'"></span>
</button>
<div x-show="open === 'ldap'" class="border-t border-cyber-border p-4">
<form method="POST" action="/settings/ldap" class="space-y-3">
{% for key, label, is_secret in sections.ldap %}
<div>
<label class="text-xs text-gray-500">{{ label }}</label>
<input type="{{ 'password' if is_secret else 'text' }}" name="{{ key }}" value="{{ vals[key] }}" class="w-full" {% if not editable.ldap %}disabled{% endif %}>
</div>
{% endfor %}
<div class="text-xs text-gray-600 mt-2">
Une fois configuré et activé, le choix <b>Local / LDAP</b> apparaîtra sur la page de connexion. Les users peuvent aussi être forcés en LDAP via le champ "Auth" dans /users.
</div>
<div class="flex gap-2 items-center">
{% if editable.ldap %}<button type="submit" class="btn-primary px-4 py-2 text-sm">Sauvegarder</button>{% endif %}
<button type="button" onclick="testLdap()" class="btn-sm bg-cyber-border text-gray-300 px-4 py-2">Tester la connexion</button>
<span id="ldap-test-result" class="text-xs ml-2"></span>
</div>
</form>
</div>
</div>
{% endif %}
</div>
<script>
function testLdap() {
var out = document.getElementById('ldap-test-result');
out.textContent = 'Test en cours...';
out.className = 'text-xs ml-2 text-gray-400';
fetch('/settings/ldap/test', {method: 'POST', credentials: 'same-origin'})
.then(function(r){ return r.json(); })
.then(function(d){
if (d.ok) { out.textContent = '✓ ' + (d.msg || 'OK'); out.className = 'text-xs ml-2 text-cyber-green'; }
else { out.textContent = '✗ ' + (d.msg || 'Erreur'); out.className = 'text-xs ml-2 text-cyber-red'; }
})
.catch(function(e){ out.textContent = '✗ ' + e.message; out.className = 'text-xs ml-2 text-cyber-red'; });
}
</script>
{% endblock %}

View File

@ -1,136 +1,133 @@
{% extends 'base.html' %}
{% block title %}Utilisateurs{% endblock %}
{% block content %}
<h2 class="text-xl font-bold text-cyber-accent mb-6">Utilisateurs & Permissions</h2>
<div class="flex justify-between items-center mb-4">
<div>
<h2 class="text-xl font-bold text-cyber-accent">Utilisateurs</h2>
<p class="text-xs text-gray-500 mt-1">Gestion des comptes et profils — les utilisateurs proviennent des contacts iTop synchronisés</p>
</div>
{% if can_edit_users %}
<a href="/users/add" class="btn-primary px-4 py-2 text-sm">+ Ajouter un utilisateur{% if available_count %} <span class="text-xs opacity-70">({{ available_count }} contact{{ 's' if available_count > 1 else '' }} disponible{{ 's' if available_count > 1 else '' }})</span>{% endif %}</a>
{% endif %}
</div>
{% if msg %}
<div class="mb-4 p-3 rounded text-sm {% if msg in ('forbidden','exists','exists_inactive','cant_self') %}bg-red-900/30 text-cyber-red{% else %}bg-green-900/30 text-cyber-green{% endif %}">
{% if msg == 'added' %}Utilisateur créé.{% elif msg == 'edited' %}Utilisateur modifié.{% elif msg == 'password_changed' %}Mot de passe modifié.{% elif msg == 'toggled' %}Statut modifié.{% elif msg == 'perms_saved' %}Permissions sauvegardées.{% elif msg == 'deleted' %}Utilisateur supprimé.{% elif msg == 'exists' %}Ce nom d'utilisateur existe déjà.{% elif msg == 'exists_inactive' %}Ce nom existe déjà (désactivé). Réactivez-le plutôt.{% elif msg == 'cant_self' %}Vous ne pouvez pas vous désactiver/supprimer vous-même.{% elif msg == 'forbidden' %}Action non autorisée.{% endif %}
<div class="mb-3 p-2 rounded text-sm {% if 'forbidden' in msg or 'error' in msg or 'cant' in msg or 'invalid' in msg or 'required' in msg %}bg-red-900/30 text-cyber-red{% else %}bg-green-900/30 text-cyber-green{% endif %}">
{% if msg == 'added' %}Utilisateur ajouté.
{% elif msg == 'exists' %}Cet utilisateur existe déjà.
{% elif msg == 'role_changed' %}Profil modifié.
{% elif msg == 'toggled' %}Statut modifié.
{% elif msg == 'password_changed' %}Mot de passe modifié.
{% elif msg == 'auth_changed' %}Méthode d'auth modifiée.
{% elif msg == 'deleted' %}Utilisateur supprimé.
{% elif msg == 'cant_self' %}Impossible sur votre propre compte.
{% elif msg == 'cant_demote_self' %}Vous ne pouvez pas vous rétrograder.
{% elif msg == 'cant_delete_admin' %}Le compte admin local ne peut pas être supprimé.
{% elif msg == 'forbidden' %}Permission refusée.
{% elif msg == 'invalid_role' %}Profil invalide.
{% elif msg == 'contact_required' %}Sélectionner un contact.
{% else %}{{ msg }}{% endif %}
</div>
{% endif %}
<!-- Liste utilisateurs -->
<div x-data="{ editing: '', editUser: null }" class="space-y-3">
{% for ud in users_data %}
<div class="card overflow-hidden">
<div class="flex items-center justify-between p-4 cursor-pointer hover:bg-cyber-border/20" @click="editing = editing === '{{ ud.user.id }}' ? '' : '{{ ud.user.id }}'">
<div class="flex items-center gap-3">
<span class="font-bold {% if ud.user.is_active %}text-cyber-accent{% else %}text-gray-600 line-through{% endif %}">{{ ud.user.username }}</span>
<span class="text-sm text-gray-400">{{ ud.user.display_name }}</span>
<span class="badge {% if ud.user.role == 'admin' %}badge-red{% elif ud.user.role == 'coordinator' %}badge-yellow{% elif ud.user.role == 'operator' %}badge-blue{% else %}badge-gray{% endif %}">{% if ud.user.role == "operator" %}intervenant{% else %}{{ ud.user.role }}{% endif %}</span>
<span class="badge {% if ud.user.is_active %}badge-green{% else %}badge-red{% endif %}">{{ 'Actif' if ud.user.is_active else 'Inactif' }}</span>
{% if ud.user.email %}<span class="text-xs text-gray-500">{{ ud.user.email }}</span>{% endif %}
</div>
<div class="flex items-center gap-2">
{% for m in modules %}
{% if ud.perms.get(m) %}
<span class="text-xs px-1 rounded {% if ud.perms[m] == 'admin' %}bg-red-900/30 text-cyber-red{% elif ud.perms[m] == 'edit' %}bg-blue-900/30 text-cyber-accent{% else %}bg-gray-800 text-gray-500{% endif %}" title="{{ m }}:{{ ud.perms[m] }}">{{ m[:3] }}</span>
{% endif %}
{% endfor %}
<span class="text-gray-500 text-lg" x-text="editing === '{{ ud.user.id }}' ? '&#9660;' : '&#9654;'"></span>
</div>
<!-- Legende des profils -->
<div class="card p-4 mb-4">
<h3 class="text-sm font-bold text-cyber-accent mb-2">Profils</h3>
<div class="grid grid-cols-1 md:grid-cols-4 gap-3 text-xs">
{% for r in roles %}
<div>
<div class="font-bold text-cyber-accent">{{ profile_labels[r] }}</div>
<div class="text-gray-400 mt-1">{{ profile_descriptions[r] }}</div>
</div>
{% endfor %}
</div>
</div>
<div x-show="editing === '{{ ud.user.id }}'" class="border-t border-cyber-border p-4 space-y-4">
{% if can_edit_users %}
<!-- Éditer infos user -->
<form method="POST" action="/users/{{ ud.user.id }}/edit" class="flex gap-3 items-end">
<div>
<label class="text-xs text-gray-500">Nom complet</label>
<input type="text" name="display_name" value="{{ ud.user.display_name }}" class="text-xs py-1 px-2 w-40">
</div>
<div>
<label class="text-xs text-gray-500">Email</label>
<input type="email" name="email" value="{{ ud.user.email or '' }}" class="text-xs py-1 px-2 w-44">
</div>
<div>
<label class="text-xs text-gray-500">Role</label>
<select name="role" class="text-xs py-1 px-2">
{% for r in ['admin','coordinator','operator','viewer'] %}
<option value="{{ r }}" {% if r == ud.user.role %}selected{% endif %}>{% if r == "operator" %}intervenant{% else %}{{ r }}{% endif %}</option>
<!-- Tableau users -->
<div class="card overflow-hidden">
<table class="w-full table-cyber text-xs">
<thead><tr>
<th class="p-2 text-left">Utilisateur</th>
<th class="p-2">Profil</th>
<th class="p-2">Auth</th>
<th class="p-2">Team iTop</th>
<th class="p-2">Email</th>
<th class="p-2">Statut</th>
<th class="p-2">Dernier login</th>
<th class="p-2">Actions</th>
</tr></thead>
<tbody>
{% for u in users %}
<tr class="border-t border-cyber-border/30 {% if not u.is_active %}opacity-50{% endif %}">
<td class="p-2">
<div class="font-mono font-bold">{{ u.username }}</div>
<div class="text-gray-400">{{ u.display_name or '' }}</div>
{% if u.force_password_change %}<div class="text-cyber-yellow" style="font-size:9px">Doit changer mdp</div>{% endif %}
</td>
<td class="p-2 text-center">
{% if can_edit_users %}
<form method="POST" action="/users/{{ u.id }}/role" style="display:inline">
<select name="role" onchange="this.form.submit()" class="text-xs py-1 px-2">
{% for r in roles %}
<option value="{{ r }}" {% if u.role == r %}selected{% endif %}>{{ profile_labels[r] }}</option>
{% endfor %}
</select>
</div>
<button type="submit" class="btn-sm bg-cyber-accent text-black">Modifier</button>
</form>
<!-- Permissions par module -->
<form method="POST" action="/users/{{ ud.user.id }}/permissions">
<h4 class="text-xs text-cyber-accent font-bold uppercase mb-2">Permissions par module</h4>
<div class="grid grid-cols-8 gap-2">
{% for m in modules %}
<div>
<label class="text-xs text-gray-500 block mb-1">{{ m }}</label>
<select name="perm_{{ m }}" class="w-full text-xs py-1">
<option value=""></option>
{% for l in levels %}
<option value="{{ l }}" {% if ud.perms.get(m) == l %}selected{% endif %}>{{ l }}</option>
{% endfor %}
</select>
</div>
{% endfor %}
</div>
<button type="submit" class="btn-primary px-4 py-1 text-sm mt-2">Sauvegarder permissions</button>
</form>
<!-- Actions -->
<div class="flex gap-3 pt-2 border-t border-cyber-border items-center">
<form method="POST" action="/users/{{ ud.user.id }}/password" class="flex gap-2 items-center">
<input type="password" name="new_password" placeholder="Nouveau mot de passe" class="text-xs py-1 px-2 w-48">
<button type="submit" class="btn-sm bg-cyber-border text-cyber-accent">Changer MDP</button>
</form>
<form method="POST" action="/users/{{ ud.user.id }}/toggle">
<button type="submit" class="btn-sm {% if ud.user.is_active %}bg-red-900/30 text-cyber-red{% else %}bg-green-900/30 text-cyber-green{% endif %}">
{{ 'Désactiver' if ud.user.is_active else 'Activer' }}
</button>
{% else %}
<span class="badge badge-blue">{{ profile_labels[u.role] or u.role }}</span>
{% endif %}
</td>
<td class="p-2 text-center">
{% if can_edit_users %}
<form method="POST" action="/users/{{ u.id }}/auth_type" style="display:inline">
<select name="auth_type" onchange="this.form.submit()" class="text-xs py-1 px-2">
<option value="local" {% if u.auth_type == 'local' %}selected{% endif %}>Local</option>
<option value="ldap" {% if u.auth_type == 'ldap' %}selected{% endif %}>LDAP</option>
</select>
</form>
<form method="POST" action="/users/{{ ud.user.id }}/delete">
<button type="submit" class="btn-sm bg-red-900/50 text-cyber-red" onclick="return confirm('SUPPRIMER définitivement {{ ud.user.username }} ?')">Supprimer</button>
{% else %}
<span class="badge {% if u.auth_type == 'ldap' %}badge-blue{% else %}badge-gray{% endif %}">{{ u.auth_type }}</span>
{% endif %}
</td>
<td class="p-2 text-center text-gray-400">
{% if u.contact_team %}<span class="badge badge-gray">{{ u.contact_team }}</span>{% else %}<span class="text-gray-600"></span>{% endif %}
</td>
<td class="p-2 text-gray-400">{{ u.email or '—' }}</td>
<td class="p-2 text-center">
<span class="badge {% if u.is_active %}badge-green{% else %}badge-red{% endif %}">{{ 'Actif' if u.is_active else 'Inactif' }}</span>
</td>
<td class="p-2 text-center text-gray-400" style="font-size:10px">{% if u.last_login %}{{ u.last_login.strftime('%Y-%m-%d %H:%M') }}{% else %}—{% endif %}</td>
<td class="p-2 text-center">
{% if can_edit_users %}
<form method="POST" action="/users/{{ u.id }}/toggle" style="display:inline">
<button type="submit" class="text-xs {% if u.is_active %}text-cyber-yellow{% else %}text-cyber-green{% endif %} hover:underline">{{ 'Désactiver' if u.is_active else 'Activer' }}</button>
</form>
</div>
{% else %}
<p class="text-xs text-gray-500">Permissions en lecture seule</p>
{% endif %}
</div>
</div>
{% endfor %}
<button onclick="document.getElementById('pw-{{ u.id }}').style.display='table-row'" class="text-xs text-gray-400 hover:text-cyber-accent ml-2">Mdp</button>
{% if can_admin_users and u.username != 'admin' %}
<form method="POST" action="/users/{{ u.id }}/delete" style="display:inline" onsubmit="return confirm('Supprimer {{ u.username }} ?')">
<button type="submit" class="text-xs text-cyber-red hover:underline ml-2">Supprimer</button>
</form>
{% endif %}
{% endif %}
</td>
</tr>
{% if can_edit_users %}
<tr id="pw-{{ u.id }}" style="display:none" class="bg-cyber-border/20">
<td colspan="8" class="p-3">
<form method="POST" action="/users/{{ u.id }}/password" class="flex gap-2 items-center">
<label class="text-xs text-gray-400">Nouveau mot de passe pour <b>{{ u.username }}</b> :</label>
<input type="password" name="new_password" required minlength="6" class="text-xs py-1 px-2 flex-1">
<label class="text-xs text-gray-400 flex items-center gap-1">
<input type="checkbox" name="force_change" checked> Doit changer au 1er login
</label>
<button type="submit" class="btn-primary px-3 py-1 text-xs">Enregistrer</button>
<button type="button" onclick="document.getElementById('pw-{{ u.id }}').style.display='none'" class="text-xs text-gray-500">Annuler</button>
</form>
</td>
</tr>
{% endif %}
{% endfor %}
</tbody>
</table>
</div>
<!-- Ajouter un utilisateur -->
{% if can_edit_users %}
<div class="card p-5 mt-6">
<h3 class="text-sm font-bold text-cyber-accent mb-3">Ajouter un utilisateur</h3>
<form method="POST" action="/users/add" class="space-y-3">
<div class="grid grid-cols-4 gap-3">
<div>
<label class="text-xs text-gray-500">Username</label>
<input type="text" name="new_username" required class="w-full">
</div>
<div>
<label class="text-xs text-gray-500">Nom complet</label>
<input type="text" name="new_display_name" required class="w-full">
</div>
<div>
<label class="text-xs text-gray-500">Email</label>
<input type="email" name="new_email" class="w-full">
</div>
<div>
<label class="text-xs text-gray-500">Role</label>
<select name="new_role" class="w-full">
<option value="operator">intervenant</option>
<option value="coordinator">coordinator</option>
<option value="admin">admin</option>
<option value="viewer">viewer</option>
</select>
</div>
</div>
<div class="w-64">
<label class="text-xs text-gray-500">Mot de passe</label>
<input type="password" name="new_password" required class="w-full">
</div>
<p class="text-xs text-gray-600">Permissions pre-remplies selon le role. Modifiables ensuite.</p>
<button type="submit" class="btn-primary px-4 py-2 text-sm">Créer</button>
</form>
</div>
{% endif %}
{% endblock %}

View File

@ -0,0 +1,122 @@
{% extends 'base.html' %}
{% block title %}Ajouter un utilisateur{% endblock %}
{% block content %}
<div class="flex justify-between items-center mb-4">
<div>
<a href="/users" class="text-xs text-gray-500 hover:text-gray-300">&larr; Retour utilisateurs</a>
<h2 class="text-xl font-bold text-cyber-accent">Ajouter un utilisateur</h2>
<p class="text-xs text-gray-500 mt-1">Choisir un contact iTop puis définir son profil PatchCenter</p>
</div>
</div>
<!-- Info -->
<div class="card p-3 mb-4 text-xs text-gray-400" style="background:#111827">
<b class="text-cyber-accent">Périmètre :</b> seuls les contacts synchronisés depuis iTop (Teams SecOps, iPOP, Externe, DSI, Admin DSI) apparaissent ici.
Si la personne n'est pas dans la liste, elle doit d'abord être créée dans iTop par un admin DSI.
</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="{{ search }}" placeholder="Rechercher nom ou email..." class="text-xs py-1 px-2" style="width:250px">
<select name="team" class="text-xs py-1 px-2" style="width:150px">
<option value="">Toutes teams</option>
{% for t in teams %}
<option value="{{ t.team }}" {% if team_filter == t.team %}selected{% endif %}>{{ t.team }}</option>
{% endfor %}
</select>
<button type="submit" class="btn-primary px-3 py-1 text-xs">Filtrer</button>
<a href="/users/add" class="text-xs text-gray-400 hover:text-cyber-accent">Reset</a>
<span class="text-xs text-gray-500 ml-auto">{{ contacts|length }} contact{{ 's' if contacts|length > 1 else '' }}</span>
</form>
</div>
{% if not contacts %}
<div class="card p-6 text-center text-gray-500">
<p>Aucun contact disponible pour créer un utilisateur.</p>
<p class="text-xs mt-2">Lancer une synchro depuis iTop dans <a href="/referentiel" class="text-cyber-accent">Référentiel</a>.</p>
</div>
{% else %}
<!-- Tableau contacts -->
<form method="POST" action="/users/add">
<div class="card overflow-hidden mb-4">
<table class="w-full table-cyber text-xs">
<thead><tr>
<th class="p-2 w-8"></th>
<th class="p-2 text-left">Nom</th>
<th class="p-2 text-left">Email</th>
<th class="p-2">Team iTop</th>
<th class="p-2 text-left">Fonction</th>
<th class="p-2 text-left">Téléphone</th>
</tr></thead>
<tbody>
{% for c in contacts %}
<tr class="border-t border-cyber-border/30 hover:bg-cyber-hover cursor-pointer" onclick="selectContact({{ c.id }}, '{{ c.name|e }}', '{{ c.team|default('') }}')">
<td class="p-2 text-center">
<input type="radio" name="contact_id" value="{{ c.id }}" id="c{{ c.id }}">
</td>
<td class="p-2"><label for="c{{ c.id }}" class="cursor-pointer font-mono">{{ c.name }}</label></td>
<td class="p-2 text-gray-400">{{ c.email }}</td>
<td class="p-2 text-center">{% if c.team %}<span class="badge badge-gray">{{ c.team }}</span>{% else %}—{% endif %}</td>
<td class="p-2 text-gray-400">{{ c.function or '' }}</td>
<td class="p-2 text-gray-400">{{ c.telephone or '' }}</td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
<!-- Formulaire de création -->
<div class="card p-4">
<h3 class="text-sm font-bold text-cyber-accent mb-3">Paramètres du compte <span id="selected-name" class="text-gray-400 font-normal ml-2"></span></h3>
<div class="grid grid-cols-1 md:grid-cols-2 gap-3">
<div>
<label class="text-xs text-gray-500 block mb-1">Profil (rôle PatchCenter)</label>
<select name="role" id="role-select" class="w-full text-sm">
{% for r in roles %}
<option value="{{ r }}">{{ profile_labels[r] }} — {{ profile_descriptions[r][:60] }}...</option>
{% endfor %}
</select>
</div>
<div>
<label class="text-xs text-gray-500 block mb-1">Type d'authentification</label>
<select name="auth_type" class="w-full text-sm">
<option value="local">Local (mot de passe stocké)</option>
<option value="ldap">LDAP/AD</option>
</select>
</div>
<div>
<label class="text-xs text-gray-500 block mb-1">Nom d'utilisateur (optionnel, dérivé de l'email sinon)</label>
<input type="text" name="username" class="w-full text-sm" placeholder="ex: jean.dupont">
</div>
<div>
<label class="text-xs text-gray-500 block mb-1">Mot de passe initial (optionnel)</label>
<input type="password" name="password" class="w-full text-sm" placeholder="Laisser vide pour générer">
</div>
</div>
<div class="mt-3">
<label class="text-xs text-gray-400 flex items-center gap-2">
<input type="checkbox" name="force_change" checked> Forcer le changement de mot de passe au 1er login
</label>
</div>
<div class="mt-4 flex gap-2">
<button type="submit" class="btn-primary px-4 py-2 text-sm" id="submit-btn" disabled>Créer l'utilisateur</button>
<a href="/users" class="text-xs text-gray-500 hover:text-cyber-accent self-center ml-2">Annuler</a>
</div>
</div>
</form>
{% endif %}
<script>
function selectContact(id, name, team) {
document.getElementById('c' + id).checked = true;
document.getElementById('selected-name').textContent = '— ' + name + (team ? ' (' + team + ')' : '');
document.getElementById('submit-btn').disabled = false;
// Auto-sélection du profil suggéré selon team
var roleSel = document.getElementById('role-select');
if (team === 'SecOps') roleSel.value = 'operator';
else if (team === 'iPOP') roleSel.value = 'coordinator';
else if (team === 'Externe') roleSel.value = 'viewer';
}
</script>
{% endblock %}

15
migrate_applications.sql Normal file
View File

@ -0,0 +1,15 @@
-- Ajoute itop_id à applications
ALTER TABLE applications ADD COLUMN IF NOT EXISTS itop_id INTEGER;
CREATE UNIQUE INDEX IF NOT EXISTS applications_itop_id_unique ON applications (itop_id) WHERE itop_id IS NOT NULL;
-- Ajoute criticite status si besoin
ALTER TABLE applications ADD COLUMN IF NOT EXISTS status VARCHAR(30);
-- Vide les données applicatives actuelles côté serveurs
UPDATE servers SET application_id = NULL, application_name = NULL WHERE application_id IS NOT NULL OR application_name IS NOT NULL;
-- Vide la table applications (on repart de zéro depuis iTop)
DELETE FROM applications;
SELECT 'applications vidée' as msg;
SELECT COUNT(*) as servers_cleared FROM servers WHERE application_id IS NULL AND application_name IS NULL;

View File

@ -0,0 +1,41 @@
-- Table de correspondance prod ↔ hors-prod
CREATE TABLE IF NOT EXISTS server_correspondance (
id SERIAL PRIMARY KEY,
prod_server_id INTEGER REFERENCES servers(id) ON DELETE CASCADE,
nonprod_server_id INTEGER REFERENCES servers(id) ON DELETE CASCADE,
environment_code VARCHAR(50),
source VARCHAR(20) NOT NULL DEFAULT 'auto',
note TEXT,
created_by INTEGER REFERENCES users(id),
created_at TIMESTAMP DEFAULT NOW(),
updated_at TIMESTAMP DEFAULT NOW(),
CONSTRAINT server_correspondance_source_check CHECK (source IN ('auto','manual','exception')),
CONSTRAINT server_correspondance_uniq UNIQUE(prod_server_id, nonprod_server_id)
);
CREATE INDEX IF NOT EXISTS idx_corr_prod ON server_correspondance(prod_server_id);
CREATE INDEX IF NOT EXISTS idx_corr_nonprod ON server_correspondance(nonprod_server_id);
-- Table des validations post-patching
CREATE TABLE IF NOT EXISTS patch_validation (
id SERIAL PRIMARY KEY,
server_id INTEGER REFERENCES servers(id) ON DELETE CASCADE,
campaign_id INTEGER,
campaign_type VARCHAR(30),
patch_date TIMESTAMP DEFAULT NOW(),
status VARCHAR(20) NOT NULL DEFAULT 'en_attente',
validated_by_contact_id INTEGER REFERENCES contacts(id),
validated_by_name VARCHAR(200),
validated_at TIMESTAMP,
marked_by_user_id INTEGER REFERENCES users(id),
forced_reason TEXT,
notes TEXT,
created_at TIMESTAMP DEFAULT NOW(),
updated_at TIMESTAMP DEFAULT NOW(),
CONSTRAINT patch_validation_status_check CHECK (status IN ('en_attente','validated_ok','validated_ko','forced'))
);
CREATE INDEX IF NOT EXISTS idx_pv_server ON patch_validation(server_id);
CREATE INDEX IF NOT EXISTS idx_pv_campaign ON patch_validation(campaign_id, campaign_type);
CREATE INDEX IF NOT EXISTS idx_pv_status ON patch_validation(status);
CREATE INDEX IF NOT EXISTS idx_pv_patch_date ON patch_validation(patch_date DESC);
SELECT 'schema créé' as msg;

7
migrate_etat.sql Normal file
View File

@ -0,0 +1,7 @@
ALTER TABLE servers DROP CONSTRAINT IF EXISTS servers_etat_check;
UPDATE servers SET etat = 'production' WHERE etat = 'en_production';
UPDATE servers SET etat = 'implementation' WHERE etat IN ('en_implementation', 'en_cours');
UPDATE servers SET etat = 'obsolete' WHERE etat IN ('decommissionne', 'en_decommissionnement', 'eteint', 'eol');
ALTER TABLE servers ADD CONSTRAINT servers_etat_check CHECK (etat IN ('production', 'implementation', 'stock', 'obsolete'));
ALTER TABLE servers ALTER COLUMN etat SET DEFAULT 'production';
SELECT etat, COUNT(*) FROM servers GROUP BY etat ORDER BY 2 DESC;

23
migrate_users.sql Normal file
View File

@ -0,0 +1,23 @@
-- Link users to iTop Person
ALTER TABLE users ADD COLUMN IF NOT EXISTS itop_person_id INTEGER;
ALTER TABLE users ADD COLUMN IF NOT EXISTS last_itop_sync TIMESTAMP;
ALTER TABLE users ADD COLUMN IF NOT EXISTS force_password_change BOOLEAN DEFAULT false;
-- source already implied by auth_type, no change
-- Link contacts to iTop Person
ALTER TABLE contacts ADD COLUMN IF NOT EXISTS itop_id INTEGER;
ALTER TABLE contacts ADD COLUMN IF NOT EXISTS telephone VARCHAR(50);
ALTER TABLE contacts ADD COLUMN IF NOT EXISTS team VARCHAR(100);
ALTER TABLE contacts ADD COLUMN IF NOT EXISTS function VARCHAR(200);
-- Unique constraint on email for linking users
CREATE UNIQUE INDEX IF NOT EXISTS users_email_unique_active ON users (LOWER(email)) WHERE email IS NOT NULL AND email != '';
-- Match existing users to contacts by email
UPDATE users u SET itop_person_id = c.itop_id
FROM contacts c
WHERE u.email IS NOT NULL AND u.email != '' AND LOWER(u.email) = LOWER(c.email) AND c.itop_id IS NOT NULL;
SELECT COUNT(*) as users_linked FROM users WHERE itop_person_id IS NOT NULL;
SELECT COUNT(*) as users_total FROM users;

52
replace_etat.py Normal file
View File

@ -0,0 +1,52 @@
#!/usr/bin/env python3
"""Remplace les anciennes valeurs d'etat par les nouvelles (iTop) dans tous les fichiers Python et templates."""
import os
import re
# Ordre important: plus specifiques d'abord
REPLACEMENTS = [
("en_decommissionnement", "obsolete"),
("en_implementation", "implementation"),
("en_production", "production"),
("decommissionne", "obsolete"),
("en_cours", "implementation"), # prudent: seulement si etat context
("'eteint'", "'obsolete'"),
('"eteint"', '"obsolete"'),
("'eol'", "'obsolete'"),
('"eol"', '"obsolete"'),
]
# Ne PAS toucher:
# - itop_service.py (mappings deja corrects, on les laisse pour rétro-compat)
# - migrate_etat.sql (script de migration)
# - les tests
SKIP_FILES = {
"itop_service.py", # contient le mapping historique
}
ROOT = "app"
count = 0
for root, dirs, files in os.walk(ROOT):
for f in files:
if not f.endswith((".py", ".html")):
continue
if f in SKIP_FILES:
continue
path = os.path.join(root, f)
with open(path, encoding="utf-8") as fh:
content = fh.read()
orig = content
for old, new in REPLACEMENTS:
# en_cours est ambigu : on ne remplace que dans contexte etat
if old == "en_cours":
content = re.sub(r"(etat\s*[=:]\s*['\"])en_cours(['\"])", r"\1implementation\2", content)
content = re.sub(r"\betat\s*==\s*['\"]en_cours['\"]", "etat == 'implementation'", content)
continue
content = content.replace(old, new)
if content != orig:
with open(path, "w", encoding="utf-8") as fh:
fh.write(content)
print(f" modified: {path}")
count += 1
print(f"\nTotal: {count} files modified")