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>
This commit is contained in:
Pierre & Lumière 2026-04-12 18:51:17 +02:00
parent 3f47fea8e6
commit ba0bff0f6e
12 changed files with 0 additions and 2995 deletions

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

@ -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

@ -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

@ -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

@ -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

@ -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 %}