Compare commits
6 Commits
4fa5f67c32
...
caa2be71a4
| Author | SHA1 | Date | |
|---|---|---|---|
| caa2be71a4 | |||
| a706e240ca | |||
| ba0bff0f6e | |||
| 3f47fea8e6 | |||
| 5ea4100f4c | |||
| 8479d7280e |
@ -39,17 +39,30 @@ def get_current_user(request: Request):
|
||||
|
||||
|
||||
def get_user_perms(db, user):
|
||||
"""Charge les permissions depuis la base pour un user.
|
||||
"""Charge les permissions : profil (role) + overrides de user_permissions.
|
||||
Retourne un dict {module: level} ex: {'servers': 'admin', 'campaigns': 'edit'}"""
|
||||
if not user:
|
||||
return {}
|
||||
uid = user.get("uid")
|
||||
if not uid:
|
||||
return {}
|
||||
rows = db.execute(text(
|
||||
# Récupère le rôle
|
||||
row = db.execute(text("SELECT role FROM users WHERE id = :uid"), {"uid": uid}).fetchone()
|
||||
if not row:
|
||||
return {}
|
||||
# Base : permissions du profil
|
||||
from .services.profile_service import get_profile_perms
|
||||
perms = get_profile_perms(row.role)
|
||||
# Overrides éventuels depuis user_permissions (niveau plus élevé uniquement)
|
||||
rank = {"view": 1, "edit": 2, "admin": 3}
|
||||
overrides = db.execute(text(
|
||||
"SELECT module, level FROM user_permissions WHERE user_id = :uid"
|
||||
), {"uid": uid}).fetchall()
|
||||
return {r.module: r.level for r in rows}
|
||||
for r in overrides:
|
||||
cur = perms.get(r.module)
|
||||
if not cur or rank.get(r.level, 0) > rank.get(cur, 0):
|
||||
perms[r.module] = r.level
|
||||
return perms
|
||||
|
||||
|
||||
def can_view(perms, module):
|
||||
|
||||
30
app/main.py
30
app/main.py
@ -5,23 +5,42 @@ from fastapi.staticfiles import StaticFiles
|
||||
from starlette.middleware.base import BaseHTTPMiddleware
|
||||
from .config import APP_NAME, APP_VERSION
|
||||
from .dependencies import get_current_user, get_user_perms
|
||||
from .database import SessionLocal
|
||||
from .routers import auth, dashboard, servers, settings, users, campaigns, planning, specifics, audit, contacts, qualys, safe_patching, audit_full, quickwin, referentiel
|
||||
from .database import SessionLocal, SessionLocalDemo
|
||||
from .routers import auth, dashboard, servers, settings, users, campaigns, planning, specifics, audit, contacts, qualys, quickwin, referentiel, patching
|
||||
|
||||
|
||||
class PermissionsMiddleware(BaseHTTPMiddleware):
|
||||
"""Injecte user + perms dans request.state pour tous les templates"""
|
||||
"""Injecte user + perms dans request.state pour tous les templates.
|
||||
Gère aussi la redirection si force_password_change est activé."""
|
||||
async def dispatch(self, request: Request, call_next):
|
||||
user = get_current_user(request)
|
||||
perms = {}
|
||||
must_change_pwd = False
|
||||
if user:
|
||||
db = SessionLocal()
|
||||
# Sélectionner la base selon le mode JWT (prod/demo)
|
||||
factory = SessionLocalDemo if user.get("mode") == "demo" else SessionLocal
|
||||
db = factory()
|
||||
try:
|
||||
perms = get_user_perms(db, user)
|
||||
# Check force_password_change
|
||||
from sqlalchemy import text
|
||||
row = db.execute(text("SELECT force_password_change FROM users WHERE id=:uid"),
|
||||
{"uid": user.get("uid")}).fetchone()
|
||||
if row and row.force_password_change:
|
||||
must_change_pwd = True
|
||||
finally:
|
||||
db.close()
|
||||
request.state.user = user
|
||||
request.state.perms = perms
|
||||
request.state.must_change_pwd = must_change_pwd
|
||||
|
||||
# Redirect vers change-password si forcé (sauf pour les routes de changement/logout/static)
|
||||
if must_change_pwd and user:
|
||||
allowed = ("/me/change-password", "/logout", "/static/")
|
||||
if not any(request.url.path.startswith(p) for p in allowed):
|
||||
from fastapi.responses import RedirectResponse
|
||||
return RedirectResponse(url="/me/change-password", status_code=303)
|
||||
|
||||
response = await call_next(request)
|
||||
return response
|
||||
|
||||
@ -41,10 +60,9 @@ app.include_router(specifics.router)
|
||||
app.include_router(audit.router)
|
||||
app.include_router(contacts.router)
|
||||
app.include_router(qualys.router)
|
||||
app.include_router(safe_patching.router)
|
||||
app.include_router(audit_full.router)
|
||||
app.include_router(quickwin.router)
|
||||
app.include_router(referentiel.router)
|
||||
app.include_router(patching.router)
|
||||
|
||||
|
||||
@app.get("/")
|
||||
|
||||
@ -5,7 +5,7 @@ from fastapi.responses import HTMLResponse, RedirectResponse, StreamingResponse
|
||||
from fastapi.templating import Jinja2Templates
|
||||
from sqlalchemy import text
|
||||
from ..dependencies import get_db, get_current_user, get_user_perms, can_view, can_edit, can_admin, base_context
|
||||
from ..services.realtime_audit_service import audit_servers_list, save_audit_to_db
|
||||
from ..services.realtime_audit_service import audit_servers_list, save_audit_to_db, start_audit_job, get_audit_job
|
||||
from ..config import APP_NAME
|
||||
|
||||
router = APIRouter()
|
||||
@ -134,7 +134,7 @@ async def audit_global(request: Request, db=Depends(get_db)):
|
||||
parallel = int(form.get("parallel", "5"))
|
||||
|
||||
# Construire la requete
|
||||
where = ["s.os_family = 'linux'", "s.etat = 'en_production'"]
|
||||
where = ["s.os_family = 'linux'", "s.etat = 'production'"]
|
||||
params = {}
|
||||
if exclude_domains:
|
||||
where.append("d.code NOT IN :ed")
|
||||
@ -173,18 +173,9 @@ async def audit_global(request: Request, db=Depends(get_db)):
|
||||
if not hostnames:
|
||||
return RedirectResponse(url="/audit?msg=no_hosts", status_code=303)
|
||||
|
||||
# Lancer l'audit
|
||||
results = audit_servers_list(hostnames)
|
||||
request.app.state.last_audit_results = results
|
||||
|
||||
ctx = base_context(request, db, user)
|
||||
ctx.update({
|
||||
"app_name": APP_NAME, "results": results,
|
||||
"total": len(results),
|
||||
"ok": sum(1 for r in results if r.get("status") == "OK"),
|
||||
"failed": sum(1 for r in results if r.get("status") != "OK"),
|
||||
})
|
||||
return templates.TemplateResponse("audit_realtime_results.html", ctx)
|
||||
# Lancer en arrière-plan
|
||||
job_id = start_audit_job(hostnames)
|
||||
return RedirectResponse(url=f"/audit/realtime/progress/{job_id}", status_code=303)
|
||||
|
||||
|
||||
@router.post("/audit/realtime", response_class=HTMLResponse)
|
||||
@ -209,23 +200,57 @@ async def audit_realtime(request: Request, db=Depends(get_db),
|
||||
if not hostnames:
|
||||
return RedirectResponse(url="/audit?msg=no_hosts", status_code=303)
|
||||
|
||||
# Lancer l'audit
|
||||
results = audit_servers_list(hostnames)
|
||||
# Lancer en arrière-plan
|
||||
job_id = start_audit_job(hostnames)
|
||||
|
||||
# Stocker en session (request.state) pour export/save
|
||||
request.app.state.last_audit_results = results
|
||||
return RedirectResponse(url=f"/audit/realtime/progress/{job_id}", status_code=303)
|
||||
|
||||
|
||||
@router.get("/audit/realtime/progress/{job_id}", response_class=HTMLResponse)
|
||||
async def audit_realtime_progress(request: Request, job_id: str, db=Depends(get_db)):
|
||||
user = get_current_user(request)
|
||||
if not user:
|
||||
return RedirectResponse(url="/login")
|
||||
perms = get_user_perms(db, user)
|
||||
if not can_view(perms, "audit"):
|
||||
return RedirectResponse(url="/audit")
|
||||
|
||||
job = get_audit_job(job_id)
|
||||
if not job:
|
||||
return RedirectResponse(url="/audit?msg=job_not_found", status_code=303)
|
||||
|
||||
ctx = base_context(request, db, user)
|
||||
ctx.update({
|
||||
"app_name": APP_NAME, "results": results,
|
||||
"total": len(results),
|
||||
"ok": sum(1 for r in results if r.get("status") == "OK"),
|
||||
"failed": sum(1 for r in results if r.get("status") != "OK"),
|
||||
ctx.update({"app_name": APP_NAME, "job_id": job_id, "total": job["total"]})
|
||||
return templates.TemplateResponse("audit_realtime_progress.html", ctx)
|
||||
|
||||
|
||||
@router.get("/audit/realtime/status/{job_id}")
|
||||
async def audit_realtime_status(request: Request, job_id: str, db=Depends(get_db)):
|
||||
from fastapi.responses import JSONResponse
|
||||
user = get_current_user(request)
|
||||
if not user:
|
||||
return JSONResponse({"ok": False}, status_code=401)
|
||||
|
||||
job = get_audit_job(job_id)
|
||||
if not job:
|
||||
return JSONResponse({"ok": False, "msg": "Job introuvable"}, status_code=404)
|
||||
|
||||
import time
|
||||
elapsed = int(time.time() - job["started_at"])
|
||||
|
||||
# When finished, store results in app state for save/export
|
||||
if job["finished"]:
|
||||
request.app.state.last_audit_results = job["results"]
|
||||
|
||||
return JSONResponse({
|
||||
"ok": True,
|
||||
"job_id": job_id,
|
||||
"total": job["total"],
|
||||
"done": job["done"],
|
||||
"finished": job["finished"],
|
||||
"elapsed": elapsed,
|
||||
"servers": job["servers"],
|
||||
})
|
||||
response = templates.TemplateResponse("audit_realtime_results.html", ctx)
|
||||
response.headers["Cache-Control"] = "no-store, no-cache, must-revalidate, max-age=0"
|
||||
response.headers["Pragma"] = "no-cache"
|
||||
return response
|
||||
|
||||
|
||||
@router.post("/audit/realtime/save")
|
||||
|
||||
@ -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)
|
||||
@ -6,6 +6,7 @@ from ..dependencies import get_current_user
|
||||
from ..database import SessionLocal, SessionLocalDemo
|
||||
from ..auth import verify_password, create_access_token, hash_password
|
||||
from ..services.audit_service import log_login, log_logout, log_login_failed
|
||||
from ..services.ldap_service import is_enabled as ldap_enabled, authenticate as ldap_authenticate
|
||||
from ..config import APP_NAME, APP_VERSION
|
||||
|
||||
router = APIRouter()
|
||||
@ -13,41 +14,62 @@ templates = Jinja2Templates(directory="app/templates")
|
||||
|
||||
@router.get("/login", response_class=HTMLResponse)
|
||||
async def login_page(request: Request):
|
||||
db = SessionLocal()
|
||||
try:
|
||||
ldap_ok = ldap_enabled(db)
|
||||
finally:
|
||||
db.close()
|
||||
return templates.TemplateResponse("login.html", {
|
||||
"request": request, "app_name": APP_NAME, "version": APP_VERSION, "error": None
|
||||
"request": request, "app_name": APP_NAME, "version": APP_VERSION,
|
||||
"error": None, "ldap_enabled": ldap_ok,
|
||||
})
|
||||
|
||||
@router.post("/login")
|
||||
async def login(request: Request, username: str = Form(...), password: str = Form(...),
|
||||
mode: str = Form("reel")):
|
||||
mode: str = Form("reel"), auth_method: str = Form("local")):
|
||||
# Select DB based on mode
|
||||
factory = SessionLocalDemo if mode == "demo" else SessionLocal
|
||||
db = factory()
|
||||
try:
|
||||
row = db.execute(text("SELECT id, username, password_hash, role, is_active FROM users WHERE LOWER(username) = LOWER(:u)"),
|
||||
row = db.execute(text("SELECT id, username, password_hash, role, is_active, auth_type FROM users WHERE LOWER(username) = LOWER(:u)"),
|
||||
{"u": username}).fetchone()
|
||||
|
||||
ldap_is_on = ldap_enabled(db)
|
||||
err_template = lambda msg: templates.TemplateResponse("login.html", {
|
||||
"request": request, "app_name": APP_NAME, "version": APP_VERSION,
|
||||
"error": msg, "ldap_enabled": ldap_is_on
|
||||
})
|
||||
|
||||
if not row:
|
||||
log_login_failed(db, request, username)
|
||||
db.commit()
|
||||
return templates.TemplateResponse("login.html", {
|
||||
"request": request, "app_name": APP_NAME, "version": APP_VERSION, "error": "Utilisateur inconnu"
|
||||
})
|
||||
return err_template("Utilisateur inconnu")
|
||||
if not row.is_active:
|
||||
log_login_failed(db, request, username)
|
||||
db.commit()
|
||||
return templates.TemplateResponse("login.html", {
|
||||
"request": request, "app_name": APP_NAME, "version": APP_VERSION, "error": "Compte desactive"
|
||||
})
|
||||
try:
|
||||
ok = verify_password(password, row.password_hash)
|
||||
except Exception:
|
||||
ok = False
|
||||
if not ok:
|
||||
log_login_failed(db, request, username)
|
||||
db.commit()
|
||||
return templates.TemplateResponse("login.html", {
|
||||
"request": request, "app_name": APP_NAME, "version": APP_VERSION, "error": "Mot de passe incorrect"
|
||||
})
|
||||
return err_template("Compte desactive")
|
||||
|
||||
# Choix de la methode d'auth
|
||||
use_ldap = (auth_method == "ldap") or (row.auth_type == "ldap")
|
||||
if use_ldap and not ldap_is_on:
|
||||
return err_template("LDAP non active")
|
||||
|
||||
if use_ldap:
|
||||
result = ldap_authenticate(db, username, password)
|
||||
if not result.get("ok"):
|
||||
log_login_failed(db, request, username)
|
||||
db.commit()
|
||||
return err_template(result.get("msg") or "Authentification LDAP echouee")
|
||||
ok = True
|
||||
else:
|
||||
try:
|
||||
ok = verify_password(password, row.password_hash or "")
|
||||
except Exception:
|
||||
ok = False
|
||||
if not ok:
|
||||
log_login_failed(db, request, username)
|
||||
db.commit()
|
||||
return err_template("Mot de passe incorrect")
|
||||
# Include mode in JWT token
|
||||
token = create_access_token({"sub": row.username, "role": row.role, "uid": row.id, "mode": mode})
|
||||
user = {"sub": row.username, "role": row.role, "uid": row.id, "mode": mode}
|
||||
|
||||
@ -27,7 +27,7 @@ router = APIRouter()
|
||||
templates = Jinja2Templates(directory="app/templates")
|
||||
|
||||
EXCLUSION_REASONS = [
|
||||
("eol", "Fin de vie (EOL)"),
|
||||
("obsolete", "Fin de vie (EOL)"),
|
||||
("creneau_inadequat", "Creneau non adequat"),
|
||||
("intervention_non_secops", "Intervention non-SecOps prevue"),
|
||||
("report_cycle", "Report au cycle suivant"),
|
||||
|
||||
@ -107,6 +107,7 @@ async def contacts_page(request: Request, db=Depends(get_db),
|
||||
"SELECT DISTINCT app_type FROM server_specifics WHERE app_type IS NOT NULL ORDER BY app_type"
|
||||
)).fetchall()
|
||||
|
||||
perms = get_user_perms(db, user)
|
||||
ctx = base_context(request, db, user)
|
||||
ctx.update({
|
||||
"app_name": APP_NAME, "contacts": contacts,
|
||||
@ -115,6 +116,7 @@ async def contacts_page(request: Request, db=Depends(get_db),
|
||||
"domains": domains, "app_types": [r.app_type for r in app_types],
|
||||
"search": search, "role_filter": role, "server": server, "server_info": server_info,
|
||||
"msg": request.query_params.get("msg"),
|
||||
"can_edit_contacts": can_edit(perms, "servers") or can_edit(perms, "contacts"),
|
||||
})
|
||||
return templates.TemplateResponse("contacts.html", ctx)
|
||||
|
||||
|
||||
@ -18,21 +18,21 @@ async def dashboard(request: Request, db=Depends(get_db)):
|
||||
# Stats generales
|
||||
stats = {}
|
||||
stats["total_servers"] = db.execute(text("SELECT COUNT(*) FROM servers")).scalar()
|
||||
stats["patchable"] = db.execute(text("SELECT COUNT(*) FROM servers WHERE patch_os_owner='secops' AND etat='en_production'")).scalar()
|
||||
stats["patchable"] = db.execute(text("SELECT COUNT(*) FROM servers WHERE patch_os_owner='secops' AND etat='production'")).scalar()
|
||||
stats["linux"] = db.execute(text("SELECT COUNT(*) FROM servers WHERE os_family='linux'")).scalar()
|
||||
stats["windows"] = db.execute(text("SELECT COUNT(*) FROM servers WHERE os_family='windows'")).scalar()
|
||||
stats["decom"] = db.execute(text("SELECT COUNT(*) FROM servers WHERE etat='decommissionne'")).scalar()
|
||||
stats["eol"] = db.execute(text("SELECT COUNT(*) FROM servers WHERE licence_support='eol'")).scalar()
|
||||
stats["decom"] = db.execute(text("SELECT COUNT(*) FROM servers WHERE etat='obsolete'")).scalar()
|
||||
stats["obsolete"] = db.execute(text("SELECT COUNT(*) FROM servers WHERE licence_support='obsolete'")).scalar()
|
||||
stats["qualys_assets"] = db.execute(text("SELECT COUNT(*) FROM qualys_assets")).scalar()
|
||||
stats["qualys_tags"] = db.execute(text("SELECT COUNT(*) FROM qualys_tags")).scalar()
|
||||
stats["qualys_active"] = db.execute(text("SELECT COUNT(*) FROM qualys_assets WHERE agent_status ILIKE '%active%' AND agent_status NOT ILIKE '%inactive%'")).scalar()
|
||||
stats["qualys_inactive"] = db.execute(text("SELECT COUNT(*) FROM qualys_assets WHERE agent_status ILIKE '%inactive%'")).scalar()
|
||||
stats["qualys_no_agent"] = db.execute(text("SELECT COUNT(*) FROM servers WHERE etat='en_production' AND NOT EXISTS (SELECT 1 FROM qualys_assets qa WHERE LOWER(qa.hostname) = LOWER(servers.hostname))")).scalar()
|
||||
stats["qualys_no_agent"] = db.execute(text("SELECT COUNT(*) FROM servers WHERE etat='production' AND NOT EXISTS (SELECT 1 FROM qualys_assets qa WHERE LOWER(qa.hostname) = LOWER(servers.hostname))")).scalar()
|
||||
|
||||
# Par domaine
|
||||
domains = db.execute(text("""
|
||||
SELECT d.name, d.code, COUNT(s.id) as total,
|
||||
COUNT(*) FILTER (WHERE s.etat='en_production') as actifs,
|
||||
COUNT(*) FILTER (WHERE s.etat='production') as actifs,
|
||||
COUNT(*) FILTER (WHERE s.os_family='linux') as linux,
|
||||
COUNT(*) FILTER (WHERE s.os_family='windows') as windows
|
||||
FROM servers s
|
||||
|
||||
679
app/routers/patching.py
Normal file
679
app/routers/patching.py
Normal file
@ -0,0 +1,679 @@
|
||||
"""Router Patching — exclusions, correspondance prod↔hors-prod, validations."""
|
||||
from fastapi import APIRouter, Request, Depends, Query, Form
|
||||
from fastapi.responses import HTMLResponse, RedirectResponse, JSONResponse
|
||||
from fastapi.templating import Jinja2Templates
|
||||
from sqlalchemy import text
|
||||
from ..dependencies import get_db, get_current_user, get_user_perms, can_view, can_edit, base_context
|
||||
from ..services import correspondance_service as corr
|
||||
from ..config import APP_NAME
|
||||
|
||||
router = APIRouter()
|
||||
templates = Jinja2Templates(directory="app/templates")
|
||||
|
||||
|
||||
def _can_edit_excludes(perms):
|
||||
"""Peut éditer les exclusions : admin, coordinator, operator (pas viewer)."""
|
||||
return can_edit(perms, "servers") or can_edit(perms, "campaigns") or can_edit(perms, "quickwin")
|
||||
|
||||
|
||||
@router.get("/patching/config-exclusions", response_class=HTMLResponse)
|
||||
async def config_exclusions_page(request: Request, db=Depends(get_db),
|
||||
search: str = Query(""),
|
||||
domain: str = Query(""),
|
||||
env: str = Query(""),
|
||||
zone: str = Query(""),
|
||||
tier: str = Query(""),
|
||||
os: str = Query(""),
|
||||
application: str = Query(""),
|
||||
has_excludes: str = Query(""),
|
||||
page: int = Query(1),
|
||||
per_page: int = Query(30)):
|
||||
user = get_current_user(request)
|
||||
if not user:
|
||||
return RedirectResponse(url="/login")
|
||||
perms = get_user_perms(db, user)
|
||||
if not _can_edit_excludes(perms):
|
||||
return RedirectResponse(url="/dashboard")
|
||||
|
||||
# Requête principale
|
||||
where = ["1=1"]
|
||||
params = {}
|
||||
if search:
|
||||
where.append("s.hostname ILIKE :s"); params["s"] = f"%{search}%"
|
||||
if domain:
|
||||
where.append("d.code = :d"); params["d"] = domain
|
||||
if env:
|
||||
where.append("e.code = :e"); params["e"] = env
|
||||
if zone:
|
||||
where.append("z.name = :z"); params["z"] = zone
|
||||
if tier:
|
||||
where.append("s.tier = :t"); params["t"] = tier
|
||||
if os:
|
||||
where.append("s.os_family = :o"); params["o"] = os
|
||||
if application:
|
||||
where.append("s.application_name = :app"); params["app"] = application
|
||||
if has_excludes == "yes":
|
||||
where.append("s.patch_excludes IS NOT NULL AND s.patch_excludes != ''")
|
||||
elif has_excludes == "no":
|
||||
where.append("(s.patch_excludes IS NULL OR s.patch_excludes = '')")
|
||||
|
||||
wc = " AND ".join(where)
|
||||
|
||||
# Count
|
||||
total = db.execute(text(f"""
|
||||
SELECT COUNT(*) FROM servers s
|
||||
LEFT JOIN domain_environments de ON s.domain_env_id = de.id
|
||||
LEFT JOIN domains d ON de.domain_id = d.id
|
||||
LEFT JOIN environments e ON de.environment_id = e.id
|
||||
LEFT JOIN zones z ON s.zone_id = z.id
|
||||
WHERE {wc}
|
||||
"""), params).scalar() or 0
|
||||
|
||||
per_page = max(10, min(per_page, 200))
|
||||
total_pages = max(1, (total + per_page - 1) // per_page)
|
||||
page = max(1, min(page, total_pages))
|
||||
offset = (page - 1) * per_page
|
||||
|
||||
rows = db.execute(text(f"""
|
||||
SELECT s.id, s.hostname, s.os_family, s.os_version, s.tier, s.etat,
|
||||
s.patch_excludes, s.application_name,
|
||||
d.name as domain_name, d.code as domain_code,
|
||||
e.name as env_name, e.code as env_code,
|
||||
z.name as zone_name
|
||||
FROM servers s
|
||||
LEFT JOIN domain_environments de ON s.domain_env_id = de.id
|
||||
LEFT JOIN domains d ON de.domain_id = d.id
|
||||
LEFT JOIN environments e ON de.environment_id = e.id
|
||||
LEFT JOIN zones z ON s.zone_id = z.id
|
||||
WHERE {wc}
|
||||
ORDER BY s.hostname
|
||||
LIMIT :limit OFFSET :offset
|
||||
"""), {**params, "limit": per_page, "offset": offset}).fetchall()
|
||||
|
||||
# Listes pour filtres
|
||||
domains = db.execute(text("SELECT code, name FROM domains ORDER BY name")).fetchall()
|
||||
envs = db.execute(text("SELECT code, name FROM environments ORDER BY name")).fetchall()
|
||||
zones = db.execute(text("SELECT DISTINCT name FROM zones ORDER BY name")).fetchall()
|
||||
applications = db.execute(text("""SELECT application_name, COUNT(*) as c FROM servers
|
||||
WHERE application_name IS NOT NULL AND application_name != ''
|
||||
GROUP BY application_name ORDER BY application_name""")).fetchall()
|
||||
all_apps = db.execute(text("""SELECT id, nom_court FROM applications
|
||||
WHERE itop_id IS NOT NULL ORDER BY nom_court""")).fetchall()
|
||||
|
||||
# Stats globales
|
||||
stats = {
|
||||
"total_servers": db.execute(text("SELECT COUNT(*) FROM servers")).scalar(),
|
||||
"with_excludes": db.execute(text("SELECT COUNT(*) FROM servers WHERE patch_excludes IS NOT NULL AND patch_excludes != ''")).scalar(),
|
||||
}
|
||||
|
||||
ctx = base_context(request, db, user)
|
||||
ctx.update({
|
||||
"app_name": APP_NAME,
|
||||
"servers": rows, "total": total,
|
||||
"page": page, "per_page": per_page, "total_pages": total_pages,
|
||||
"filters": {"search": search, "domain": domain, "env": env,
|
||||
"zone": zone, "tier": tier, "os": os,
|
||||
"application": application, "has_excludes": has_excludes},
|
||||
"domains": domains, "envs": envs, "zones": [z.name for z in zones],
|
||||
"applications": applications, "all_apps": all_apps,
|
||||
"stats": stats,
|
||||
"msg": request.query_params.get("msg"),
|
||||
})
|
||||
return templates.TemplateResponse("patching_config_exclusions.html", ctx)
|
||||
|
||||
|
||||
@router.post("/patching/config-exclusions/{server_id}/save")
|
||||
async def save_server_excludes(request: Request, server_id: int, db=Depends(get_db),
|
||||
patch_excludes: str = Form("")):
|
||||
"""Enregistre les exclusions d'un serveur + push iTop."""
|
||||
from fastapi.responses import JSONResponse
|
||||
user = get_current_user(request)
|
||||
if not user:
|
||||
return JSONResponse({"ok": False, "msg": "Non authentifié"}, status_code=401)
|
||||
perms = get_user_perms(db, user)
|
||||
if not _can_edit_excludes(perms):
|
||||
return JSONResponse({"ok": False, "msg": "Permission refusée"}, status_code=403)
|
||||
|
||||
# Normalise : split sur espaces/newlines, dédoublonne, rejoint avec un espace
|
||||
parts = [p.strip() for p in patch_excludes.replace("\n", " ").replace("\t", " ").split() if p.strip()]
|
||||
seen = set()
|
||||
cleaned = []
|
||||
for p in parts:
|
||||
if p not in seen:
|
||||
seen.add(p)
|
||||
cleaned.append(p)
|
||||
new_val = " ".join(cleaned)
|
||||
srv = db.execute(text("SELECT id, hostname FROM servers WHERE id=:id"), {"id": server_id}).fetchone()
|
||||
if not srv:
|
||||
return JSONResponse({"ok": False, "msg": "Serveur introuvable"}, status_code=404)
|
||||
|
||||
# 1. Maj base locale
|
||||
db.execute(text("UPDATE servers SET patch_excludes=:pe, updated_at=NOW() WHERE id=:id"),
|
||||
{"pe": new_val, "id": server_id})
|
||||
db.commit()
|
||||
|
||||
# 2. Push iTop immédiat (best effort)
|
||||
itop_result = {"pushed": False, "msg": ""}
|
||||
try:
|
||||
from ..services.itop_service import ITopClient
|
||||
from ..services.secrets_service import get_secret
|
||||
url = get_secret(db, "itop_url")
|
||||
u = get_secret(db, "itop_user")
|
||||
p = get_secret(db, "itop_pass")
|
||||
if url and u and p:
|
||||
client = ITopClient(url, u, p)
|
||||
r = client._call("core/get", **{"class": "VirtualMachine",
|
||||
"key": f'SELECT VirtualMachine WHERE name = "{srv.hostname}"', "output_fields": "name"})
|
||||
if r.get("objects"):
|
||||
vm_id = list(r["objects"].values())[0]["key"]
|
||||
upd = client.update("VirtualMachine", vm_id, {"patch_excludes": new_val})
|
||||
if upd.get("code") == 0:
|
||||
itop_result = {"pushed": True, "msg": "Poussé vers iTop"}
|
||||
else:
|
||||
itop_result = {"pushed": False, "msg": f"iTop: {upd.get('message','')[:80]}"}
|
||||
except Exception as e:
|
||||
itop_result = {"pushed": False, "msg": f"iTop error: {str(e)[:80]}"}
|
||||
|
||||
return JSONResponse({"ok": True, "patch_excludes": new_val, "itop": itop_result})
|
||||
|
||||
|
||||
@router.post("/patching/config-exclusions/bulk")
|
||||
async def bulk_update_excludes(request: Request, db=Depends(get_db)):
|
||||
"""Bulk add/remove pattern sur plusieurs serveurs."""
|
||||
from fastapi.responses import JSONResponse
|
||||
user = get_current_user(request)
|
||||
if not user:
|
||||
return JSONResponse({"ok": False, "msg": "Non authentifié"}, status_code=401)
|
||||
perms = get_user_perms(db, user)
|
||||
if not _can_edit_excludes(perms):
|
||||
return JSONResponse({"ok": False, "msg": "Permission refusée"}, status_code=403)
|
||||
|
||||
body = await request.json()
|
||||
server_ids = body.get("server_ids", [])
|
||||
pattern = (body.get("pattern") or "").strip()
|
||||
action = body.get("action", "add") # "add" | "remove" | "replace"
|
||||
|
||||
if not server_ids or not pattern and action != "replace":
|
||||
return JSONResponse({"ok": False, "msg": "Paramètres manquants"})
|
||||
|
||||
ids = [int(x) for x in server_ids if str(x).isdigit()]
|
||||
if not ids:
|
||||
return JSONResponse({"ok": False, "msg": "Aucun serveur valide"})
|
||||
|
||||
placeholders = ",".join(str(i) for i in ids)
|
||||
rows = db.execute(text(f"SELECT id, hostname, patch_excludes FROM servers WHERE id IN ({placeholders})")).fetchall()
|
||||
|
||||
updated = 0
|
||||
for r in rows:
|
||||
current = (r.patch_excludes or "").strip()
|
||||
parts = current.split() if current else []
|
||||
if action == "add":
|
||||
if pattern not in parts:
|
||||
parts.append(pattern)
|
||||
elif action == "remove":
|
||||
parts = [p for p in parts if p != pattern]
|
||||
elif action == "replace":
|
||||
parts = pattern.split()
|
||||
new_val = " ".join(parts)
|
||||
if new_val != current:
|
||||
db.execute(text("UPDATE servers SET patch_excludes=:pe, updated_at=NOW() WHERE id=:id"),
|
||||
{"pe": new_val, "id": r.id})
|
||||
updated += 1
|
||||
db.commit()
|
||||
|
||||
# Push iTop en batch (best effort, async conceptually)
|
||||
itop_pushed = 0
|
||||
itop_errors = 0
|
||||
try:
|
||||
from ..services.itop_service import ITopClient
|
||||
from ..services.secrets_service import get_secret
|
||||
url = get_secret(db, "itop_url")
|
||||
u = get_secret(db, "itop_user")
|
||||
p = get_secret(db, "itop_pass")
|
||||
if url and u and p:
|
||||
client = ITopClient(url, u, p)
|
||||
# Refresh après update
|
||||
rows2 = db.execute(text(f"SELECT hostname, patch_excludes FROM servers WHERE id IN ({placeholders})")).fetchall()
|
||||
for r in rows2:
|
||||
resp = client._call("core/get", **{"class": "VirtualMachine",
|
||||
"key": f'SELECT VirtualMachine WHERE name = "{r.hostname}"', "output_fields": "name"})
|
||||
if resp.get("objects"):
|
||||
vm_id = list(resp["objects"].values())[0]["key"]
|
||||
up = client.update("VirtualMachine", vm_id, {"patch_excludes": r.patch_excludes or ""})
|
||||
if up.get("code") == 0:
|
||||
itop_pushed += 1
|
||||
else:
|
||||
itop_errors += 1
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
return JSONResponse({"ok": True, "updated": updated, "itop_pushed": itop_pushed, "itop_errors": itop_errors})
|
||||
|
||||
|
||||
@router.post("/patching/config-exclusions/bulk-application")
|
||||
async def bulk_update_application(request: Request, db=Depends(get_db)):
|
||||
"""Bulk changement de solution applicative sur plusieurs serveurs."""
|
||||
from fastapi.responses import JSONResponse
|
||||
user = get_current_user(request)
|
||||
if not user:
|
||||
return JSONResponse({"ok": False, "msg": "Non authentifié"}, status_code=401)
|
||||
perms = get_user_perms(db, user)
|
||||
if not _can_edit_excludes(perms):
|
||||
return JSONResponse({"ok": False, "msg": "Permission refusée"}, status_code=403)
|
||||
|
||||
body = await request.json()
|
||||
server_ids = body.get("server_ids", [])
|
||||
application_id = body.get("application_id") # int ou None/"" pour désassocier
|
||||
|
||||
ids = [int(x) for x in server_ids if str(x).isdigit()]
|
||||
if not ids:
|
||||
return JSONResponse({"ok": False, "msg": "Aucun serveur"})
|
||||
|
||||
app_id_val = None
|
||||
app_itop_id = None
|
||||
app_name = None
|
||||
if application_id and str(application_id).strip().isdigit():
|
||||
app_id_val = int(application_id)
|
||||
row = db.execute(text("SELECT itop_id, nom_court FROM applications WHERE id=:id"),
|
||||
{"id": app_id_val}).fetchone()
|
||||
if row:
|
||||
app_itop_id = row.itop_id
|
||||
app_name = row.nom_court
|
||||
else:
|
||||
return JSONResponse({"ok": False, "msg": "Application introuvable"})
|
||||
|
||||
placeholders = ",".join(str(i) for i in ids)
|
||||
db.execute(text(f"""UPDATE servers SET application_id=:aid, application_name=:an, updated_at=NOW()
|
||||
WHERE id IN ({placeholders})"""), {"aid": app_id_val, "an": app_name})
|
||||
db.commit()
|
||||
updated = len(ids)
|
||||
|
||||
# Push iTop
|
||||
itop_pushed = 0
|
||||
itop_errors = 0
|
||||
try:
|
||||
from ..services.itop_service import ITopClient
|
||||
from ..services.secrets_service import get_secret
|
||||
url = get_secret(db, "itop_url")
|
||||
u = get_secret(db, "itop_user")
|
||||
p = get_secret(db, "itop_pass")
|
||||
if url and u and p:
|
||||
client = ITopClient(url, u, p)
|
||||
new_list = [{"applicationsolution_id": int(app_itop_id)}] if app_itop_id else []
|
||||
hosts = db.execute(text(f"SELECT hostname FROM servers WHERE id IN ({placeholders})")).fetchall()
|
||||
for h in hosts:
|
||||
try:
|
||||
rr = client._call("core/get", **{"class": "VirtualMachine",
|
||||
"key": f'SELECT VirtualMachine WHERE name = "{h.hostname}"', "output_fields": "name"})
|
||||
if rr.get("objects"):
|
||||
vm_id = list(rr["objects"].values())[0]["key"]
|
||||
up = client.update("VirtualMachine", vm_id, {"applicationsolution_list": new_list})
|
||||
if up.get("code") == 0:
|
||||
itop_pushed += 1
|
||||
else:
|
||||
itop_errors += 1
|
||||
except Exception:
|
||||
itop_errors += 1
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
return JSONResponse({"ok": True, "updated": updated, "itop_pushed": itop_pushed, "itop_errors": itop_errors,
|
||||
"app_name": app_name or "(aucune)"})
|
||||
|
||||
|
||||
# ═══════════════════════════════════════════════════════
|
||||
# Correspondance prod ↔ hors-prod
|
||||
# ═══════════════════════════════════════════════════════
|
||||
|
||||
@router.get("/patching/correspondance", response_class=HTMLResponse)
|
||||
async def correspondance_page(request: Request, db=Depends(get_db),
|
||||
search: str = Query(""), application: str = Query(""),
|
||||
domain: str = Query(""), env: str = Query("")):
|
||||
user = get_current_user(request)
|
||||
if not user:
|
||||
return RedirectResponse(url="/login")
|
||||
perms = get_user_perms(db, user)
|
||||
if not _can_edit_excludes(perms) and not can_view(perms, "campaigns"):
|
||||
return RedirectResponse(url="/dashboard")
|
||||
|
||||
servers = corr.get_servers_for_builder(db, search=search, app=application,
|
||||
domain=domain, env=env)
|
||||
|
||||
applications = db.execute(text("""SELECT DISTINCT application_name FROM servers
|
||||
WHERE application_name IS NOT NULL AND application_name != ''
|
||||
ORDER BY application_name""")).fetchall()
|
||||
envs = db.execute(text("SELECT DISTINCT name FROM environments ORDER BY name")).fetchall()
|
||||
domains = db.execute(text("SELECT DISTINCT name FROM domains ORDER BY name")).fetchall()
|
||||
all_apps = db.execute(text("""SELECT id, nom_court FROM applications
|
||||
WHERE itop_id IS NOT NULL ORDER BY nom_court""")).fetchall()
|
||||
|
||||
# Stats globales
|
||||
stats = {
|
||||
"total_links": db.execute(text("SELECT COUNT(*) FROM server_correspondance")).scalar() or 0,
|
||||
"filtered": len(servers),
|
||||
}
|
||||
|
||||
ctx = base_context(request, db, user)
|
||||
ctx.update({"app_name": APP_NAME, "servers": servers, "stats": stats,
|
||||
"applications": applications,
|
||||
"envs": [e.name for e in envs],
|
||||
"domains": [d.name for d in domains],
|
||||
"all_apps": all_apps,
|
||||
"search": search, "application": application,
|
||||
"domain": domain, "env": env,
|
||||
"can_edit": _can_edit_excludes(perms),
|
||||
"msg": request.query_params.get("msg", "")})
|
||||
return templates.TemplateResponse("patching_correspondance.html", ctx)
|
||||
|
||||
|
||||
@router.post("/patching/correspondance/bulk-env")
|
||||
async def correspondance_bulk_env(request: Request, db=Depends(get_db)):
|
||||
"""Change l'environnement réel de N serveurs (PatchCenter + push iTop)."""
|
||||
user = get_current_user(request)
|
||||
if not user:
|
||||
return JSONResponse({"ok": False}, status_code=401)
|
||||
perms = get_user_perms(db, user)
|
||||
if not _can_edit_excludes(perms):
|
||||
return JSONResponse({"ok": False, "msg": "Permission refusée"}, status_code=403)
|
||||
body = await request.json()
|
||||
server_ids = [int(x) for x in body.get("server_ids", []) if str(x).isdigit()]
|
||||
env_name = (body.get("env_name") or "").strip()
|
||||
if not server_ids or not env_name:
|
||||
return JSONResponse({"ok": False, "msg": "Paramètres manquants"})
|
||||
|
||||
# Trouver env_id
|
||||
env_row = db.execute(text("SELECT id FROM environments WHERE name=:n"), {"n": env_name}).fetchone()
|
||||
if not env_row:
|
||||
return JSONResponse({"ok": False, "msg": f"Env '{env_name}' introuvable"})
|
||||
env_id = env_row.id
|
||||
|
||||
placeholders = ",".join(str(i) for i in server_ids)
|
||||
# Pour chaque serveur : trouver/créer le domain_env correspondant et l'affecter
|
||||
updated = 0
|
||||
srvs = db.execute(text(f"""SELECT s.id, s.hostname, s.domain_env_id, de.domain_id
|
||||
FROM servers s LEFT JOIN domain_environments de ON s.domain_env_id = de.id
|
||||
WHERE s.id IN ({placeholders})""")).fetchall()
|
||||
|
||||
for s in srvs:
|
||||
if not s.domain_id:
|
||||
continue # pas de domaine actuel, skip
|
||||
# Trouver ou créer domain_environments(domain_id, env_id)
|
||||
de = db.execute(text("""SELECT id FROM domain_environments
|
||||
WHERE domain_id=:d AND environment_id=:e"""),
|
||||
{"d": s.domain_id, "e": env_id}).fetchone()
|
||||
if not de:
|
||||
db.execute(text("""INSERT INTO domain_environments (domain_id, environment_id)
|
||||
VALUES (:d, :e)"""), {"d": s.domain_id, "e": env_id})
|
||||
db.commit()
|
||||
de = db.execute(text("""SELECT id FROM domain_environments
|
||||
WHERE domain_id=:d AND environment_id=:e"""),
|
||||
{"d": s.domain_id, "e": env_id}).fetchone()
|
||||
db.execute(text("UPDATE servers SET domain_env_id=:de_id, updated_at=NOW() WHERE id=:sid"),
|
||||
{"de_id": de.id, "sid": s.id})
|
||||
updated += 1
|
||||
db.commit()
|
||||
|
||||
# Push iTop
|
||||
itop_pushed = 0
|
||||
itop_errors = 0
|
||||
try:
|
||||
from ..services.itop_service import ITopClient
|
||||
from ..services.secrets_service import get_secret
|
||||
url = get_secret(db, "itop_url")
|
||||
u = get_secret(db, "itop_user")
|
||||
p = get_secret(db, "itop_pass")
|
||||
if url and u and p:
|
||||
client = ITopClient(url, u, p)
|
||||
for s in srvs:
|
||||
try:
|
||||
rr = client._call("core/get", **{"class": "VirtualMachine",
|
||||
"key": f'SELECT VirtualMachine WHERE name = "{s.hostname}"', "output_fields": "name"})
|
||||
if rr.get("objects"):
|
||||
vm_id = list(rr["objects"].values())[0]["key"]
|
||||
upd = client.update("VirtualMachine", vm_id, {
|
||||
"environnement_id": f"SELECT Environnement WHERE name = '{env_name}'"
|
||||
})
|
||||
if upd.get("code") == 0:
|
||||
itop_pushed += 1
|
||||
else:
|
||||
itop_errors += 1
|
||||
except Exception:
|
||||
itop_errors += 1
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
return JSONResponse({"ok": True, "updated": updated,
|
||||
"itop_pushed": itop_pushed, "itop_errors": itop_errors,
|
||||
"env_name": env_name})
|
||||
|
||||
|
||||
@router.post("/patching/correspondance/bulk-application")
|
||||
async def correspondance_bulk_app(request: Request, db=Depends(get_db)):
|
||||
"""Change la solution applicative de N serveurs (PatchCenter + push iTop)."""
|
||||
user = get_current_user(request)
|
||||
if not user:
|
||||
return JSONResponse({"ok": False}, status_code=401)
|
||||
perms = get_user_perms(db, user)
|
||||
if not _can_edit_excludes(perms):
|
||||
return JSONResponse({"ok": False, "msg": "Permission refusée"}, status_code=403)
|
||||
body = await request.json()
|
||||
server_ids = [int(x) for x in body.get("server_ids", []) if str(x).isdigit()]
|
||||
application_id = body.get("application_id")
|
||||
if not server_ids:
|
||||
return JSONResponse({"ok": False, "msg": "Aucun serveur"})
|
||||
|
||||
app_id_val = None
|
||||
app_itop_id = None
|
||||
app_name = None
|
||||
if application_id and str(application_id).strip().isdigit():
|
||||
app_id_val = int(application_id)
|
||||
row = db.execute(text("SELECT itop_id, nom_court FROM applications WHERE id=:id"),
|
||||
{"id": app_id_val}).fetchone()
|
||||
if row:
|
||||
app_itop_id = row.itop_id
|
||||
app_name = row.nom_court
|
||||
else:
|
||||
return JSONResponse({"ok": False, "msg": "Application introuvable"})
|
||||
|
||||
placeholders = ",".join(str(i) for i in server_ids)
|
||||
db.execute(text(f"""UPDATE servers SET application_id=:aid, application_name=:an, updated_at=NOW()
|
||||
WHERE id IN ({placeholders})"""), {"aid": app_id_val, "an": app_name})
|
||||
db.commit()
|
||||
|
||||
itop_pushed = 0
|
||||
itop_errors = 0
|
||||
try:
|
||||
from ..services.itop_service import ITopClient
|
||||
from ..services.secrets_service import get_secret
|
||||
url = get_secret(db, "itop_url")
|
||||
u = get_secret(db, "itop_user")
|
||||
p = get_secret(db, "itop_pass")
|
||||
if url and u and p:
|
||||
client = ITopClient(url, u, p)
|
||||
new_list = [{"applicationsolution_id": int(app_itop_id)}] if app_itop_id else []
|
||||
hosts = db.execute(text(f"SELECT hostname FROM servers WHERE id IN ({placeholders})")).fetchall()
|
||||
for h in hosts:
|
||||
try:
|
||||
rr = client._call("core/get", **{"class": "VirtualMachine",
|
||||
"key": f'SELECT VirtualMachine WHERE name = "{h.hostname}"', "output_fields": "name"})
|
||||
if rr.get("objects"):
|
||||
vm_id = list(rr["objects"].values())[0]["key"]
|
||||
up = client.update("VirtualMachine", vm_id, {"applicationsolution_list": new_list})
|
||||
if up.get("code") == 0:
|
||||
itop_pushed += 1
|
||||
else:
|
||||
itop_errors += 1
|
||||
except Exception:
|
||||
itop_errors += 1
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
return JSONResponse({"ok": True, "updated": len(server_ids),
|
||||
"itop_pushed": itop_pushed, "itop_errors": itop_errors,
|
||||
"app_name": app_name or "(aucune)"})
|
||||
|
||||
|
||||
@router.post("/patching/correspondance/bulk-create")
|
||||
async def correspondance_bulk_create(request: Request, db=Depends(get_db)):
|
||||
user = get_current_user(request)
|
||||
if not user:
|
||||
return JSONResponse({"ok": False}, status_code=401)
|
||||
perms = get_user_perms(db, user)
|
||||
if not _can_edit_excludes(perms):
|
||||
return JSONResponse({"ok": False, "msg": "Permission refusée"}, status_code=403)
|
||||
body = await request.json()
|
||||
prod_ids = [int(x) for x in body.get("prod_ids", []) if str(x).isdigit()]
|
||||
nonprod_ids = [int(x) for x in body.get("nonprod_ids", []) if str(x).isdigit()]
|
||||
env_labels = body.get("env_labels", {})
|
||||
if not prod_ids or not nonprod_ids:
|
||||
return JSONResponse({"ok": False, "msg": "Au moins 1 prod et 1 non-prod requis"})
|
||||
r = corr.bulk_create_correspondance(db, prod_ids, nonprod_ids, env_labels, user.get("uid"))
|
||||
return JSONResponse({"ok": True, **r})
|
||||
|
||||
|
||||
@router.post("/patching/correspondance/auto-detect")
|
||||
async def correspondance_auto_detect(request: Request, db=Depends(get_db)):
|
||||
user = get_current_user(request)
|
||||
if not user:
|
||||
return JSONResponse({"ok": False}, status_code=401)
|
||||
perms = get_user_perms(db, user)
|
||||
if not _can_edit_excludes(perms):
|
||||
return JSONResponse({"ok": False, "msg": "Permission refusée"}, status_code=403)
|
||||
stats = corr.detect_correspondances(db)
|
||||
return JSONResponse({"ok": True, **{k: v for k, v in stats.items() if k != "plan"}})
|
||||
|
||||
|
||||
@router.post("/patching/correspondance/link")
|
||||
async def correspondance_link(request: Request, db=Depends(get_db),
|
||||
prod_id: int = Form(...), nonprod_id: int = Form(...),
|
||||
env_code: str = Form(""), note: str = Form("")):
|
||||
user = get_current_user(request)
|
||||
if not user:
|
||||
return JSONResponse({"ok": False}, status_code=401)
|
||||
perms = get_user_perms(db, user)
|
||||
if not _can_edit_excludes(perms):
|
||||
return JSONResponse({"ok": False, "msg": "Permission refusée"}, status_code=403)
|
||||
corr.create_manual_link(db, prod_id, nonprod_id, env_code, note, user.get("uid"))
|
||||
return JSONResponse({"ok": True})
|
||||
|
||||
|
||||
@router.post("/patching/correspondance/link-by-host")
|
||||
async def correspondance_link_by_host(request: Request, db=Depends(get_db),
|
||||
prod_id: int = Form(...),
|
||||
nonprod_hostname: str = Form(...),
|
||||
env_code: str = Form(""), note: str = Form("")):
|
||||
user = get_current_user(request)
|
||||
if not user:
|
||||
return JSONResponse({"ok": False}, status_code=401)
|
||||
perms = get_user_perms(db, user)
|
||||
if not _can_edit_excludes(perms):
|
||||
return JSONResponse({"ok": False, "msg": "Permission refusée"}, status_code=403)
|
||||
row = db.execute(text("SELECT id FROM servers WHERE LOWER(hostname)=LOWER(:h)"),
|
||||
{"h": nonprod_hostname.strip()}).fetchone()
|
||||
if not row:
|
||||
return JSONResponse({"ok": False, "msg": f"Serveur '{nonprod_hostname}' introuvable"})
|
||||
corr.create_manual_link(db, prod_id, row.id, env_code, note, user.get("uid"))
|
||||
return JSONResponse({"ok": True})
|
||||
|
||||
|
||||
@router.post("/patching/correspondance/{corr_id}/delete")
|
||||
async def correspondance_delete(request: Request, corr_id: int, db=Depends(get_db)):
|
||||
user = get_current_user(request)
|
||||
if not user:
|
||||
return JSONResponse({"ok": False}, status_code=401)
|
||||
perms = get_user_perms(db, user)
|
||||
if not _can_edit_excludes(perms):
|
||||
return JSONResponse({"ok": False, "msg": "Permission refusée"}, status_code=403)
|
||||
corr.delete_link(db, corr_id)
|
||||
return JSONResponse({"ok": True})
|
||||
|
||||
|
||||
# ═══════════════════════════════════════════════════════
|
||||
# Validations post-patching
|
||||
# ═══════════════════════════════════════════════════════
|
||||
|
||||
@router.get("/patching/validations", response_class=HTMLResponse)
|
||||
async def validations_page(request: Request, db=Depends(get_db),
|
||||
status: str = Query("en_attente"),
|
||||
campaign_id: int = Query(None),
|
||||
env: str = Query("")):
|
||||
user = get_current_user(request)
|
||||
if not user:
|
||||
return RedirectResponse(url="/login")
|
||||
perms = get_user_perms(db, user)
|
||||
if not _can_edit_excludes(perms):
|
||||
return RedirectResponse(url="/dashboard")
|
||||
|
||||
validations = corr.get_pending_validations(db, env=env, campaign_id=campaign_id, status=status)
|
||||
|
||||
# Contacts validateurs (responsables + référents)
|
||||
contacts = db.execute(text("""SELECT id, name, email, role, team
|
||||
FROM contacts WHERE is_active=true
|
||||
AND role IN ('responsable_applicatif','responsable_domaine','referent_technique','ra_prod','ra_test')
|
||||
ORDER BY name""")).fetchall()
|
||||
|
||||
stats = {
|
||||
"en_attente": db.execute(text("SELECT COUNT(*) FROM patch_validation WHERE status='en_attente'")).scalar() or 0,
|
||||
"validated_ok": db.execute(text("SELECT COUNT(*) FROM patch_validation WHERE status='validated_ok'")).scalar() or 0,
|
||||
"validated_ko": db.execute(text("SELECT COUNT(*) FROM patch_validation WHERE status='validated_ko'")).scalar() or 0,
|
||||
"forced": db.execute(text("SELECT COUNT(*) FROM patch_validation WHERE status='forced'")).scalar() or 0,
|
||||
}
|
||||
|
||||
envs = db.execute(text("SELECT DISTINCT name FROM environments ORDER BY name")).fetchall()
|
||||
|
||||
ctx = base_context(request, db, user)
|
||||
ctx.update({"app_name": APP_NAME, "validations": validations, "contacts": contacts,
|
||||
"stats": stats, "status": status, "campaign_id": campaign_id, "env": env,
|
||||
"envs": [e.name for e in envs],
|
||||
"can_force": can_edit(perms, "campaigns") or user.get("role") == "admin",
|
||||
"msg": request.query_params.get("msg", "")})
|
||||
return templates.TemplateResponse("patching_validations.html", ctx)
|
||||
|
||||
|
||||
@router.post("/patching/validations/mark")
|
||||
async def validations_mark(request: Request, db=Depends(get_db)):
|
||||
user = get_current_user(request)
|
||||
if not user:
|
||||
return JSONResponse({"ok": False}, status_code=401)
|
||||
perms = get_user_perms(db, user)
|
||||
if not _can_edit_excludes(perms):
|
||||
return JSONResponse({"ok": False, "msg": "Permission refusée"}, status_code=403)
|
||||
|
||||
body = await request.json()
|
||||
ids = body.get("validation_ids", [])
|
||||
status = body.get("status", "validated_ok")
|
||||
contact_id = body.get("contact_id")
|
||||
forced_reason = body.get("forced_reason", "")
|
||||
notes = body.get("notes", "")
|
||||
|
||||
if status not in ("validated_ok", "validated_ko", "forced"):
|
||||
return JSONResponse({"ok": False, "msg": "Status invalide"})
|
||||
if status == "forced" and not forced_reason.strip():
|
||||
return JSONResponse({"ok": False, "msg": "Raison obligatoire pour forcer"})
|
||||
if status in ("validated_ok", "validated_ko") and not contact_id:
|
||||
return JSONResponse({"ok": False, "msg": "Validateur obligatoire"})
|
||||
|
||||
validator_name = None
|
||||
if contact_id:
|
||||
row = db.execute(text("SELECT name FROM contacts WHERE id=:id"),
|
||||
{"id": int(contact_id)}).fetchone()
|
||||
if row:
|
||||
validator_name = row.name
|
||||
|
||||
n = corr.mark_validation(db, ids, status, contact_id, validator_name,
|
||||
forced_reason, notes, user.get("uid"))
|
||||
return JSONResponse({"ok": True, "updated": n})
|
||||
|
||||
|
||||
@router.get("/patching/validations/history/{server_id}", response_class=HTMLResponse)
|
||||
async def validations_history(request: Request, server_id: int, db=Depends(get_db)):
|
||||
user = get_current_user(request)
|
||||
if not user:
|
||||
return RedirectResponse(url="/login")
|
||||
srv = db.execute(text("SELECT id, hostname FROM servers WHERE id=:id"), {"id": server_id}).fetchone()
|
||||
if not srv:
|
||||
return HTMLResponse("Serveur introuvable", status_code=404)
|
||||
history = corr.get_validation_history(db, server_id)
|
||||
ctx = base_context(request, db, user)
|
||||
ctx.update({"app_name": APP_NAME, "server": srv, "history": history})
|
||||
return templates.TemplateResponse("patching_validations_history.html", ctx)
|
||||
@ -40,7 +40,7 @@ def _get_planning_data(db, year):
|
||||
SELECT d.code, d.name, COUNT(s.id) as srv_count
|
||||
FROM domains d
|
||||
LEFT JOIN domain_environments de ON de.domain_id = d.id
|
||||
LEFT JOIN servers s ON s.domain_env_id = de.id AND s.etat = 'en_production'
|
||||
LEFT JOIN servers s ON s.domain_env_id = de.id AND s.etat = 'production'
|
||||
GROUP BY d.code, d.name, d.display_order
|
||||
ORDER BY d.display_order
|
||||
""")).fetchall()
|
||||
|
||||
@ -519,20 +519,20 @@ async def qualys_agents_page(request: Request, db=Depends(get_db)):
|
||||
|
||||
@router.post("/qualys/agents/refresh")
|
||||
async def qualys_agents_refresh(request: Request, db=Depends(get_db)):
|
||||
from fastapi.responses import JSONResponse
|
||||
user = get_current_user(request)
|
||||
if not user:
|
||||
return RedirectResponse(url="/login")
|
||||
return JSONResponse({"ok": False, "msg": "Non authentifié"}, status_code=401)
|
||||
perms = get_user_perms(db, user)
|
||||
if not can_edit(perms, "qualys"):
|
||||
return RedirectResponse(url="/qualys/agents")
|
||||
return JSONResponse({"ok": False, "msg": "Permission refusée"}, status_code=403)
|
||||
from ..services.qualys_service import refresh_all_agents
|
||||
try:
|
||||
stats = refresh_all_agents(db)
|
||||
msg = f"refresh_ok_{stats.get('created',0)}_{stats.get('updated',0)}"
|
||||
return JSONResponse({"ok": True, "msg": f"{stats.get('created',0)} créés, {stats.get('updated',0)} mis à jour", "stats": stats})
|
||||
except Exception as e:
|
||||
import traceback; traceback.print_exc()
|
||||
msg = "refresh_error"
|
||||
return RedirectResponse(url=f"/qualys/agents?msg={msg}", status_code=303)
|
||||
return JSONResponse({"ok": False, "msg": str(e)[:200]}, status_code=500)
|
||||
|
||||
|
||||
@router.get("/qualys/agents/export-no-agent")
|
||||
@ -872,15 +872,20 @@ async def qualys_deploy_page(request: Request, db=Depends(get_db)):
|
||||
|
||||
packages = list_packages()
|
||||
servers = db.execute(text("""
|
||||
SELECT s.id, s.hostname, s.os_family, s.etat, s.ssh_user, s.ssh_port, s.ssh_method,
|
||||
d.name as domain, e.name as env
|
||||
SELECT s.id, s.hostname, s.os_family, s.os_version, s.etat, s.ssh_user, s.ssh_port, s.ssh_method,
|
||||
d.name as domain, e.name as env,
|
||||
qa.agent_version, qa.agent_status, qa.last_checkin
|
||||
FROM servers s
|
||||
LEFT JOIN domain_environments de ON s.domain_env_id = de.id
|
||||
LEFT JOIN domains d ON de.domain_id = d.id
|
||||
LEFT JOIN environments e ON de.environment_id = e.id
|
||||
LEFT JOIN qualys_assets qa ON qa.server_id = s.id
|
||||
ORDER BY s.hostname
|
||||
""")).fetchall()
|
||||
servers = [dict(r._mapping) for r in servers]
|
||||
for s in servers:
|
||||
if s.get("last_checkin"):
|
||||
s["last_checkin"] = str(s["last_checkin"])[:19]
|
||||
|
||||
ctx = base_context(request, db, user)
|
||||
ctx.update({
|
||||
@ -896,106 +901,96 @@ async def qualys_deploy_page(request: Request, db=Depends(get_db)):
|
||||
|
||||
|
||||
@router.post("/qualys/deploy/run")
|
||||
async def qualys_deploy_run(request: Request, db=Depends(get_db),
|
||||
server_ids: str = Form(""),
|
||||
activation_id: str = Form(""),
|
||||
customer_id: str = Form(""),
|
||||
server_uri: str = Form(""),
|
||||
package_deb: str = Form(""),
|
||||
package_rpm: str = Form("")):
|
||||
async def qualys_deploy_run(request: Request, db=Depends(get_db)):
|
||||
from fastapi.responses import JSONResponse
|
||||
user = get_current_user(request)
|
||||
if not user:
|
||||
return RedirectResponse(url="/login")
|
||||
return JSONResponse({"ok": False, "msg": "Non authentifié"}, status_code=401)
|
||||
perms = get_user_perms(db, user)
|
||||
if not can_edit(perms, "qualys"):
|
||||
return RedirectResponse(url="/dashboard")
|
||||
return JSONResponse({"ok": False, "msg": "Permission refusée"}, status_code=403)
|
||||
|
||||
from ..services.agent_deploy_service import deploy_agent
|
||||
from ..services.agent_deploy_service import start_deploy_job
|
||||
from ..services.secrets_service import get_secret
|
||||
|
||||
ids = [int(x) for x in server_ids.split(",") if x.strip().isdigit()]
|
||||
body = await request.json()
|
||||
server_ids = body.get("server_ids", "")
|
||||
activation_id = body.get("activation_id", "")
|
||||
customer_id = body.get("customer_id", "")
|
||||
server_uri = body.get("server_uri", "")
|
||||
package_deb = body.get("package_deb", "")
|
||||
package_rpm = body.get("package_rpm", "")
|
||||
force_downgrade = body.get("force_downgrade", False)
|
||||
|
||||
ids = [int(x) for x in str(server_ids).split(",") if x.strip().isdigit()]
|
||||
if not ids:
|
||||
return RedirectResponse(url="/qualys/deploy?msg=no_servers", status_code=303)
|
||||
return JSONResponse({"ok": False, "msg": "Aucun serveur sélectionné"})
|
||||
|
||||
ssh_key = get_secret(db, "ssh_key_file") or "/opt/patchcenter/keys/id_ed25519"
|
||||
|
||||
# Get servers
|
||||
placeholders = ",".join(str(i) for i in ids)
|
||||
servers = db.execute(text(f"""
|
||||
SELECT id, hostname, os_family, ssh_user, ssh_port, ssh_method
|
||||
rows = db.execute(text(f"""
|
||||
SELECT id, hostname, os_family, os_version, ssh_user, ssh_port, ssh_method
|
||||
FROM servers WHERE id IN ({placeholders})
|
||||
""")).fetchall()
|
||||
servers = [{"hostname": r.hostname, "os_family": r.os_family,
|
||||
"os_version": r.os_version, "ssh_user": r.ssh_user, "ssh_port": r.ssh_port} for r in rows]
|
||||
|
||||
results = []
|
||||
log_lines = []
|
||||
job_id = start_deploy_job(servers, ssh_key, package_deb, package_rpm,
|
||||
activation_id, customer_id, server_uri, force_downgrade=force_downgrade)
|
||||
|
||||
for srv in servers:
|
||||
# Choose package based on OS
|
||||
if "debian" in (srv.os_family or "").lower() or srv.os_family == "linux":
|
||||
pkg = package_deb
|
||||
else:
|
||||
pkg = package_rpm
|
||||
|
||||
if not pkg or not os.path.exists(pkg):
|
||||
results.append({"hostname": srv.hostname, "status": "FAILED", "detail": f"Package introuvable: {pkg}"})
|
||||
continue
|
||||
|
||||
def on_line(msg, h=srv.hostname):
|
||||
log_lines.append(f"[{h}] {msg}")
|
||||
|
||||
result = deploy_agent(
|
||||
hostname=srv.hostname,
|
||||
ssh_user=srv.ssh_user or "root",
|
||||
ssh_key_path=ssh_key,
|
||||
ssh_port=srv.ssh_port or 22,
|
||||
os_family=srv.os_family,
|
||||
package_path=pkg,
|
||||
activation_id=activation_id,
|
||||
customer_id=customer_id,
|
||||
server_uri=server_uri,
|
||||
on_line=on_line,
|
||||
)
|
||||
results.append(result)
|
||||
|
||||
# Save to app state for display
|
||||
request.app.state.last_deploy_results = results
|
||||
request.app.state.last_deploy_log = log_lines
|
||||
|
||||
# Audit log
|
||||
from ..services.audit_service import log_action
|
||||
ok = sum(1 for r in results if r["status"] in ("SUCCESS", "ALREADY_INSTALLED"))
|
||||
fail = sum(1 for r in results if r["status"] not in ("SUCCESS", "ALREADY_INSTALLED"))
|
||||
log_action(db, request, user, "qualys_deploy", f"{ok} OK, {fail} fail sur {len(results)} serveurs")
|
||||
log_action(db, request, user, "qualys_deploy",
|
||||
entity_type="deploy_job",
|
||||
details={"job_id": job_id, "servers": len(servers)})
|
||||
db.commit()
|
||||
|
||||
ctx = base_context(request, db, user)
|
||||
ctx.update({
|
||||
"app_name": APP_NAME,
|
||||
"results": results,
|
||||
"log_lines": log_lines,
|
||||
"total": len(results),
|
||||
"ok": ok,
|
||||
"failed": fail,
|
||||
return JSONResponse({"ok": True, "job_id": job_id, "total": len(servers)})
|
||||
|
||||
|
||||
@router.get("/qualys/deploy/status/{job_id}")
|
||||
async def qualys_deploy_status(request: Request, job_id: str, db=Depends(get_db)):
|
||||
from fastapi.responses import JSONResponse
|
||||
user = get_current_user(request)
|
||||
if not user:
|
||||
return JSONResponse({"ok": False, "msg": "Non authentifié"}, status_code=401)
|
||||
|
||||
from ..services.agent_deploy_service import get_deploy_job
|
||||
job = get_deploy_job(job_id)
|
||||
if not job:
|
||||
return JSONResponse({"ok": False, "msg": "Job introuvable"}, status_code=404)
|
||||
|
||||
import time
|
||||
elapsed = int(time.time() - job["started_at"])
|
||||
return JSONResponse({
|
||||
"ok": True,
|
||||
"job_id": job_id,
|
||||
"total": job["total"],
|
||||
"done": job["done"],
|
||||
"finished": job["finished"],
|
||||
"elapsed": elapsed,
|
||||
"servers": job["servers"],
|
||||
"log": job["log"][-50:], # dernières 50 lignes
|
||||
})
|
||||
return templates.TemplateResponse("qualys_deploy_results.html", ctx)
|
||||
|
||||
|
||||
@router.post("/qualys/deploy/check")
|
||||
async def qualys_deploy_check(request: Request, db=Depends(get_db),
|
||||
server_ids: str = Form("")):
|
||||
async def qualys_deploy_check(request: Request, db=Depends(get_db)):
|
||||
from fastapi.responses import JSONResponse
|
||||
user = get_current_user(request)
|
||||
if not user:
|
||||
return RedirectResponse(url="/login")
|
||||
return JSONResponse({"ok": False, "msg": "Non authentifié"}, status_code=401)
|
||||
perms = get_user_perms(db, user)
|
||||
if not can_view(perms, "qualys"):
|
||||
return RedirectResponse(url="/dashboard")
|
||||
return JSONResponse({"ok": False, "msg": "Permission refusée"}, status_code=403)
|
||||
|
||||
from ..services.agent_deploy_service import check_agent
|
||||
from ..services.secrets_service import get_secret
|
||||
|
||||
ids = [int(x) for x in server_ids.split(",") if x.strip().isdigit()]
|
||||
body = await request.json()
|
||||
server_ids = body.get("server_ids", "")
|
||||
ids = [int(x) for x in str(server_ids).split(",") if x.strip().isdigit()]
|
||||
if not ids:
|
||||
return RedirectResponse(url="/qualys/deploy?msg=no_servers", status_code=303)
|
||||
return JSONResponse({"ok": False, "msg": "Aucun serveur sélectionné"})
|
||||
|
||||
ssh_key = get_secret(db, "ssh_key_file") or "/opt/patchcenter/keys/id_ed25519"
|
||||
placeholders = ",".join(str(i) for i in ids)
|
||||
@ -1006,13 +1001,7 @@ async def qualys_deploy_check(request: Request, db=Depends(get_db),
|
||||
r = check_agent(srv.hostname, srv.ssh_user or "root", ssh_key, srv.ssh_port or 22)
|
||||
results.append(r)
|
||||
|
||||
ctx = base_context(request, db, user)
|
||||
ctx.update({
|
||||
"app_name": APP_NAME,
|
||||
"results": results,
|
||||
"total": len(results),
|
||||
"active": sum(1 for r in results if r["status"] == "ACTIVE"),
|
||||
"not_installed": sum(1 for r in results if r["status"] == "NOT_INSTALLED"),
|
||||
"failed": sum(1 for r in results if r["status"] == "CONNECTION_FAILED"),
|
||||
})
|
||||
return templates.TemplateResponse("qualys_deploy_results.html", ctx)
|
||||
return JSONResponse({"ok": True, "results": results, "total": len(results),
|
||||
"active": sum(1 for r in results if r["status"] == "ACTIVE"),
|
||||
"not_installed": sum(1 for r in results if r["status"] == "NOT_INSTALLED"),
|
||||
"failed": sum(1 for r in results if r["status"] == "CONNECTION_FAILED")})
|
||||
|
||||
@ -10,7 +10,7 @@ from ..services.quickwin_service import (
|
||||
get_server_configs, upsert_server_config, delete_server_config,
|
||||
get_eligible_servers, list_runs, get_run, get_run_entries,
|
||||
create_run, delete_run, update_entry_field,
|
||||
can_start_prod, get_run_stats, inject_yum_history,
|
||||
can_start_prod, check_prod_validations, get_run_stats, inject_yum_history,
|
||||
advance_run_status, get_step_stats, mark_snapshot, mark_all_snapshots,
|
||||
build_yum_commands, get_available_servers, get_available_filters,
|
||||
add_entries_to_run, remove_entries_from_run,
|
||||
@ -55,53 +55,32 @@ async def quickwin_page(request: Request, db=Depends(get_db)):
|
||||
|
||||
# -- Config exclusions par serveur --
|
||||
|
||||
DEFAULT_REBOOT_PACKAGES = (
|
||||
"kernel* glibc* systemd* dbus* polkit* linux-firmware* microcode_ctl* "
|
||||
"tuned* dracut* grub2* kexec-tools* libselinux* selinux-policy* shim* "
|
||||
"mokutil* net-snmp* NetworkManager* network-scripts* nss* openssl-libs*"
|
||||
)
|
||||
|
||||
|
||||
@router.get("/quickwin/config", response_class=HTMLResponse)
|
||||
async def quickwin_config_page(request: Request, db=Depends(get_db),
|
||||
page: int = Query(1),
|
||||
per_page: int = Query(14),
|
||||
search: str = Query(""),
|
||||
env: str = Query(""),
|
||||
domain: str = Query(""),
|
||||
zone: str = Query("")):
|
||||
async def quickwin_config_page(request: Request, db=Depends(get_db)):
|
||||
"""Page d'édition de la liste globale des packages qui nécessitent un reboot.
|
||||
Cette liste est utilisée par QuickWin (en plus des exclusions iTop par serveur)."""
|
||||
user = get_current_user(request)
|
||||
if not user:
|
||||
return RedirectResponse(url="/login")
|
||||
perms = get_user_perms(db, user)
|
||||
if not can_view(perms, "campaigns") and not can_view(perms, "quickwin"):
|
||||
if not can_edit(perms, "campaigns") and not can_edit(perms, "quickwin"):
|
||||
return RedirectResponse(url="/dashboard")
|
||||
|
||||
configs = get_server_configs(db)
|
||||
|
||||
# Filtres
|
||||
filtered = configs
|
||||
if search:
|
||||
filtered = [s for s in filtered if search.lower() in s.hostname.lower()]
|
||||
if env:
|
||||
filtered = [s for s in filtered if s.environnement == env]
|
||||
if domain:
|
||||
filtered = [s for s in filtered if s.domaine == domain]
|
||||
if zone:
|
||||
filtered = [s for s in filtered if (s.zone or '') == zone]
|
||||
|
||||
# Pagination
|
||||
per_page = max(5, min(per_page, 100))
|
||||
total = len(filtered)
|
||||
total_pages = max(1, (total + per_page - 1) // per_page)
|
||||
page = max(1, min(page, total_pages))
|
||||
start = (page - 1) * per_page
|
||||
page_servers = filtered[start:start + per_page]
|
||||
from ..services.secrets_service import get_secret
|
||||
current = get_secret(db, "patching_reboot_packages") or DEFAULT_REBOOT_PACKAGES
|
||||
|
||||
ctx = base_context(request, db, user)
|
||||
ctx.update({
|
||||
"app_name": APP_NAME,
|
||||
"all_servers": page_servers,
|
||||
"all_configs": configs,
|
||||
"default_excludes": DEFAULT_GENERAL_EXCLUDES,
|
||||
"total_count": total,
|
||||
"page": page,
|
||||
"per_page": per_page,
|
||||
"total_pages": total_pages,
|
||||
"filters": {"search": search, "env": env, "domain": domain, "zone": zone},
|
||||
"reboot_packages": current,
|
||||
"default_packages": DEFAULT_REBOOT_PACKAGES,
|
||||
"msg": request.query_params.get("msg"),
|
||||
})
|
||||
return templates.TemplateResponse("quickwin_config.html", ctx)
|
||||
@ -109,44 +88,20 @@ async def quickwin_config_page(request: Request, db=Depends(get_db),
|
||||
|
||||
@router.post("/quickwin/config/save")
|
||||
async def quickwin_config_save(request: Request, db=Depends(get_db),
|
||||
server_id: int = Form(0),
|
||||
general_excludes: str = Form(""),
|
||||
specific_excludes: str = Form(""),
|
||||
notes: str = Form("")):
|
||||
reboot_packages: str = Form("")):
|
||||
"""Sauvegarde la liste globale des packages nécessitant un reboot."""
|
||||
user = get_current_user(request)
|
||||
if not user:
|
||||
return RedirectResponse(url="/login")
|
||||
if server_id:
|
||||
upsert_server_config(db, server_id, general_excludes.strip(),
|
||||
specific_excludes.strip(), notes.strip())
|
||||
perms = get_user_perms(db, user)
|
||||
if not can_edit(perms, "campaigns") and not can_edit(perms, "quickwin"):
|
||||
return RedirectResponse(url="/dashboard")
|
||||
from ..services.secrets_service import set_secret
|
||||
set_secret(db, "patching_reboot_packages", reboot_packages.strip(),
|
||||
"Packages nécessitant un reboot (QuickWin)")
|
||||
return RedirectResponse(url="/quickwin/config?msg=saved", status_code=303)
|
||||
|
||||
|
||||
@router.post("/quickwin/config/delete")
|
||||
async def quickwin_config_delete(request: Request, db=Depends(get_db),
|
||||
config_id: int = Form(0)):
|
||||
user = get_current_user(request)
|
||||
if not user:
|
||||
return RedirectResponse(url="/login")
|
||||
if config_id:
|
||||
delete_server_config(db, config_id)
|
||||
return RedirectResponse(url="/quickwin/config?msg=deleted", status_code=303)
|
||||
|
||||
|
||||
@router.post("/quickwin/config/bulk-add")
|
||||
async def quickwin_config_bulk_add(request: Request, db=Depends(get_db),
|
||||
server_ids: str = Form(""),
|
||||
general_excludes: str = Form("")):
|
||||
"""Ajouter plusieurs serveurs d'un coup avec les memes exclusions generales"""
|
||||
user = get_current_user(request)
|
||||
if not user:
|
||||
return RedirectResponse(url="/login")
|
||||
ids = [int(x) for x in server_ids.split(",") if x.strip().isdigit()]
|
||||
for sid in ids:
|
||||
upsert_server_config(db, sid, general_excludes.strip(), "", "")
|
||||
return RedirectResponse(url=f"/quickwin/config?msg=added_{len(ids)}", status_code=303)
|
||||
|
||||
|
||||
# -- Runs QuickWin --
|
||||
|
||||
@router.post("/quickwin/create")
|
||||
@ -168,7 +123,7 @@ async def quickwin_create(request: Request, db=Depends(get_db),
|
||||
|
||||
ids = [int(x) for x in server_ids.split(",") if x.strip().isdigit()]
|
||||
if not ids:
|
||||
# Prendre tous les serveurs eligibles (linux, en_production, secops)
|
||||
# Prendre tous les serveurs eligibles (linux, production, secops)
|
||||
eligible = get_eligible_servers(db)
|
||||
ids = [s.id for s in eligible]
|
||||
|
||||
@ -189,6 +144,9 @@ async def quickwin_correspondance_redirect(request: Request, db=Depends(get_db))
|
||||
user = get_current_user(request)
|
||||
if not user:
|
||||
return RedirectResponse(url="/login")
|
||||
perms = get_user_perms(db, user)
|
||||
if not can_edit(perms, "campaigns") and not can_edit(perms, "quickwin"):
|
||||
return RedirectResponse(url="/dashboard")
|
||||
runs = list_runs(db)
|
||||
if not runs:
|
||||
return RedirectResponse(url="/quickwin?msg=no_run")
|
||||
@ -221,6 +179,7 @@ async def quickwin_detail(request: Request, run_id: int, db=Depends(get_db),
|
||||
entries = get_run_entries(db, run_id)
|
||||
stats = get_run_stats(db, run_id)
|
||||
prod_ok = can_start_prod(db, run_id)
|
||||
validations_ok, validations_blockers = check_prod_validations(db, run_id)
|
||||
step_stats_hp = get_step_stats(db, run_id, "hprod")
|
||||
step_stats_pr = get_step_stats(db, run_id, "prod")
|
||||
|
||||
@ -279,6 +238,7 @@ async def quickwin_detail(request: Request, run_id: int, db=Depends(get_db),
|
||||
"p_page": p_page, "p_total_pages": p_total_pages,
|
||||
"per_page": per_page,
|
||||
"prod_ok": prod_ok,
|
||||
"validations_ok": validations_ok, "validations_blockers": validations_blockers,
|
||||
"step_hp": step_stats_hp, "step_pr": step_stats_pr,
|
||||
"scope": scope,
|
||||
"filters": {"search": search, "status": status, "domain": domain,
|
||||
|
||||
@ -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"}
|
||||
)
|
||||
@ -2,6 +2,7 @@
|
||||
from fastapi import APIRouter, Request, Depends, Query, Form
|
||||
from fastapi.responses import HTMLResponse, RedirectResponse, StreamingResponse
|
||||
from fastapi.templating import Jinja2Templates
|
||||
from sqlalchemy import text
|
||||
from ..dependencies import get_db, get_current_user
|
||||
from ..services.server_service import (
|
||||
get_server_full, get_server_tags, get_server_ips,
|
||||
@ -19,21 +20,38 @@ async def servers_list(request: Request, db=Depends(get_db),
|
||||
domain: str = Query(None), env: str = Query(None),
|
||||
tier: str = Query(None), etat: str = Query(None),
|
||||
os: str = Query(None), owner: str = Query(None),
|
||||
application: str = Query(None),
|
||||
search: str = Query(None), page: int = Query(1),
|
||||
sort: str = Query("hostname"), sort_dir: str = Query("asc")):
|
||||
user = get_current_user(request)
|
||||
if not user:
|
||||
return RedirectResponse(url="/login")
|
||||
|
||||
filters = {"domain": domain, "env": env, "tier": tier, "etat": etat, "os": os, "owner": owner, "search": search}
|
||||
filters = {"domain": domain, "env": env, "tier": tier, "etat": etat, "os": os,
|
||||
"owner": owner, "application": application, "search": search}
|
||||
servers, total = list_servers(db, filters, page, sort=sort, sort_dir=sort_dir)
|
||||
domains_list, envs_list = get_reference_data(db)
|
||||
|
||||
applications_list = db.execute(text("""SELECT application_name, COUNT(*) as c FROM servers
|
||||
WHERE application_name IS NOT NULL AND application_name != ''
|
||||
GROUP BY application_name ORDER BY application_name""")).fetchall()
|
||||
|
||||
from ..dependencies import get_user_perms, can_edit
|
||||
perms = get_user_perms(db, user)
|
||||
can_edit_servers = can_edit(perms, "servers")
|
||||
|
||||
# Correspondances en bulk pour la page en cours
|
||||
from ..services.correspondance_service import get_links_bulk
|
||||
links = get_links_bulk(db, [s.id for s in servers])
|
||||
|
||||
return templates.TemplateResponse("servers.html", {
|
||||
"request": request, "user": user, "app_name": APP_NAME,
|
||||
"servers": servers, "total": total, "page": page, "per_page": 50,
|
||||
"domains_list": domains_list, "envs_list": envs_list, "filters": filters,
|
||||
"domains_list": domains_list, "envs_list": envs_list,
|
||||
"applications_list": applications_list, "filters": filters,
|
||||
"sort": sort, "sort_dir": sort_dir,
|
||||
"perms": perms, "can_edit_servers": can_edit_servers,
|
||||
"links": links,
|
||||
})
|
||||
|
||||
|
||||
@ -78,8 +96,10 @@ async def server_detail(request: Request, server_id: int, db=Depends(get_db)):
|
||||
return HTMLResponse("<p>Serveur non trouve</p>")
|
||||
tags = get_server_tags(db, s.qid)
|
||||
ips = get_server_ips(db, server_id)
|
||||
from ..services.correspondance_service import get_server_links
|
||||
links = get_server_links(db, server_id)
|
||||
return templates.TemplateResponse("partials/server_detail.html", {
|
||||
"request": request, "s": s, "tags": tags, "ips": ips
|
||||
"request": request, "s": s, "tags": tags, "ips": ips, "links": links
|
||||
})
|
||||
|
||||
|
||||
@ -100,10 +120,14 @@ async def server_edit(request: Request, server_id: int, db=Depends(get_db)):
|
||||
zones_list = db.execute(sqlt(
|
||||
"SELECT name FROM zones ORDER BY name"
|
||||
)).fetchall()
|
||||
applications = db.execute(sqlt(
|
||||
"SELECT id, nom_court FROM applications WHERE itop_id IS NOT NULL ORDER BY nom_court"
|
||||
)).fetchall()
|
||||
return templates.TemplateResponse("partials/server_edit.html", {
|
||||
"request": request, "s": s, "domains": domains, "envs": envs, "ips": ips,
|
||||
"dns_list": [r.name for r in dns_list],
|
||||
"zones_list": [r.name for r in zones_list],
|
||||
"applications": applications,
|
||||
})
|
||||
|
||||
|
||||
@ -116,11 +140,15 @@ async def server_update(request: Request, server_id: int, db=Depends(get_db),
|
||||
commentaire: str = Form(None),
|
||||
ip_reelle: str = Form(None), ip_connexion: str = Form(None),
|
||||
ssh_method: str = Form(None), domain_ltd: str = Form(None),
|
||||
pref_patch_jour: str = Form(None), pref_patch_heure: str = Form(None)):
|
||||
pref_patch_jour: str = Form(None), pref_patch_heure: str = Form(None),
|
||||
application_id: str = Form(None)):
|
||||
|
||||
user = get_current_user(request)
|
||||
if not user:
|
||||
return HTMLResponse("<p>Non autorise</p>")
|
||||
from ..dependencies import get_user_perms, can_edit
|
||||
if not can_edit(get_user_perms(db, user), "servers"):
|
||||
return HTMLResponse("<p>Permission refusee</p>", status_code=403)
|
||||
|
||||
data = {
|
||||
"domain_code": domain_code, "env_code": env_code, "zone": zone,
|
||||
@ -133,11 +161,47 @@ async def server_update(request: Request, server_id: int, db=Depends(get_db),
|
||||
}
|
||||
update_server(db, server_id, data, user.get("sub"))
|
||||
|
||||
# Application (changement manuel SecOps) — update + push iTop
|
||||
if application_id is not None:
|
||||
app_id_val = int(application_id) if application_id and application_id.strip().isdigit() else None
|
||||
app_itop_id = None
|
||||
app_name = None
|
||||
if app_id_val:
|
||||
row = db.execute(text("SELECT itop_id, nom_court FROM applications WHERE id=:id"),
|
||||
{"id": app_id_val}).fetchone()
|
||||
if row:
|
||||
app_itop_id = row.itop_id
|
||||
app_name = row.nom_court
|
||||
db.execute(text("""UPDATE servers SET application_id=:aid, application_name=:an, updated_at=NOW()
|
||||
WHERE id=:sid"""), {"aid": app_id_val, "an": app_name, "sid": server_id})
|
||||
db.commit()
|
||||
# Push iTop (best effort)
|
||||
try:
|
||||
from ..services.itop_service import ITopClient
|
||||
from ..services.secrets_service import get_secret
|
||||
srv_row = db.execute(text("SELECT hostname FROM servers WHERE id=:id"), {"id": server_id}).fetchone()
|
||||
if srv_row:
|
||||
url = get_secret(db, "itop_url")
|
||||
u = get_secret(db, "itop_user")
|
||||
p = get_secret(db, "itop_pass")
|
||||
if url and u and p:
|
||||
client = ITopClient(url, u, p)
|
||||
r = client._call("core/get", **{"class": "VirtualMachine",
|
||||
"key": f'SELECT VirtualMachine WHERE name = "{srv_row.hostname}"', "output_fields": "name"})
|
||||
if r.get("objects"):
|
||||
vm_id = list(r["objects"].values())[0]["key"]
|
||||
new_list = [{"applicationsolution_id": int(app_itop_id)}] if app_itop_id else []
|
||||
client.update("VirtualMachine", vm_id, {"applicationsolution_list": new_list})
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
s = get_server_full(db, server_id)
|
||||
tags = get_server_tags(db, s.qid)
|
||||
ips = get_server_ips(db, server_id)
|
||||
from ..services.correspondance_service import get_server_links
|
||||
links = get_server_links(db, server_id)
|
||||
return templates.TemplateResponse("partials/server_detail.html", {
|
||||
"request": request, "s": s, "tags": tags, "ips": ips
|
||||
"request": request, "s": s, "tags": tags, "ips": ips, "links": links
|
||||
})
|
||||
|
||||
|
||||
@ -148,6 +212,9 @@ async def servers_bulk(request: Request, db=Depends(get_db),
|
||||
user = get_current_user(request)
|
||||
if not user:
|
||||
return RedirectResponse(url="/login")
|
||||
from ..dependencies import get_user_perms, can_edit
|
||||
if not can_edit(get_user_perms(db, user), "servers"):
|
||||
return RedirectResponse(url="/servers?msg=forbidden", status_code=303)
|
||||
if not server_ids or not bulk_field or not bulk_value:
|
||||
return RedirectResponse(url="/servers", status_code=303)
|
||||
|
||||
@ -203,7 +270,9 @@ async def server_sync_qualys(request: Request, server_id: int, db=Depends(get_db
|
||||
s = get_server_full(db, server_id)
|
||||
tags = get_server_tags(db, s.qid) if s else []
|
||||
ips = get_server_ips(db, server_id)
|
||||
from ..services.correspondance_service import get_server_links
|
||||
links = get_server_links(db, server_id) if s else {"as_prod": [], "as_nonprod": []}
|
||||
return templates.TemplateResponse("partials/server_detail.html", {
|
||||
"request": request, "s": s, "tags": tags, "ips": ips,
|
||||
"request": request, "s": s, "tags": tags, "ips": ips, "links": links,
|
||||
"sync_msg": result.get("msg"), "sync_ok": result.get("ok"),
|
||||
})
|
||||
|
||||
@ -75,6 +75,20 @@ SECTIONS = {
|
||||
("teams_sp_client_secret", "App Client Secret", True),
|
||||
("teams_sp_tenant_id", "Tenant ID", False),
|
||||
],
|
||||
"ldap": [
|
||||
("ldap_enabled", "Activer LDAP/AD (true/false)", False),
|
||||
("ldap_server", "Serveur (ex: ldaps://ad.sanef.com:636)", False),
|
||||
("ldap_base_dn", "Base DN (ex: DC=sanef,DC=com)", False),
|
||||
("ldap_bind_dn", "Compte de bind (DN complet)", False),
|
||||
("ldap_bind_pwd", "Mot de passe compte de bind", True),
|
||||
("ldap_user_filter", "Filtre user (ex: (sAMAccountName={username}))", False),
|
||||
("ldap_email_attr", "Attribut email (ex: mail)", False),
|
||||
("ldap_name_attr", "Attribut nom affiché (ex: displayName)", False),
|
||||
("ldap_tls", "TLS (true/false)", False),
|
||||
],
|
||||
"itop_contacts": [
|
||||
("itop_contact_teams", "Teams iTop à synchroniser (séparées par ,)", False),
|
||||
],
|
||||
}
|
||||
|
||||
|
||||
@ -102,6 +116,8 @@ SECTION_ACCESS = {
|
||||
"splunk": {"visible": ["admin", "coordinator"], "editable": ["admin", "coordinator"]},
|
||||
"teams": {"visible": ["admin", "coordinator"], "editable": ["admin", "coordinator"]},
|
||||
"itop": {"visible": ["admin"], "editable": ["admin"]},
|
||||
"itop_contacts": {"visible": ["admin"], "editable": ["admin"]},
|
||||
"ldap": {"visible": ["admin"], "editable": ["admin"]},
|
||||
"security": {"visible": ["admin"], "editable": ["admin"]},
|
||||
}
|
||||
|
||||
@ -173,6 +189,20 @@ async def settings_save(request: Request, section: str, db=Depends(get_db)):
|
||||
return templates.TemplateResponse("settings.html", ctx)
|
||||
|
||||
|
||||
@router.post("/settings/ldap/test")
|
||||
async def settings_ldap_test(request: Request, db=Depends(get_db)):
|
||||
"""Teste la connexion LDAP avec le compte de bind."""
|
||||
from fastapi.responses import JSONResponse
|
||||
user = get_current_user(request)
|
||||
if not user:
|
||||
return JSONResponse({"ok": False, "msg": "Non authentifié"}, status_code=401)
|
||||
perms = get_user_perms(db, user)
|
||||
if not can_edit(perms, "settings"):
|
||||
return JSONResponse({"ok": False, "msg": "Permission refusée"}, status_code=403)
|
||||
from ..services.ldap_service import test_connection
|
||||
return JSONResponse(test_connection(db))
|
||||
|
||||
|
||||
# --- vCenter CRUD ---
|
||||
|
||||
@router.post("/settings/vcenter/add", response_class=HTMLResponse)
|
||||
|
||||
@ -1,180 +1,273 @@
|
||||
"""Router users — gestion utilisateurs + permissions par module"""
|
||||
"""Router users — gestion utilisateurs par profil + picker de contacts iTop.
|
||||
|
||||
Seul l'admin peut gérer les utilisateurs.
|
||||
Les utilisateurs sont créés depuis les contacts synchronisés d'iTop (sauf admin local).
|
||||
"""
|
||||
from fastapi import APIRouter, Request, Depends, Form
|
||||
from fastapi.responses import HTMLResponse, RedirectResponse
|
||||
from fastapi.templating import Jinja2Templates
|
||||
from sqlalchemy import text
|
||||
from ..dependencies import get_db, get_current_user, get_user_perms, can_view, can_edit, can_admin, base_context
|
||||
from ..auth import hash_password
|
||||
from ..services.profile_service import PROFILES, PROFILE_LABELS, PROFILE_DESCRIPTIONS
|
||||
from ..config import APP_NAME
|
||||
|
||||
router = APIRouter()
|
||||
templates = Jinja2Templates(directory="app/templates")
|
||||
|
||||
MODULES = ["servers", "campaigns", "qualys", "audit", "settings", "users", "planning", "specifics"]
|
||||
LEVELS = ["view", "edit", "admin"]
|
||||
# Profils disponibles (keys du PROFILES dict)
|
||||
ROLES = ["admin", "coordinator", "operator", "viewer"]
|
||||
|
||||
|
||||
def _get_users_with_perms(db):
|
||||
users = db.execute(text(
|
||||
"SELECT id, username, display_name, email, role, auth_type, is_active, last_login FROM users ORDER BY username"
|
||||
)).fetchall()
|
||||
result = []
|
||||
for u in users:
|
||||
perms = {}
|
||||
rows = db.execute(text(
|
||||
"SELECT module, level FROM user_permissions WHERE user_id = :uid"
|
||||
), {"uid": u.id}).fetchall()
|
||||
for r in rows:
|
||||
perms[r.module] = r.level
|
||||
result.append({"user": u, "perms": perms})
|
||||
return result
|
||||
def _get_users(db):
|
||||
users = db.execute(text("""
|
||||
SELECT u.id, u.username, u.display_name, u.email, u.role, u.auth_type,
|
||||
u.is_active, u.last_login, u.itop_person_id, u.force_password_change,
|
||||
c.team as contact_team, c.function as contact_function
|
||||
FROM users u
|
||||
LEFT JOIN contacts c ON c.itop_id = u.itop_person_id
|
||||
ORDER BY u.is_active DESC, u.username
|
||||
""")).fetchall()
|
||||
return users
|
||||
|
||||
|
||||
def _check_access(request, db):
|
||||
def _check_access(request, db, require_edit=False):
|
||||
user = get_current_user(request)
|
||||
if not user:
|
||||
return None, None, RedirectResponse(url="/login")
|
||||
perms = get_user_perms(db, user)
|
||||
if not can_view(perms, "users"):
|
||||
return None, None, RedirectResponse(url="/dashboard")
|
||||
if require_edit and not can_edit(perms, "users"):
|
||||
return None, None, RedirectResponse(url="/users?msg=forbidden", status_code=303)
|
||||
return user, perms, None
|
||||
|
||||
|
||||
@router.get("/me/change-password", response_class=HTMLResponse)
|
||||
async def me_change_password_page(request: Request, db=Depends(get_db)):
|
||||
user = get_current_user(request)
|
||||
if not user:
|
||||
return RedirectResponse(url="/login")
|
||||
return templates.TemplateResponse("change_password.html", {
|
||||
"request": request, "app_name": APP_NAME, "user": user,
|
||||
"error": None, "perms": {},
|
||||
})
|
||||
|
||||
|
||||
@router.post("/me/change-password")
|
||||
async def me_change_password(request: Request, db=Depends(get_db),
|
||||
current_password: str = Form(...),
|
||||
new_password: str = Form(...),
|
||||
confirm_password: str = Form(...)):
|
||||
user = get_current_user(request)
|
||||
if not user:
|
||||
return RedirectResponse(url="/login")
|
||||
|
||||
from ..auth import verify_password
|
||||
row = db.execute(text("SELECT id, password_hash FROM users WHERE id=:uid"),
|
||||
{"uid": user.get("uid")}).fetchone()
|
||||
if not row:
|
||||
return RedirectResponse(url="/logout", status_code=303)
|
||||
|
||||
err = None
|
||||
if not verify_password(current_password, row.password_hash or ""):
|
||||
err = "Mot de passe actuel incorrect"
|
||||
elif new_password != confirm_password:
|
||||
err = "Les deux mots de passe ne correspondent pas"
|
||||
elif len(new_password) < 8:
|
||||
err = "Le nouveau mot de passe doit faire au moins 8 caractères"
|
||||
elif new_password == current_password:
|
||||
err = "Le nouveau mot de passe doit être différent de l'actuel"
|
||||
|
||||
if err:
|
||||
return templates.TemplateResponse("change_password.html", {
|
||||
"request": request, "app_name": APP_NAME, "user": user,
|
||||
"error": err, "perms": {},
|
||||
})
|
||||
|
||||
pw_hash = hash_password(new_password)
|
||||
db.execute(text("""UPDATE users SET password_hash=:ph, force_password_change=false,
|
||||
updated_at=NOW() WHERE id=:uid"""),
|
||||
{"ph": pw_hash, "uid": user.get("uid")})
|
||||
db.commit()
|
||||
return RedirectResponse(url="/dashboard?msg=password_changed", status_code=303)
|
||||
|
||||
|
||||
@router.get("/users", response_class=HTMLResponse)
|
||||
async def users_page(request: Request, db=Depends(get_db)):
|
||||
user, perms, redirect = _check_access(request, db)
|
||||
if redirect:
|
||||
return redirect
|
||||
users_data = _get_users_with_perms(db)
|
||||
|
||||
users = _get_users(db)
|
||||
|
||||
# Compter les contacts disponibles pour ajout (pas encore users)
|
||||
available_count = db.execute(text("""
|
||||
SELECT COUNT(*) FROM contacts c
|
||||
WHERE c.itop_id IS NOT NULL AND c.is_active = true
|
||||
AND NOT EXISTS (SELECT 1 FROM users u WHERE u.itop_person_id = c.itop_id)
|
||||
""")).scalar()
|
||||
|
||||
ctx = base_context(request, db, user)
|
||||
ctx.update({
|
||||
"app_name": APP_NAME, "users_data": users_data,
|
||||
"modules": MODULES, "levels": LEVELS,
|
||||
"app_name": APP_NAME, "users": users,
|
||||
"available_count": available_count,
|
||||
"roles": ROLES, "profile_labels": PROFILE_LABELS,
|
||||
"profile_descriptions": PROFILE_DESCRIPTIONS,
|
||||
"can_edit_users": can_edit(perms, "users"),
|
||||
"can_admin_users": can_admin(perms, "users"),
|
||||
"msg": request.query_params.get("msg"),
|
||||
})
|
||||
return templates.TemplateResponse("users.html", ctx)
|
||||
|
||||
|
||||
@router.post("/users/add")
|
||||
async def user_add(request: Request, db=Depends(get_db),
|
||||
new_username: str = Form(...), new_display_name: str = Form(...),
|
||||
new_email: str = Form(""), new_password: str = Form(...),
|
||||
new_role: str = Form("operator")):
|
||||
user, perms, redirect = _check_access(request, db)
|
||||
@router.get("/users/add", response_class=HTMLResponse)
|
||||
async def users_add_page(request: Request, db=Depends(get_db),
|
||||
search: str = "", team: str = ""):
|
||||
user, perms, redirect = _check_access(request, db, require_edit=True)
|
||||
if redirect:
|
||||
return redirect
|
||||
if not can_edit(perms, "users"):
|
||||
return RedirectResponse(url="/users?msg=forbidden", status_code=303)
|
||||
|
||||
# Verifier si username existe deja
|
||||
existing = db.execute(text("SELECT id, is_active FROM users WHERE LOWER(username) = LOWER(:u)"),
|
||||
{"u": new_username.strip()}).fetchone()
|
||||
# Lister les contacts iTop non-encore-users
|
||||
where = ["c.itop_id IS NOT NULL", "c.is_active = true",
|
||||
"NOT EXISTS (SELECT 1 FROM users u WHERE u.itop_person_id = c.itop_id)"]
|
||||
params = {}
|
||||
if search:
|
||||
where.append("(c.name ILIKE :s OR c.email ILIKE :s)")
|
||||
params["s"] = f"%{search}%"
|
||||
if team:
|
||||
where.append("c.team = :t")
|
||||
params["t"] = team
|
||||
|
||||
wc = " AND ".join(where)
|
||||
contacts = db.execute(text(f"""
|
||||
SELECT c.id, c.itop_id, c.name, c.email, c.telephone, c.team, c.function, c.role
|
||||
FROM contacts c WHERE {wc}
|
||||
ORDER BY c.team, c.name LIMIT 200
|
||||
"""), params).fetchall()
|
||||
|
||||
# Liste des teams disponibles pour filtrer
|
||||
teams = db.execute(text("""
|
||||
SELECT DISTINCT team FROM contacts WHERE team IS NOT NULL AND team != ''
|
||||
ORDER BY team
|
||||
""")).fetchall()
|
||||
|
||||
ctx = base_context(request, db, user)
|
||||
ctx.update({
|
||||
"app_name": APP_NAME, "contacts": contacts, "teams": teams,
|
||||
"roles": ROLES, "profile_labels": PROFILE_LABELS,
|
||||
"profile_descriptions": PROFILE_DESCRIPTIONS,
|
||||
"search": search, "team_filter": team,
|
||||
})
|
||||
return templates.TemplateResponse("users_add.html", ctx)
|
||||
|
||||
|
||||
@router.post("/users/add")
|
||||
async def users_add(request: Request, db=Depends(get_db),
|
||||
contact_id: str = Form(""), role: str = Form("viewer"),
|
||||
username: str = Form(""), password: str = Form(""),
|
||||
auth_type: str = Form("local"),
|
||||
force_change: str = Form("")):
|
||||
user, perms, redirect = _check_access(request, db, require_edit=True)
|
||||
if redirect:
|
||||
return redirect
|
||||
if role not in ROLES:
|
||||
return RedirectResponse(url="/users?msg=invalid_role", status_code=303)
|
||||
|
||||
# Récupérer le contact iTop
|
||||
contact = None
|
||||
if contact_id and contact_id.strip().isdigit():
|
||||
contact = db.execute(text(
|
||||
"SELECT id, itop_id, name, email FROM contacts WHERE id = :cid"
|
||||
), {"cid": int(contact_id)}).fetchone()
|
||||
if not contact:
|
||||
return RedirectResponse(url="/users/add?msg=contact_required", status_code=303)
|
||||
|
||||
# Générer username si non fourni (email avant @)
|
||||
if not username.strip():
|
||||
username = contact.email.split("@")[0] if contact.email else contact.name.lower().replace(" ", ".")
|
||||
|
||||
# Verifier duplicats
|
||||
existing = db.execute(text("SELECT id FROM users WHERE LOWER(username)=LOWER(:u) OR itop_person_id=:iid"),
|
||||
{"u": username.strip(), "iid": contact.itop_id}).fetchone()
|
||||
if existing:
|
||||
if not existing.is_active:
|
||||
return RedirectResponse(url=f"/users?msg=exists_inactive", status_code=303)
|
||||
return RedirectResponse(url=f"/users?msg=exists", status_code=303)
|
||||
return RedirectResponse(url="/users?msg=exists", status_code=303)
|
||||
|
||||
pw_hash = hash_password(password) if password else hash_password("ChangeMe!" + str(contact.itop_id))
|
||||
fpc = True if force_change == "on" else (not password)
|
||||
|
||||
pw_hash = hash_password(new_password)
|
||||
db.execute(text("""
|
||||
INSERT INTO users (username, display_name, email, password_hash, role)
|
||||
VALUES (:u, :dn, :e, :ph, :r)
|
||||
"""), {"u": new_username.strip(), "dn": new_display_name, "e": new_email or None,
|
||||
"ph": pw_hash, "r": new_role})
|
||||
|
||||
row = db.execute(text("SELECT id FROM users WHERE username = :u"), {"u": new_username.strip()}).fetchone()
|
||||
if row:
|
||||
default_perms = {
|
||||
"admin": {m: "admin" for m in MODULES},
|
||||
"coordinator": {"servers": "admin", "campaigns": "admin", "qualys": "admin", "audit": "admin",
|
||||
"settings": "view", "users": "view", "planning": "admin", "specifics": "admin"},
|
||||
"operator": {"servers": "admin", "campaigns": "view", "qualys": "admin", "audit": "admin",
|
||||
"settings": "view", "planning": "view", "specifics": "admin"},
|
||||
"viewer": {"servers": "view", "campaigns": "view", "audit": "view", "planning": "view"},
|
||||
}
|
||||
for mod, lvl in default_perms.get(new_role, {}).items():
|
||||
db.execute(text(
|
||||
"INSERT INTO user_permissions (user_id, module, level) VALUES (:uid, :m, :l) ON CONFLICT DO NOTHING"
|
||||
), {"uid": row.id, "m": mod, "l": lvl})
|
||||
|
||||
INSERT INTO users (username, display_name, email, password_hash, role,
|
||||
auth_type, itop_person_id, is_active, force_password_change)
|
||||
VALUES (:u, :dn, :e, :ph, :r, :at, :iid, true, :fpc)
|
||||
"""), {
|
||||
"u": username.strip(), "dn": contact.name, "e": contact.email,
|
||||
"ph": pw_hash, "r": role, "at": auth_type,
|
||||
"iid": contact.itop_id, "fpc": fpc,
|
||||
})
|
||||
db.commit()
|
||||
return RedirectResponse(url="/users?msg=added", status_code=303)
|
||||
|
||||
|
||||
@router.post("/users/{user_id}/permissions")
|
||||
async def user_permissions_save(request: Request, user_id: int, db=Depends(get_db)):
|
||||
user, perms, redirect = _check_access(request, db)
|
||||
@router.post("/users/{user_id}/role")
|
||||
async def user_change_role(request: Request, user_id: int, db=Depends(get_db),
|
||||
role: str = Form(...)):
|
||||
user, perms, redirect = _check_access(request, db, require_edit=True)
|
||||
if redirect:
|
||||
return redirect
|
||||
if not can_edit(perms, "users"):
|
||||
return RedirectResponse(url="/users?msg=forbidden", status_code=303)
|
||||
|
||||
form = await request.form()
|
||||
db.execute(text("DELETE FROM user_permissions WHERE user_id = :uid"), {"uid": user_id})
|
||||
for mod in MODULES:
|
||||
lvl = form.get(f"perm_{mod}", "")
|
||||
if lvl and lvl in LEVELS:
|
||||
db.execute(text(
|
||||
"INSERT INTO user_permissions (user_id, module, level) VALUES (:uid, :m, :l)"
|
||||
), {"uid": user_id, "m": mod, "l": lvl})
|
||||
if role not in ROLES:
|
||||
return RedirectResponse(url="/users?msg=invalid_role", status_code=303)
|
||||
# Empêche un admin de se rétrograder lui-même
|
||||
if user_id == user.get("uid") and role != "admin":
|
||||
return RedirectResponse(url="/users?msg=cant_demote_self", status_code=303)
|
||||
db.execute(text("UPDATE users SET role=:r, updated_at=NOW() WHERE id=:id"),
|
||||
{"r": role, "id": user_id})
|
||||
db.commit()
|
||||
return RedirectResponse(url=f"/users?msg=perms_saved", status_code=303)
|
||||
|
||||
|
||||
@router.post("/users/{user_id}/edit")
|
||||
async def user_edit(request: Request, user_id: int, db=Depends(get_db),
|
||||
display_name: str = Form(""), email: str = Form(""),
|
||||
role: str = Form("")):
|
||||
user, perms, redirect = _check_access(request, db)
|
||||
if redirect:
|
||||
return redirect
|
||||
if not can_edit(perms, "users"):
|
||||
return RedirectResponse(url="/users?msg=forbidden", status_code=303)
|
||||
|
||||
updates = []
|
||||
params = {"id": user_id}
|
||||
if display_name:
|
||||
updates.append("display_name = :dn"); params["dn"] = display_name
|
||||
if email:
|
||||
updates.append("email = :em"); params["em"] = email
|
||||
if role:
|
||||
updates.append("role = :r"); params["r"] = role
|
||||
if updates:
|
||||
db.execute(text(f"UPDATE users SET {', '.join(updates)} WHERE id = :id"), params)
|
||||
db.commit()
|
||||
return RedirectResponse(url="/users?msg=edited", status_code=303)
|
||||
return RedirectResponse(url="/users?msg=role_changed", status_code=303)
|
||||
|
||||
|
||||
@router.post("/users/{user_id}/toggle")
|
||||
async def user_toggle(request: Request, user_id: int, db=Depends(get_db)):
|
||||
user, perms, redirect = _check_access(request, db)
|
||||
user, perms, redirect = _check_access(request, db, require_edit=True)
|
||||
if redirect:
|
||||
return redirect
|
||||
if not can_edit(perms, "users"):
|
||||
return RedirectResponse(url="/users?msg=forbidden", status_code=303)
|
||||
# Empecher de se desactiver soi-meme
|
||||
if user_id == user.get("uid"):
|
||||
return RedirectResponse(url="/users?msg=cant_self", status_code=303)
|
||||
db.execute(text("UPDATE users SET is_active = NOT is_active WHERE id = :id"), {"id": user_id})
|
||||
db.execute(text("UPDATE users SET is_active = NOT is_active WHERE id=:id"), {"id": user_id})
|
||||
db.commit()
|
||||
return RedirectResponse(url="/users?msg=toggled", status_code=303)
|
||||
|
||||
|
||||
@router.post("/users/{user_id}/password")
|
||||
async def user_password(request: Request, user_id: int, db=Depends(get_db),
|
||||
new_password: str = Form(...)):
|
||||
user, perms, redirect = _check_access(request, db)
|
||||
new_password: str = Form(...),
|
||||
force_change: str = Form("")):
|
||||
user, perms, redirect = _check_access(request, db, require_edit=True)
|
||||
if redirect:
|
||||
return redirect
|
||||
if not can_edit(perms, "users"):
|
||||
return RedirectResponse(url="/users?msg=forbidden", status_code=303)
|
||||
pw_hash = hash_password(new_password)
|
||||
db.execute(text("UPDATE users SET password_hash = :ph WHERE id = :id"),
|
||||
{"ph": pw_hash, "id": user_id})
|
||||
fpc = (force_change == "on")
|
||||
db.execute(text("""UPDATE users SET password_hash=:ph, force_password_change=:fpc,
|
||||
updated_at=NOW() WHERE id=:id"""),
|
||||
{"ph": pw_hash, "fpc": fpc, "id": user_id})
|
||||
db.commit()
|
||||
return RedirectResponse(url="/users?msg=password_changed", status_code=303)
|
||||
|
||||
|
||||
@router.post("/users/{user_id}/auth_type")
|
||||
async def user_auth_type(request: Request, user_id: int, db=Depends(get_db),
|
||||
auth_type: str = Form(...)):
|
||||
user, perms, redirect = _check_access(request, db, require_edit=True)
|
||||
if redirect:
|
||||
return redirect
|
||||
if auth_type not in ("local", "ldap"):
|
||||
return RedirectResponse(url="/users?msg=invalid", status_code=303)
|
||||
db.execute(text("UPDATE users SET auth_type=:at, updated_at=NOW() WHERE id=:id"),
|
||||
{"at": auth_type, "id": user_id})
|
||||
db.commit()
|
||||
return RedirectResponse(url="/users?msg=auth_changed", status_code=303)
|
||||
|
||||
|
||||
@router.post("/users/{user_id}/delete")
|
||||
async def user_delete(request: Request, user_id: int, db=Depends(get_db)):
|
||||
user, perms, redirect = _check_access(request, db)
|
||||
@ -184,7 +277,11 @@ async def user_delete(request: Request, user_id: int, db=Depends(get_db)):
|
||||
return RedirectResponse(url="/users?msg=forbidden", status_code=303)
|
||||
if user_id == user.get("uid"):
|
||||
return RedirectResponse(url="/users?msg=cant_self", status_code=303)
|
||||
db.execute(text("DELETE FROM user_permissions WHERE user_id = :uid"), {"uid": user_id})
|
||||
db.execute(text("DELETE FROM users WHERE id = :id"), {"id": user_id})
|
||||
# Protéger l'admin local (id=1)
|
||||
row = db.execute(text("SELECT username, auth_type FROM users WHERE id=:id"), {"id": user_id}).fetchone()
|
||||
if row and row.username == "admin" and row.auth_type == "local":
|
||||
return RedirectResponse(url="/users?msg=cant_delete_admin", status_code=303)
|
||||
db.execute(text("DELETE FROM user_permissions WHERE user_id=:uid"), {"uid": user_id})
|
||||
db.execute(text("DELETE FROM users WHERE id=:id"), {"id": user_id})
|
||||
db.commit()
|
||||
return RedirectResponse(url="/users?msg=deleted", status_code=303)
|
||||
|
||||
@ -80,11 +80,21 @@ def check_agent(hostname, ssh_user, ssh_key_path, ssh_port=22):
|
||||
|
||||
result = {"hostname": hostname}
|
||||
|
||||
# Check if installed
|
||||
code, out, _ = _run_cmd(client, "which qualys-cloud-agent 2>/dev/null || rpm -q qualys-cloud-agent 2>/dev/null || dpkg -l qualys-cloud-agent 2>/dev/null | grep '^ii'")
|
||||
if code != 0 and not out.strip():
|
||||
# Check if installed (rpm -q returns 0 only if installed, dpkg -s returns 0 only if installed)
|
||||
installed = False
|
||||
code, out, _ = _run_cmd(client, "rpm -q qualys-cloud-agent 2>/dev/null")
|
||||
if code == 0 and "not installed" not in out.lower() and "n'est pas install" not in out.lower():
|
||||
installed = True
|
||||
else:
|
||||
code, out, _ = _run_cmd(client, "dpkg -s qualys-cloud-agent 2>/dev/null")
|
||||
if code == 0 and "install ok installed" in out.lower():
|
||||
installed = True
|
||||
|
||||
if not installed:
|
||||
result["status"] = "NOT_INSTALLED"
|
||||
result["detail"] = "Agent non installe"
|
||||
result["version"] = ""
|
||||
result["service_status"] = ""
|
||||
client.close()
|
||||
return result
|
||||
|
||||
@ -94,9 +104,8 @@ def check_agent(hostname, ssh_user, ssh_key_path, ssh_port=22):
|
||||
result["service_status"] = status
|
||||
|
||||
# Get version via package manager
|
||||
code, out, _ = _run_cmd(client, "rpm -q qualys-cloud-agent 2>/dev/null || dpkg -l qualys-cloud-agent 2>/dev/null | grep '^ii' | awk '{print $3}'")
|
||||
code, out, _ = _run_cmd(client, "rpm -q --qf '%{VERSION}-%{RELEASE}' qualys-cloud-agent 2>/dev/null || dpkg-query -W -f='${Version}' qualys-cloud-agent 2>/dev/null")
|
||||
version = out.strip()
|
||||
# Extract just version number
|
||||
m = re.search(r'(\d+\.\d+\.\d+[\.\-]\d+)', version)
|
||||
result["version"] = m.group(1) if m else version[:50]
|
||||
|
||||
@ -118,65 +127,129 @@ def check_agent(hostname, ssh_user, ssh_key_path, ssh_port=22):
|
||||
return result
|
||||
|
||||
|
||||
def _compare_versions(v1, v2):
|
||||
"""Compare deux versions. Retourne -1 (v1<v2), 0 (egales), 1 (v1>v2)"""
|
||||
def to_parts(v):
|
||||
return [int(x) for x in re.split(r'[.\-]', v) if x.isdigit()]
|
||||
p1, p2 = to_parts(v1 or ""), to_parts(v2 or "")
|
||||
for a, b in zip(p1, p2):
|
||||
if a < b: return -1
|
||||
if a > b: return 1
|
||||
if len(p1) < len(p2): return -1
|
||||
if len(p1) > len(p2): return 1
|
||||
return 0
|
||||
|
||||
|
||||
def deploy_agent(hostname, ssh_user, ssh_key_path, ssh_port, os_family,
|
||||
package_path, activation_id, customer_id, server_uri,
|
||||
on_line=None):
|
||||
"""Deploie l'agent Qualys sur un serveur"""
|
||||
on_line=None, on_stage=None, force_downgrade=False):
|
||||
"""Deploie l'agent Qualys sur un serveur (install, upgrade ou downgrade)"""
|
||||
|
||||
def emit(msg):
|
||||
if on_line:
|
||||
on_line(msg)
|
||||
log.info(f"[{hostname}] {msg}")
|
||||
|
||||
def stage(name, detail=""):
|
||||
if on_stage:
|
||||
on_stage(name, detail)
|
||||
|
||||
stage("connecting", f"Connexion SSH {ssh_user}@{hostname}:{ssh_port}")
|
||||
emit(f"Connexion SSH {ssh_user}@{hostname}:{ssh_port}...")
|
||||
client, error = _get_ssh_client(hostname, ssh_user, ssh_key_path, ssh_port)
|
||||
if not client:
|
||||
emit(f"ERREUR connexion: {error}")
|
||||
stage("failed", error)
|
||||
return {"hostname": hostname, "status": "FAILED", "detail": error}
|
||||
|
||||
result = {"hostname": hostname, "status": "PENDING"}
|
||||
pkg_name = os.path.basename(package_path)
|
||||
pkg_version = _extract_version(pkg_name)
|
||||
is_deb = pkg_name.endswith(".deb")
|
||||
|
||||
try:
|
||||
# 1. Detect OS if not provided
|
||||
if not os_family:
|
||||
code, out, _ = _run_cmd(client, "cat /etc/os-release 2>/dev/null | head -3")
|
||||
os_family = "linux" # default
|
||||
os_family = "linux"
|
||||
if "debian" in out.lower() or "ubuntu" in out.lower():
|
||||
os_family = "debian"
|
||||
elif "red hat" in out.lower() or "centos" in out.lower() or "rocky" in out.lower():
|
||||
os_family = "rhel"
|
||||
emit(f"OS detecte: {os_family}")
|
||||
|
||||
# 2. Check if already installed
|
||||
code, out, _ = _run_cmd(client, "systemctl is-active qualys-cloud-agent 2>/dev/null")
|
||||
if out.strip() == "active":
|
||||
emit("Agent deja installe et actif - skip")
|
||||
result["status"] = "ALREADY_INSTALLED"
|
||||
result["detail"] = "Agent deja actif"
|
||||
client.close()
|
||||
return result
|
||||
# 2. Check installed version
|
||||
stage("checking", "Verification agent existant")
|
||||
installed_version = None
|
||||
code, out, _ = _run_cmd(client, "rpm -q qualys-cloud-agent 2>/dev/null")
|
||||
if code == 0 and "not installed" not in out.lower() and "n'est pas install" not in out.lower():
|
||||
m = re.search(r'(\d+\.\d+\.\d+[\.\-]\d+)', out)
|
||||
installed_version = m.group(1) if m else None
|
||||
else:
|
||||
code, out, _ = _run_cmd(client, "dpkg-query -W -f='${Version}' qualys-cloud-agent 2>/dev/null")
|
||||
if code == 0 and out.strip():
|
||||
m = re.search(r'(\d+\.\d+\.\d+[\.\-]\d+)', out)
|
||||
installed_version = m.group(1) if m else None
|
||||
|
||||
if installed_version:
|
||||
cmp = _compare_versions(pkg_version, installed_version)
|
||||
emit(f"Version installee: {installed_version}, package: {pkg_version}")
|
||||
if cmp == 0:
|
||||
# Meme version
|
||||
code, out, _ = _run_cmd(client, "systemctl is-active qualys-cloud-agent 2>/dev/null")
|
||||
if out.strip() == "active":
|
||||
emit(f"Meme version ({installed_version}) et agent actif - skip")
|
||||
stage("already_installed", f"v{installed_version} deja active")
|
||||
result["status"] = "ALREADY_INSTALLED"
|
||||
result["detail"] = f"v{installed_version} deja installee et active"
|
||||
client.close()
|
||||
return result
|
||||
else:
|
||||
emit(f"Meme version mais service {out.strip()} - reinstallation")
|
||||
elif cmp < 0:
|
||||
# Downgrade
|
||||
if not force_downgrade:
|
||||
emit(f"DOWNGRADE refuse: {installed_version} → {pkg_version}")
|
||||
stage("downgrade_refused", f"v{installed_version} > v{pkg_version}")
|
||||
result["status"] = "DOWNGRADE_REFUSED"
|
||||
result["detail"] = f"Version installee ({installed_version}) plus recente que le package ({pkg_version}). Cochez 'Forcer le downgrade'."
|
||||
client.close()
|
||||
return result
|
||||
else:
|
||||
emit(f"DOWNGRADE force: {installed_version} → {pkg_version}")
|
||||
else:
|
||||
emit(f"UPGRADE: {installed_version} → {pkg_version}")
|
||||
|
||||
# 3. Copy package
|
||||
pkg_name = os.path.basename(package_path)
|
||||
remote_path = f"/tmp/{pkg_name}"
|
||||
emit(f"Copie {pkg_name} ({os.path.getsize(package_path)//1024//1024} Mo)...")
|
||||
pkg_size = os.path.getsize(package_path) // 1024 // 1024
|
||||
stage("copying", f"Copie {pkg_name} ({pkg_size} Mo)")
|
||||
emit(f"Copie {pkg_name} ({pkg_size} Mo)...")
|
||||
|
||||
sftp = client.open_sftp()
|
||||
sftp.put(package_path, remote_path)
|
||||
sftp.put(package_path, remote_path=f"/tmp/{pkg_name}")
|
||||
sftp.close()
|
||||
emit("Copie terminee")
|
||||
|
||||
# 4. Install
|
||||
is_deb = pkg_name.endswith(".deb")
|
||||
remote_path = f"/tmp/{pkg_name}"
|
||||
|
||||
# 4. Install / Upgrade / Downgrade
|
||||
if is_deb:
|
||||
stage("installing", "Installation (dpkg)")
|
||||
emit("Installation (dpkg)...")
|
||||
code, out, err = _run_cmd(client, f"dpkg --install {remote_path}", sudo=True, timeout=120)
|
||||
else:
|
||||
emit("Installation (rpm)...")
|
||||
code, out, err = _run_cmd(client, f"rpm -ivh --nosignature {remote_path}", sudo=True, timeout=120)
|
||||
if force_downgrade and installed_version:
|
||||
stage("installing", f"Downgrade (rpm) {installed_version} → {pkg_version}")
|
||||
emit(f"Downgrade (rpm --oldpackage)...")
|
||||
code, out, err = _run_cmd(client, f"rpm -Uvh --nosignature --oldpackage {remote_path}", sudo=True, timeout=120)
|
||||
else:
|
||||
stage("installing", "Installation/Upgrade (rpm)")
|
||||
emit("Installation/Upgrade (rpm -Uvh)...")
|
||||
code, out, err = _run_cmd(client, f"rpm -Uvh --nosignature {remote_path}", sudo=True, timeout=120)
|
||||
|
||||
if code != 0 and "already installed" not in (out + err).lower():
|
||||
emit(f"ERREUR installation (code {code}): {err[:200]}")
|
||||
stage("failed", f"Installation echouee: {err[:100]}")
|
||||
result["status"] = "INSTALL_FAILED"
|
||||
result["detail"] = err[:200]
|
||||
client.close()
|
||||
@ -184,6 +257,7 @@ def deploy_agent(hostname, ssh_user, ssh_key_path, ssh_port, os_family,
|
||||
emit("Installation OK")
|
||||
|
||||
# 5. Activate
|
||||
stage("activating", "Activation de l'agent")
|
||||
emit("Activation de l'agent...")
|
||||
activate_cmd = (
|
||||
f"/usr/local/qualys/cloud-agent/bin/qualys-cloud-agent.sh "
|
||||
@ -195,6 +269,7 @@ def deploy_agent(hostname, ssh_user, ssh_key_path, ssh_port, os_family,
|
||||
code, out, err = _run_cmd(client, activate_cmd, sudo=True, timeout=60)
|
||||
if code != 0:
|
||||
emit(f"ERREUR activation (code {code}): {err[:200]}")
|
||||
stage("failed", f"Activation echouee: {err[:100]}")
|
||||
result["status"] = "ACTIVATE_FAILED"
|
||||
result["detail"] = err[:200]
|
||||
client.close()
|
||||
@ -202,17 +277,22 @@ def deploy_agent(hostname, ssh_user, ssh_key_path, ssh_port, os_family,
|
||||
emit("Activation OK")
|
||||
|
||||
# 6. Restart service
|
||||
stage("restarting", "Redemarrage du service")
|
||||
emit("Redemarrage du service...")
|
||||
_run_cmd(client, "systemctl restart qualys-cloud-agent", sudo=True)
|
||||
|
||||
# 7. Verify
|
||||
stage("verifying", "Verification du service")
|
||||
code, out, _ = _run_cmd(client, "systemctl is-active qualys-cloud-agent")
|
||||
action = "Upgrade" if installed_version else "Install"
|
||||
if out.strip() == "active":
|
||||
emit("Agent deploye et actif !")
|
||||
emit(f"Agent {action.lower()} OK et actif !")
|
||||
stage("success", f"{action} OK — v{pkg_version} active")
|
||||
result["status"] = "SUCCESS"
|
||||
result["detail"] = "Agent deploye avec succes"
|
||||
result["detail"] = f"{action} v{pkg_version} OK"
|
||||
else:
|
||||
emit(f"Agent installe mais statut: {out.strip()}")
|
||||
stage("partial", f"Service: {out.strip()}")
|
||||
result["status"] = "PARTIAL"
|
||||
result["detail"] = f"Installe, service: {out.strip()}"
|
||||
|
||||
@ -221,8 +301,108 @@ def deploy_agent(hostname, ssh_user, ssh_key_path, ssh_port, os_family,
|
||||
|
||||
except Exception as e:
|
||||
emit(f"ERREUR: {e}")
|
||||
stage("failed", str(e)[:100])
|
||||
result["status"] = "FAILED"
|
||||
result["detail"] = str(e)[:200]
|
||||
|
||||
client.close()
|
||||
return result
|
||||
|
||||
|
||||
# ═══════════════════════════════════════════════
|
||||
# Background job manager
|
||||
# ═══════════════════════════════════════════════
|
||||
import threading
|
||||
import uuid
|
||||
import time
|
||||
|
||||
_deploy_jobs = {} # job_id -> job dict
|
||||
|
||||
|
||||
def start_deploy_job(servers, ssh_key, package_deb, package_rpm,
|
||||
activation_id, customer_id, server_uri, force_downgrade=False):
|
||||
"""Lance un job de deploiement en arriere-plan. Retourne le job_id."""
|
||||
job_id = str(uuid.uuid4())[:8]
|
||||
job = {
|
||||
"id": job_id,
|
||||
"started_at": time.time(),
|
||||
"total": len(servers),
|
||||
"done": 0,
|
||||
"servers": {},
|
||||
"log": [],
|
||||
"finished": False,
|
||||
}
|
||||
for srv in servers:
|
||||
job["servers"][srv["hostname"]] = {
|
||||
"hostname": srv["hostname"],
|
||||
"stage": "pending",
|
||||
"detail": "En attente",
|
||||
"status": None,
|
||||
"result": None,
|
||||
}
|
||||
_deploy_jobs[job_id] = job
|
||||
|
||||
def _run():
|
||||
threads = []
|
||||
for srv in servers:
|
||||
t = threading.Thread(target=_deploy_one, args=(job, srv, ssh_key,
|
||||
package_deb, package_rpm, activation_id, customer_id, server_uri, force_downgrade))
|
||||
t.daemon = True
|
||||
threads.append(t)
|
||||
t.start()
|
||||
for t in threads:
|
||||
t.join()
|
||||
job["finished"] = True
|
||||
job["finished_at"] = time.time()
|
||||
|
||||
master = threading.Thread(target=_run, daemon=True)
|
||||
master.start()
|
||||
return job_id
|
||||
|
||||
|
||||
def _deploy_one(job, srv, ssh_key, package_deb, package_rpm,
|
||||
activation_id, customer_id, server_uri, force_downgrade=False):
|
||||
hostname = srv["hostname"]
|
||||
|
||||
def on_stage(stage, detail=""):
|
||||
job["servers"][hostname]["stage"] = stage
|
||||
job["servers"][hostname]["detail"] = detail
|
||||
|
||||
def on_line(msg):
|
||||
job["log"].append(f"[{hostname}] {msg}")
|
||||
|
||||
osv = (srv.get("os_version") or "").lower()
|
||||
if any(k in osv for k in ("centos", "red hat", "rhel", "rocky", "oracle", "fedora", "alma")):
|
||||
pkg = package_rpm
|
||||
else:
|
||||
pkg = package_deb
|
||||
|
||||
if not pkg or not os.path.exists(pkg):
|
||||
job["servers"][hostname]["stage"] = "failed"
|
||||
job["servers"][hostname]["detail"] = f"Package introuvable: {pkg}"
|
||||
job["servers"][hostname]["status"] = "FAILED"
|
||||
job["done"] += 1
|
||||
return
|
||||
|
||||
result = deploy_agent(
|
||||
hostname=hostname, ssh_user=srv.get("ssh_user") or "root",
|
||||
ssh_key_path=ssh_key, ssh_port=srv.get("ssh_port") or 22,
|
||||
os_family=srv.get("os_family"), package_path=pkg,
|
||||
activation_id=activation_id, customer_id=customer_id,
|
||||
server_uri=server_uri, on_line=on_line, on_stage=on_stage,
|
||||
force_downgrade=force_downgrade,
|
||||
)
|
||||
job["servers"][hostname]["status"] = result["status"]
|
||||
job["servers"][hostname]["result"] = result
|
||||
job["done"] += 1
|
||||
|
||||
|
||||
def get_deploy_job(job_id):
|
||||
"""Retourne l'etat d'un job de deploiement."""
|
||||
return _deploy_jobs.get(job_id)
|
||||
|
||||
|
||||
def list_deploy_jobs():
|
||||
"""Liste les jobs recents (< 1h)."""
|
||||
now = time.time()
|
||||
return {jid: j for jid, j in _deploy_jobs.items() if now - j["started_at"] < 3600}
|
||||
|
||||
@ -124,7 +124,7 @@ def get_servers_for_planning(db, year, week_number):
|
||||
or_clauses.append("z.name = 'DMZ'")
|
||||
|
||||
where = f"""
|
||||
s.etat = 'en_production' AND s.patch_os_owner = 'secops'
|
||||
s.etat = 'production' AND s.patch_os_owner = 'secops'
|
||||
AND s.licence_support IN ('active', 'els') AND s.os_family = 'linux'
|
||||
AND ({' OR '.join(or_clauses)})
|
||||
"""
|
||||
|
||||
442
app/services/correspondance_service.py
Normal file
442
app/services/correspondance_service.py
Normal file
@ -0,0 +1,442 @@
|
||||
"""Service de correspondance prod ↔ hors-prod + validations post-patching.
|
||||
|
||||
Détection automatique par signature de hostname :
|
||||
- 2ème caractère = environnement SANEF (p=prod, r=recette, t=test, i=preprod, v=validation, d=dev, o=preprod, s=prod)
|
||||
- Signature = hostname avec le 2ème char remplacé par "_"
|
||||
- Tous les hostnames avec la même signature sont candidats correspondants.
|
||||
|
||||
Exceptions (ls-*, sp-*, etc.) : ne sont pas traitées automatiquement.
|
||||
"""
|
||||
import logging
|
||||
from sqlalchemy import text
|
||||
from collections import defaultdict
|
||||
|
||||
log = logging.getLogger(__name__)
|
||||
|
||||
# Lettres prod (un prod pour une signature)
|
||||
PROD_CHARS = {"p", "s"} # p=Production, s=Production secours (à valider)
|
||||
|
||||
# Lettres hors-prod avec label
|
||||
NONPROD_CHARS = {
|
||||
"r": "Recette", "t": "Test", "i": "Pre-production",
|
||||
"v": "Validation", "d": "Developpement", "o": "Pre-production",
|
||||
}
|
||||
|
||||
# Préfixes qui ne suivent PAS la nomenclature
|
||||
EXCEPTION_PREFIXES = ("ls-", "sp")
|
||||
|
||||
|
||||
def _signature(hostname):
|
||||
"""Retourne (signature, env_char) ou (None, None) si non analysable."""
|
||||
hn = (hostname or "").lower().strip()
|
||||
if not hn or len(hn) < 3:
|
||||
return None, None
|
||||
# Préfixes d'exception
|
||||
for pref in EXCEPTION_PREFIXES:
|
||||
if hn.startswith(pref):
|
||||
return None, None
|
||||
# Format standard : X{env_char}YYYYYY
|
||||
env_char = hn[1]
|
||||
if env_char not in PROD_CHARS and env_char not in NONPROD_CHARS:
|
||||
return None, None
|
||||
signature = hn[0] + "_" + hn[2:]
|
||||
return signature, env_char
|
||||
|
||||
|
||||
def detect_correspondances(db, dry_run=False):
|
||||
"""Parcourt tous les serveurs, groupe par signature, crée les liens auto.
|
||||
Ne touche pas aux liens 'manual' ou 'exception' existants.
|
||||
Retourne un dict de stats."""
|
||||
stats = {"signatures": 0, "prod_found": 0, "nonprod_found": 0,
|
||||
"links_created": 0, "links_kept_manual": 0, "orphan_nonprod": 0,
|
||||
"ambiguous": 0, "exceptions": 0}
|
||||
|
||||
# Tous les serveurs actifs (exclut stock/obsolete)
|
||||
rows = db.execute(text("""SELECT id, hostname FROM servers
|
||||
WHERE etat NOT IN ('stock','obsolete') ORDER BY hostname""")).fetchall()
|
||||
|
||||
by_signature = defaultdict(list) # signature -> [(server_id, env_char, hostname)]
|
||||
for r in rows:
|
||||
sig, env = _signature(r.hostname)
|
||||
if sig is None:
|
||||
stats["exceptions"] += 1
|
||||
continue
|
||||
by_signature[sig].append((r.id, env, r.hostname))
|
||||
|
||||
stats["signatures"] = len(by_signature)
|
||||
|
||||
if dry_run:
|
||||
# Préparer plan
|
||||
plan = []
|
||||
for sig, members in by_signature.items():
|
||||
prods = [m for m in members if m[1] in PROD_CHARS]
|
||||
nps = [m for m in members if m[1] in NONPROD_CHARS]
|
||||
if len(prods) == 1 and nps:
|
||||
plan.append({"signature": sig, "prod": prods[0][2],
|
||||
"nonprods": [(n[2], NONPROD_CHARS[n[1]]) for n in nps]})
|
||||
elif len(prods) > 1 and nps:
|
||||
stats["ambiguous"] += 1
|
||||
elif not prods and nps:
|
||||
stats["orphan_nonprod"] += len(nps)
|
||||
stats["plan"] = plan[:50]
|
||||
return stats
|
||||
|
||||
for sig, members in by_signature.items():
|
||||
prods = [m for m in members if m[1] in PROD_CHARS]
|
||||
nonprods = [m for m in members if m[1] in NONPROD_CHARS]
|
||||
stats["prod_found"] += len(prods)
|
||||
stats["nonprod_found"] += len(nonprods)
|
||||
|
||||
if not prods and nonprods:
|
||||
stats["orphan_nonprod"] += len(nonprods)
|
||||
continue
|
||||
|
||||
if len(prods) > 1:
|
||||
stats["ambiguous"] += 1
|
||||
# On n'auto-détecte pas quand plusieurs prods (ambigu)
|
||||
continue
|
||||
|
||||
if len(prods) == 1 and nonprods:
|
||||
prod_id = prods[0][0]
|
||||
for np_id, np_env, np_host in nonprods:
|
||||
env_label = NONPROD_CHARS.get(np_env, "Autre")
|
||||
# Insert si pas déjà présent + pas 'manual' ou 'exception'
|
||||
existing = db.execute(text("""SELECT id, source FROM server_correspondance
|
||||
WHERE prod_server_id=:p AND nonprod_server_id=:n"""),
|
||||
{"p": prod_id, "n": np_id}).fetchone()
|
||||
if existing:
|
||||
if existing.source in ("manual", "exception"):
|
||||
stats["links_kept_manual"] += 1
|
||||
# sinon déjà auto, on skip
|
||||
continue
|
||||
try:
|
||||
db.execute(text("""INSERT INTO server_correspondance
|
||||
(prod_server_id, nonprod_server_id, environment_code, source)
|
||||
VALUES (:p, :n, :env, 'auto')"""),
|
||||
{"p": prod_id, "n": np_id, "env": env_label})
|
||||
stats["links_created"] += 1
|
||||
except Exception as e:
|
||||
db.rollback()
|
||||
|
||||
db.commit()
|
||||
return stats
|
||||
|
||||
|
||||
def get_servers_for_builder(db, search="", app="", domain="", env=""):
|
||||
"""Retourne tous les serveurs matchant les filtres, avec leurs correspondances existantes.
|
||||
Exclut les serveurs en stock / obsolete (décommissionnés, EOL)."""
|
||||
where = ["s.etat NOT IN ('stock','obsolete')"]
|
||||
params = {}
|
||||
if search:
|
||||
where.append("s.hostname ILIKE :s"); params["s"] = f"%{search}%"
|
||||
if app:
|
||||
where.append("s.application_name = :app"); params["app"] = app
|
||||
if domain:
|
||||
where.append("d.name = :dom"); params["dom"] = domain
|
||||
if env:
|
||||
where.append("e.name = :env"); params["env"] = env
|
||||
wc = " AND ".join(where)
|
||||
|
||||
return db.execute(text(f"""
|
||||
SELECT s.id, s.hostname, s.application_name,
|
||||
e.name as env_name, d.name as domain_name, z.name as zone_name,
|
||||
(SELECT COUNT(*) FROM server_correspondance sc WHERE sc.prod_server_id = s.id) as n_as_prod,
|
||||
(SELECT COUNT(*) FROM server_correspondance sc WHERE sc.nonprod_server_id = s.id) as n_as_nonprod
|
||||
FROM servers s
|
||||
LEFT JOIN domain_environments de ON s.domain_env_id = de.id
|
||||
LEFT JOIN environments e ON de.environment_id = e.id
|
||||
LEFT JOIN domains d ON de.domain_id = d.id
|
||||
LEFT JOIN zones z ON s.zone_id = z.id
|
||||
WHERE {wc}
|
||||
ORDER BY e.name, s.hostname
|
||||
LIMIT 500
|
||||
"""), params).fetchall()
|
||||
|
||||
|
||||
def bulk_create_correspondance(db, prod_ids, nonprod_ids, env_labels, user_id):
|
||||
"""Crée toutes les correspondances prod × non-prod.
|
||||
env_labels est un dict {nonprod_id: env_label} optionnel."""
|
||||
created = 0
|
||||
skipped = 0
|
||||
for pid in prod_ids:
|
||||
for npid in nonprod_ids:
|
||||
if pid == npid:
|
||||
continue
|
||||
existing = db.execute(text("""SELECT id FROM server_correspondance
|
||||
WHERE prod_server_id=:p AND nonprod_server_id=:n"""),
|
||||
{"p": pid, "n": npid}).fetchone()
|
||||
if existing:
|
||||
skipped += 1
|
||||
continue
|
||||
env = (env_labels or {}).get(str(npid)) or (env_labels or {}).get(npid) or ""
|
||||
db.execute(text("""INSERT INTO server_correspondance
|
||||
(prod_server_id, nonprod_server_id, environment_code, source, created_by)
|
||||
VALUES (:p, :n, :env, 'manual', :uid)"""),
|
||||
{"p": pid, "n": npid, "env": env, "uid": user_id})
|
||||
created += 1
|
||||
db.commit()
|
||||
return {"created": created, "skipped": skipped}
|
||||
|
||||
|
||||
def get_correspondance_view(db, search="", app="", env=""):
|
||||
"""Vue hiérarchique des correspondances groupées par application.
|
||||
Exclut les serveurs en stock/obsolete."""
|
||||
where = ["s.etat NOT IN ('stock','obsolete')"]
|
||||
params = {}
|
||||
if search:
|
||||
where.append("s.hostname ILIKE :s"); params["s"] = f"%{search}%"
|
||||
if app:
|
||||
where.append("s.application_name = :app"); params["app"] = app
|
||||
if env:
|
||||
where.append("e.name = :env"); params["env"] = env
|
||||
else:
|
||||
# Par défaut : tout ce qui ressemble à prod (Production ou code prod)
|
||||
where.append("(e.name ILIKE '%production%' OR e.code ILIKE '%prod%')")
|
||||
|
||||
wc = " AND ".join(where)
|
||||
|
||||
prods = db.execute(text(f"""
|
||||
SELECT s.id, s.hostname, s.application_name, e.name as env_name,
|
||||
d.name as domain_name
|
||||
FROM servers s
|
||||
LEFT JOIN domain_environments de ON s.domain_env_id = de.id
|
||||
LEFT JOIN environments e ON de.environment_id = e.id
|
||||
LEFT JOIN domains d ON de.domain_id = d.id
|
||||
WHERE {wc}
|
||||
ORDER BY s.application_name, s.hostname
|
||||
"""), params).fetchall()
|
||||
|
||||
results = []
|
||||
for p in prods:
|
||||
corrs = db.execute(text("""SELECT sc.id as corr_id, sc.environment_code, sc.source, sc.note,
|
||||
ns.id as np_id, ns.hostname as np_hostname,
|
||||
(SELECT pv.status FROM patch_validation pv WHERE pv.server_id = ns.id
|
||||
ORDER BY pv.patch_date DESC LIMIT 1) as last_validation_status,
|
||||
(SELECT pv.validated_at FROM patch_validation pv WHERE pv.server_id = ns.id
|
||||
ORDER BY pv.patch_date DESC LIMIT 1) as last_validated_at,
|
||||
(SELECT pv.patch_date FROM patch_validation pv WHERE pv.server_id = ns.id
|
||||
ORDER BY pv.patch_date DESC LIMIT 1) as last_patch_date
|
||||
FROM server_correspondance sc
|
||||
JOIN servers ns ON sc.nonprod_server_id = ns.id
|
||||
WHERE sc.prod_server_id = :pid
|
||||
ORDER BY sc.environment_code, ns.hostname"""), {"pid": p.id}).fetchall()
|
||||
|
||||
# Validation status agrégé du prod
|
||||
# Compter statuts des hors-prod liés
|
||||
n_total = len(corrs)
|
||||
n_ok = sum(1 for c in corrs if c.last_validation_status in ("validated_ok", "forced"))
|
||||
n_pending = sum(1 for c in corrs if c.last_validation_status == "en_attente")
|
||||
n_ko = sum(1 for c in corrs if c.last_validation_status == "validated_ko")
|
||||
|
||||
if n_total == 0:
|
||||
global_status = "no_nonprod" # gris
|
||||
elif n_ko > 0:
|
||||
global_status = "ko"
|
||||
elif n_pending > 0:
|
||||
global_status = "pending"
|
||||
elif n_ok == n_total:
|
||||
global_status = "all_ok"
|
||||
else:
|
||||
global_status = "partial"
|
||||
|
||||
results.append({
|
||||
"prod_id": p.id, "prod_hostname": p.hostname,
|
||||
"application": p.application_name, "domain": p.domain_name,
|
||||
"env": p.env_name,
|
||||
"correspondants": [dict(c._mapping) for c in corrs],
|
||||
"n_total": n_total, "n_ok": n_ok, "n_pending": n_pending, "n_ko": n_ko,
|
||||
"global_status": global_status,
|
||||
})
|
||||
|
||||
return results
|
||||
|
||||
|
||||
def get_server_links(db, server_id):
|
||||
"""Pour un serveur donné, retourne ses liens :
|
||||
- as_prod : liste des hors-prod qui lui sont liés (si ce serveur est prod)
|
||||
- as_nonprod : liste des prod auxquels il est lié (si ce serveur est non-prod)
|
||||
Chaque item : {hostname, env_name, environment_code, source, corr_id}
|
||||
"""
|
||||
as_prod = db.execute(text("""SELECT sc.id as corr_id, sc.environment_code, sc.source,
|
||||
ns.id, ns.hostname, e.name as env_name
|
||||
FROM server_correspondance sc
|
||||
JOIN servers ns ON sc.nonprod_server_id = ns.id
|
||||
LEFT JOIN domain_environments de ON ns.domain_env_id = de.id
|
||||
LEFT JOIN environments e ON de.environment_id = e.id
|
||||
WHERE sc.prod_server_id = :id ORDER BY e.name, ns.hostname"""),
|
||||
{"id": server_id}).fetchall()
|
||||
|
||||
as_nonprod = db.execute(text("""SELECT sc.id as corr_id, sc.environment_code, sc.source,
|
||||
ps.id, ps.hostname, e.name as env_name
|
||||
FROM server_correspondance sc
|
||||
JOIN servers ps ON sc.prod_server_id = ps.id
|
||||
LEFT JOIN domain_environments de ON ps.domain_env_id = de.id
|
||||
LEFT JOIN environments e ON de.environment_id = e.id
|
||||
WHERE sc.nonprod_server_id = :id ORDER BY ps.hostname"""),
|
||||
{"id": server_id}).fetchall()
|
||||
|
||||
return {
|
||||
"as_prod": [dict(r._mapping) for r in as_prod],
|
||||
"as_nonprod": [dict(r._mapping) for r in as_nonprod],
|
||||
}
|
||||
|
||||
|
||||
def get_links_bulk(db, server_ids):
|
||||
"""Pour une liste d'IDs, retourne un dict {server_id: {as_prod: [...], as_nonprod: [...]}}.
|
||||
Optimisé pour affichage en liste (/servers)."""
|
||||
if not server_ids:
|
||||
return {}
|
||||
placeholders = ",".join(str(i) for i in server_ids if str(i).isdigit())
|
||||
if not placeholders:
|
||||
return {}
|
||||
result = {sid: {"as_prod": [], "as_nonprod": []} for sid in server_ids}
|
||||
|
||||
# Prod → non-prods
|
||||
rows = db.execute(text(f"""SELECT sc.prod_server_id as sid, sc.environment_code,
|
||||
ns.hostname, e.name as env_name
|
||||
FROM server_correspondance sc
|
||||
JOIN servers ns ON sc.nonprod_server_id = ns.id
|
||||
LEFT JOIN domain_environments de ON ns.domain_env_id = de.id
|
||||
LEFT JOIN environments e ON de.environment_id = e.id
|
||||
WHERE sc.prod_server_id IN ({placeholders})
|
||||
ORDER BY ns.hostname""")).fetchall()
|
||||
for r in rows:
|
||||
if r.sid in result:
|
||||
result[r.sid]["as_prod"].append({"hostname": r.hostname,
|
||||
"env_name": r.env_name, "environment_code": r.environment_code})
|
||||
|
||||
# Non-prod → prods
|
||||
rows = db.execute(text(f"""SELECT sc.nonprod_server_id as sid,
|
||||
ps.hostname, e.name as env_name
|
||||
FROM server_correspondance sc
|
||||
JOIN servers ps ON sc.prod_server_id = ps.id
|
||||
LEFT JOIN domain_environments de ON ps.domain_env_id = de.id
|
||||
LEFT JOIN environments e ON de.environment_id = e.id
|
||||
WHERE sc.nonprod_server_id IN ({placeholders})
|
||||
ORDER BY ps.hostname""")).fetchall()
|
||||
for r in rows:
|
||||
if r.sid in result:
|
||||
result[r.sid]["as_nonprod"].append({"hostname": r.hostname, "env_name": r.env_name})
|
||||
return result
|
||||
|
||||
|
||||
def get_orphan_nonprod(db):
|
||||
"""Retourne les hors-prod sans prod associée (exclut stock/obsolete)."""
|
||||
rows = db.execute(text("""
|
||||
SELECT s.id, s.hostname, s.application_name, e.name as env_name,
|
||||
d.name as domain_name
|
||||
FROM servers s
|
||||
LEFT JOIN domain_environments de ON s.domain_env_id = de.id
|
||||
LEFT JOIN environments e ON de.environment_id = e.id
|
||||
LEFT JOIN domains d ON de.domain_id = d.id
|
||||
WHERE e.name IS NOT NULL AND e.name NOT ILIKE '%production%'
|
||||
AND s.etat NOT IN ('stock','obsolete')
|
||||
AND NOT EXISTS (SELECT 1 FROM server_correspondance sc WHERE sc.nonprod_server_id = s.id)
|
||||
ORDER BY s.application_name, s.hostname
|
||||
LIMIT 500
|
||||
""")).fetchall()
|
||||
return rows
|
||||
|
||||
|
||||
def create_manual_link(db, prod_id, nonprod_id, env_code, note, user_id):
|
||||
"""Crée un lien manuel."""
|
||||
existing = db.execute(text("""SELECT id FROM server_correspondance
|
||||
WHERE prod_server_id=:p AND nonprod_server_id=:n"""),
|
||||
{"p": prod_id, "n": nonprod_id}).fetchone()
|
||||
if existing:
|
||||
db.execute(text("""UPDATE server_correspondance SET source='manual',
|
||||
environment_code=:env, note=:note, updated_at=NOW() WHERE id=:id"""),
|
||||
{"env": env_code, "note": note, "id": existing.id})
|
||||
else:
|
||||
db.execute(text("""INSERT INTO server_correspondance (prod_server_id,
|
||||
nonprod_server_id, environment_code, source, note, created_by)
|
||||
VALUES (:p, :n, :env, 'manual', :note, :uid)"""),
|
||||
{"p": prod_id, "n": nonprod_id, "env": env_code, "note": note, "uid": user_id})
|
||||
db.commit()
|
||||
|
||||
|
||||
def delete_link(db, corr_id):
|
||||
db.execute(text("DELETE FROM server_correspondance WHERE id=:id"), {"id": corr_id})
|
||||
db.commit()
|
||||
|
||||
|
||||
# ─── Patch validation ───
|
||||
|
||||
def create_validation_entry(db, server_id, campaign_id=None, campaign_type="manual"):
|
||||
"""Crée une entrée 'en_attente' après patching."""
|
||||
db.execute(text("""INSERT INTO patch_validation (server_id, campaign_id, campaign_type,
|
||||
patch_date, status) VALUES (:sid, :cid, :ct, NOW(), 'en_attente')"""),
|
||||
{"sid": server_id, "cid": campaign_id, "ct": campaign_type})
|
||||
db.commit()
|
||||
|
||||
|
||||
def mark_validation(db, validation_ids, status, validator_contact_id, validator_name,
|
||||
forced_reason, notes, user_id):
|
||||
"""Marque N validations. status dans (validated_ok, validated_ko, forced)."""
|
||||
placeholders = ",".join(str(i) for i in validation_ids if str(i).isdigit())
|
||||
if not placeholders:
|
||||
return 0
|
||||
db.execute(text(f"""UPDATE patch_validation SET
|
||||
status=:s, validated_by_contact_id=:cid, validated_by_name=:n,
|
||||
validated_at=NOW(), marked_by_user_id=:uid,
|
||||
forced_reason=:fr, notes=:nt, updated_at=NOW()
|
||||
WHERE id IN ({placeholders})"""),
|
||||
{"s": status, "cid": validator_contact_id, "n": validator_name,
|
||||
"uid": user_id, "fr": forced_reason, "nt": notes})
|
||||
db.commit()
|
||||
return len(placeholders.split(","))
|
||||
|
||||
|
||||
def get_pending_validations(db, env="", campaign_id=None, status="en_attente", limit=500):
|
||||
"""Liste les validations filtrées."""
|
||||
where = ["1=1"]
|
||||
params = {}
|
||||
if status:
|
||||
where.append("pv.status = :st"); params["st"] = status
|
||||
if campaign_id:
|
||||
where.append("pv.campaign_id = :cid"); params["cid"] = campaign_id
|
||||
if env:
|
||||
where.append("e.name = :env"); params["env"] = env
|
||||
wc = " AND ".join(where)
|
||||
return db.execute(text(f"""
|
||||
SELECT pv.id, pv.server_id, s.hostname, s.application_name,
|
||||
e.name as env_name, d.name as domain_name,
|
||||
pv.campaign_id, pv.campaign_type, pv.patch_date, pv.status,
|
||||
pv.validated_by_name, pv.validated_at,
|
||||
pv.forced_reason, pv.notes,
|
||||
EXTRACT(day FROM NOW() - pv.patch_date) as days_pending
|
||||
FROM patch_validation pv
|
||||
JOIN servers s ON pv.server_id = s.id
|
||||
LEFT JOIN domain_environments de ON s.domain_env_id = de.id
|
||||
LEFT JOIN environments e ON de.environment_id = e.id
|
||||
LEFT JOIN domains d ON de.domain_id = d.id
|
||||
WHERE {wc}
|
||||
ORDER BY pv.patch_date DESC
|
||||
LIMIT {int(limit)}
|
||||
"""), params).fetchall()
|
||||
|
||||
|
||||
def get_validation_history(db, server_id):
|
||||
return db.execute(text("""
|
||||
SELECT pv.id, pv.campaign_id, pv.campaign_type, pv.patch_date, pv.status,
|
||||
pv.validated_by_name, pv.validated_at, pv.forced_reason, pv.notes,
|
||||
u.display_name as marked_by
|
||||
FROM patch_validation pv
|
||||
LEFT JOIN users u ON pv.marked_by_user_id = u.id
|
||||
WHERE pv.server_id = :sid
|
||||
ORDER BY pv.patch_date DESC
|
||||
"""), {"sid": server_id}).fetchall()
|
||||
|
||||
|
||||
def can_patch_prod(db, prod_server_id):
|
||||
"""Retourne (bool, list_of_pending_hostnames) : peut-on patcher le prod ?
|
||||
OK si tous les hors-prod liés ont validated_ok ou forced sur leur dernier patching."""
|
||||
corrs = db.execute(text("""SELECT ns.id, ns.hostname,
|
||||
(SELECT pv.status FROM patch_validation pv WHERE pv.server_id = ns.id
|
||||
ORDER BY pv.patch_date DESC LIMIT 1) as last_status
|
||||
FROM server_correspondance sc JOIN servers ns ON sc.nonprod_server_id = ns.id
|
||||
WHERE sc.prod_server_id = :pid"""), {"pid": prod_server_id}).fetchall()
|
||||
if not corrs:
|
||||
return True, [] # pas de hors-prod = OK (ou selon règle, à ajuster)
|
||||
blockers = [c.hostname for c in corrs if c.last_status not in ("validated_ok", "forced")]
|
||||
return (len(blockers) == 0), blockers
|
||||
@ -43,19 +43,67 @@ class ITopClient:
|
||||
return self._call("core/create", **{"class": cls, "fields": fields, "comment": "PatchCenter sync"})
|
||||
|
||||
|
||||
def _normalize_os_for_itop(os_string):
|
||||
"""Normalise PRETTY_NAME vers un nom propre pour iTop OSVersion.
|
||||
Ex: 'Debian GNU/Linux 12 (bookworm)' → 'Debian 12 (Bookworm)'
|
||||
'CentOS Stream 9' → 'CentOS Stream 9'
|
||||
'Red Hat Enterprise Linux 9.4 (Plow)' → 'RHEL 9.4 (Plow)'
|
||||
"""
|
||||
import re
|
||||
s = os_string.strip()
|
||||
# Debian GNU/Linux X (codename) → Debian X (Codename)
|
||||
m = re.match(r'Debian GNU/Linux (\d+)\s*\((\w+)\)', s, re.I)
|
||||
if m:
|
||||
return f"Debian {m.group(1)} ({m.group(2).capitalize()})"
|
||||
# Ubuntu X.Y LTS
|
||||
m = re.match(r'Ubuntu (\d+\.\d+(?:\.\d+)?)\s*(LTS)?', s, re.I)
|
||||
if m:
|
||||
lts = " LTS" if m.group(2) else ""
|
||||
return f"Ubuntu {m.group(1)}{lts}"
|
||||
# Red Hat Enterprise Linux [Server] [release] X → RHEL X
|
||||
m = re.match(r'Red Hat Enterprise Linux\s*(?:Server)?\s*(?:release)?\s*(\d+[\.\d]*)\s*\(?([\w]*)\)?', s, re.I)
|
||||
if m:
|
||||
codename = f" ({m.group(2).capitalize()})" if m.group(2) else ""
|
||||
return f"RHEL {m.group(1)}{codename}"
|
||||
# CentOS Stream release X → CentOS Stream X
|
||||
m = re.match(r'CentOS Stream\s+release\s+(\d+[\.\d]*)', s, re.I)
|
||||
if m:
|
||||
return f"CentOS Stream {m.group(1)}"
|
||||
# CentOS Linux X.Y → CentOS X.Y
|
||||
m = re.match(r'CentOS\s+Linux\s+(\d+[\.\d]*)', s, re.I)
|
||||
if m:
|
||||
return f"CentOS {m.group(1)}"
|
||||
# Rocky Linux X.Y (codename) → Rocky Linux X.Y
|
||||
m = re.match(r'Rocky\s+Linux\s+(\d+[\.\d]*)', s, re.I)
|
||||
if m:
|
||||
return f"Rocky Linux {m.group(1)}"
|
||||
# Oracle Linux / Oracle Enterprise Linux
|
||||
m = re.match(r'Oracle\s+(?:Enterprise\s+)?Linux\s+(\d+[\.\d]*)', s, re.I)
|
||||
if m:
|
||||
return f"Oracle Linux {m.group(1)}"
|
||||
# Fallback: remove "release" word
|
||||
return re.sub(r'\s+release\s+', ' ', s).strip()
|
||||
|
||||
|
||||
def _upsert_ip(db, server_id, ip):
|
||||
if not ip:
|
||||
return
|
||||
existing = db.execute(text(
|
||||
# Remove all old itop-managed primary IPs for this server (keep only the current one)
|
||||
db.execute(text(
|
||||
"DELETE FROM server_ips WHERE server_id=:sid AND ip_type='primary' AND description='itop' AND ip_address != :ip"),
|
||||
{"sid": server_id, "ip": ip})
|
||||
# Check if this exact IP already exists
|
||||
exact = db.execute(text(
|
||||
"SELECT id FROM server_ips WHERE server_id=:sid AND ip_address=:ip"),
|
||||
{"sid": server_id, "ip": ip}).fetchone()
|
||||
if not existing:
|
||||
try:
|
||||
db.execute(text(
|
||||
"INSERT INTO server_ips (server_id, ip_address, ip_type, is_ssh, description) VALUES (:sid, :ip, 'primary', true, 'itop')"),
|
||||
{"sid": server_id, "ip": ip})
|
||||
except Exception:
|
||||
pass
|
||||
if exact:
|
||||
return
|
||||
try:
|
||||
db.execute(text(
|
||||
"INSERT INTO server_ips (server_id, ip_address, ip_type, is_ssh, description) VALUES (:sid, :ip, 'primary', true, 'itop')"),
|
||||
{"sid": server_id, "ip": ip})
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
|
||||
def _save_sync_timestamp(db, direction, stats):
|
||||
@ -154,11 +202,20 @@ def sync_from_itop(db, itop_url, itop_user, itop_pass):
|
||||
except Exception:
|
||||
db.rollback()
|
||||
|
||||
# ─── 5. Contacts + Teams ───
|
||||
persons = client.get_all("Person", "name,first_name,email,phone,org_name")
|
||||
# ─── 5. Contacts + Teams (filtre périmètre IT uniquement) ───
|
||||
persons = client.get_all("Person", "name,first_name,email,phone,org_name,function,status")
|
||||
|
||||
# Périmètre IT : teams à synchroniser (configurable via settings)
|
||||
from .secrets_service import get_secret as _gs
|
||||
it_teams_raw = _gs(db, "itop_contact_teams")
|
||||
it_teams_list = ["SecOps", "iPOP", "Externe", "DSI", "Admin DSI"]
|
||||
if it_teams_raw:
|
||||
it_teams_list = [t.strip() for t in it_teams_raw.split(",") if t.strip()]
|
||||
|
||||
# Map person → (itop_id, team)
|
||||
person_info = {} # fullname_lower -> {itop_id, team}
|
||||
team_members = {} # pour responsables domaine×env plus bas
|
||||
|
||||
# Get team memberships to determine role
|
||||
team_members = {} # person_fullname_lower -> team_name
|
||||
teams = client.get_all("Team", "name,persons_list")
|
||||
for t in teams:
|
||||
team_name = t.get("name", "")
|
||||
@ -167,29 +224,67 @@ def sync_from_itop(db, itop_url, itop_user, itop_pass):
|
||||
if pname:
|
||||
team_members[pname] = team_name
|
||||
|
||||
team_role_map = {"SecOps": "referent_technique", "iPOP": "responsable_applicatif", "Externe": "referent_technique"}
|
||||
team_role_map = {"SecOps": "referent_technique", "iPOP": "responsable_applicatif",
|
||||
"Externe": "referent_technique", "DSI": "referent_technique",
|
||||
"Admin DSI": "referent_technique"}
|
||||
|
||||
# Set des itop_id et emails vus dans le périmètre IT (pour désactiver les autres)
|
||||
seen_itop_ids = set()
|
||||
seen_emails = set()
|
||||
stats["contacts_deactivated"] = 0
|
||||
|
||||
for p in persons:
|
||||
fullname = f"{p.get('first_name','')} {p.get('name','')}".strip()
|
||||
email = p.get("email", "")
|
||||
if not email:
|
||||
continue
|
||||
# Determine role from team
|
||||
team = team_members.get(fullname.lower(), "")
|
||||
# Filtre : ne synchroniser que les persons dans le périmètre IT
|
||||
if team not in it_teams_list:
|
||||
continue
|
||||
role = team_role_map.get(team, "referent_technique")
|
||||
itop_id = p.get("itop_id")
|
||||
phone = p.get("phone", "")
|
||||
function = p.get("function", "")
|
||||
# Status iTop : active/inactive
|
||||
itop_status = (p.get("status", "") or "").lower()
|
||||
is_active = itop_status != "inactive"
|
||||
|
||||
if itop_id:
|
||||
seen_itop_ids.add(int(itop_id))
|
||||
seen_emails.add(email.lower())
|
||||
|
||||
existing = db.execute(text("SELECT id FROM contacts WHERE LOWER(email)=LOWER(:e)"), {"e": email}).fetchone()
|
||||
if existing:
|
||||
db.execute(text("UPDATE contacts SET name=:n, role=:r, updated_at=NOW() WHERE id=:id"),
|
||||
{"id": existing.id, "n": fullname, "r": role})
|
||||
db.execute(text("""UPDATE contacts SET name=:n, role=:r, itop_id=:iid,
|
||||
telephone=:tel, team=:t, function=:f, is_active=:a, updated_at=NOW() WHERE id=:id"""),
|
||||
{"id": existing.id, "n": fullname, "r": role, "iid": itop_id,
|
||||
"tel": phone, "t": team, "f": function, "a": is_active})
|
||||
else:
|
||||
try:
|
||||
db.execute(text("INSERT INTO contacts (name, email, role) VALUES (:n, :e, :r)"),
|
||||
{"n": fullname, "e": email, "r": role})
|
||||
db.execute(text("""INSERT INTO contacts (name, email, role, itop_id,
|
||||
telephone, team, function, is_active) VALUES (:n, :e, :r, :iid, :tel, :t, :f, :a)"""),
|
||||
{"n": fullname, "e": email, "r": role, "iid": itop_id,
|
||||
"tel": phone, "t": team, "f": function, "a": is_active})
|
||||
stats["contacts"] += 1
|
||||
except Exception:
|
||||
db.rollback()
|
||||
|
||||
# Désactiver les contacts iTop qui ne sont plus dans le périmètre (plus dans les teams IT)
|
||||
# Critère : a un itop_id mais n'a pas été vu dans le sync
|
||||
if seen_itop_ids:
|
||||
placeholders = ",".join(str(i) for i in seen_itop_ids)
|
||||
r = db.execute(text(f"""UPDATE contacts SET is_active=false, updated_at=NOW()
|
||||
WHERE itop_id IS NOT NULL AND itop_id NOT IN ({placeholders}) AND is_active=true"""))
|
||||
stats["contacts_deactivated"] = r.rowcount
|
||||
|
||||
# Désactiver les users PatchCenter liés à des contacts devenus inactifs
|
||||
stats["users_deactivated"] = 0
|
||||
r = db.execute(text("""UPDATE users SET is_active=false, updated_at=NOW()
|
||||
WHERE is_active=true AND itop_person_id IN
|
||||
(SELECT itop_id FROM contacts WHERE is_active=false AND itop_id IS NOT NULL)"""))
|
||||
stats["users_deactivated"] = r.rowcount
|
||||
|
||||
# ─── 6. Build lookup maps ───
|
||||
domain_map = {r.name.lower(): r.id for r in db.execute(text("SELECT id, name FROM domains")).fetchall()}
|
||||
env_map = {r.name.lower(): r.id for r in db.execute(text("SELECT id, name FROM environments")).fetchall()}
|
||||
@ -206,6 +301,30 @@ def sync_from_itop(db, itop_url, itop_user, itop_pass):
|
||||
de_responsables = defaultdict(lambda: {"resp_dom": defaultdict(int), "resp_dom_email": {},
|
||||
"referent": defaultdict(int), "referent_email": {}})
|
||||
|
||||
# ─── 7bis. ApplicationSolutions ───
|
||||
crit_map = {"high": "haute", "critical": "critique", "medium": "standard", "low": "basse"}
|
||||
itop_apps = client.get_all("ApplicationSolution", "name,description,business_criticity,status")
|
||||
for app in itop_apps:
|
||||
iid = app.get("itop_id")
|
||||
name = (app.get("name") or "")[:50]
|
||||
full = (app.get("name") or "")[:200]
|
||||
desc = (app.get("description") or "")[:500]
|
||||
crit = crit_map.get((app.get("business_criticity") or "").lower(), "basse")
|
||||
st = (app.get("status") or "active")[:30]
|
||||
try:
|
||||
db.execute(text("""INSERT INTO applications (itop_id, nom_court, nom_complet, description, criticite, status)
|
||||
VALUES (:iid, :n, :nc, :d, :c, :s)
|
||||
ON CONFLICT (itop_id) DO UPDATE SET nom_court=EXCLUDED.nom_court,
|
||||
nom_complet=EXCLUDED.nom_complet, description=EXCLUDED.description,
|
||||
criticite=EXCLUDED.criticite, status=EXCLUDED.status, updated_at=NOW()"""),
|
||||
{"iid": iid, "n": name, "nc": full, "d": desc, "c": crit, "s": st})
|
||||
stats["applications"] = stats.get("applications", 0) + 1
|
||||
except Exception:
|
||||
db.rollback()
|
||||
db.commit()
|
||||
app_by_itop_id = {r.itop_id: r.id for r in db.execute(text(
|
||||
"SELECT id, itop_id FROM applications WHERE itop_id IS NOT NULL")).fetchall()}
|
||||
|
||||
# ─── 8. VirtualMachines ───
|
||||
vms = client.get_all("VirtualMachine",
|
||||
"name,description,status,managementip,osfamily_id_friendlyname,"
|
||||
@ -215,10 +334,12 @@ def sync_from_itop(db, itop_url, itop_user, itop_pass):
|
||||
"contacts_list,virtualhost_name,business_criticity,"
|
||||
"tier_name,connexion_method_name,ssh_user_name,"
|
||||
"patch_frequency_name,pref_patch_jour_name,patch_window,"
|
||||
"patch_excludes,domain_ldap_name,last_patch_date")
|
||||
"patch_excludes,domain_ldap_name,last_patch_date,"
|
||||
"applicationsolution_list")
|
||||
|
||||
itop_status = {"production": "en_production", "stock": "stock",
|
||||
"implementation": "en_cours", "obsolete": "decommissionne"}
|
||||
# PatchCenter etat = iTop status (meme enum: production, implementation, stock, obsolete)
|
||||
itop_status = {"production": "production", "stock": "stock",
|
||||
"implementation": "implementation", "obsolete": "obsolete"}
|
||||
|
||||
for v in vms:
|
||||
hostname = v.get("name", "").split(".")[0].lower()
|
||||
@ -264,12 +385,25 @@ def sync_from_itop(db, itop_url, itop_user, itop_pass):
|
||||
resp_srv_name = v.get("responsable_serveur_name", "")
|
||||
resp_dom_name = v.get("responsable_domaine_name", "")
|
||||
|
||||
# ApplicationSolution (première app si plusieurs)
|
||||
app_id = None
|
||||
app_name = None
|
||||
apps_list = v.get("applicationsolution_list") or []
|
||||
if apps_list:
|
||||
first = apps_list[0]
|
||||
try:
|
||||
itop_aid = int(first.get("applicationsolution_id", 0))
|
||||
app_id = app_by_itop_id.get(itop_aid)
|
||||
app_name = first.get("applicationsolution_name", "")
|
||||
except (ValueError, TypeError):
|
||||
pass
|
||||
|
||||
vals = {
|
||||
"hostname": hostname, "fqdn": v.get("name", hostname),
|
||||
"os_family": "linux" if "linux" in v.get("osfamily_id_friendlyname", "").lower() else "windows",
|
||||
"os_version": v.get("osversion_id_friendlyname", ""),
|
||||
"machine_type": "vm",
|
||||
"etat": itop_status.get(v.get("status", ""), "en_production"),
|
||||
"etat": itop_status.get(v.get("status", ""), "production"),
|
||||
"de_id": de_id, "zone_id": zone_id,
|
||||
"resp_srv": resp_srv_name,
|
||||
"resp_srv_email": person_email.get(resp_srv_name.lower(), ""),
|
||||
@ -282,6 +416,7 @@ def sync_from_itop(db, itop_url, itop_user, itop_pass):
|
||||
"patch_freq": patch_freq, "patch_excludes": v.get("patch_excludes", ""),
|
||||
"domain_ltd": v.get("domain_ldap_name", ""),
|
||||
"pref_jour": pref_jour, "pref_heure": pref_heure,
|
||||
"app_id": app_id, "app_name": app_name,
|
||||
}
|
||||
|
||||
existing = db.execute(text("SELECT id FROM servers WHERE LOWER(hostname)=LOWER(:h)"), {"h": hostname}).fetchone()
|
||||
@ -293,6 +428,7 @@ def sync_from_itop(db, itop_url, itop_user, itop_pass):
|
||||
tier=:tier, ssh_method=:ssh_method, ssh_user=:ssh_user,
|
||||
patch_frequency=:patch_freq, patch_excludes=:patch_excludes,
|
||||
domain_ltd=:domain_ltd, pref_patch_jour=:pref_jour, pref_patch_heure=:pref_heure,
|
||||
application_id=:app_id, application_name=:app_name,
|
||||
updated_at=NOW() WHERE id=:sid"""), {**vals, "sid": existing.id})
|
||||
if vals["ip"]:
|
||||
_upsert_ip(db, existing.id, vals["ip"])
|
||||
@ -341,22 +477,33 @@ def sync_from_itop(db, itop_url, itop_user, itop_pass):
|
||||
hostname = s.get("name", "").split(".")[0].lower()
|
||||
if not hostname:
|
||||
continue
|
||||
contacts = s.get("contacts_list", [])
|
||||
resp = contacts[0].get("contact_id_friendlyname", "") if contacts else ""
|
||||
ip = s.get("managementip", "")
|
||||
osf = "linux" if "linux" in s.get("osfamily_id_friendlyname", "").lower() else "windows"
|
||||
osv = s.get("osversion_id_friendlyname", "")
|
||||
|
||||
existing = db.execute(text("SELECT id FROM servers WHERE LOWER(hostname)=LOWER(:h)"), {"h": hostname}).fetchone()
|
||||
if not existing:
|
||||
if existing:
|
||||
db.execute(text("""UPDATE servers SET fqdn=:f, os_family=:osf, os_version=:osv,
|
||||
responsable_nom=:resp, commentaire=:desc, site=:site, updated_at=NOW()
|
||||
WHERE id=:sid"""),
|
||||
{"f": s.get("name", hostname), "osf": osf, "osv": osv,
|
||||
"resp": resp, "desc": s.get("description", ""),
|
||||
"site": s.get("location_name", ""), "sid": existing.id})
|
||||
if ip:
|
||||
_upsert_ip(db, existing.id, ip)
|
||||
stats["servers_updated"] += 1
|
||||
else:
|
||||
try:
|
||||
contacts = s.get("contacts_list", [])
|
||||
resp = contacts[0].get("contact_id_friendlyname", "") if contacts else ""
|
||||
db.execute(text("""INSERT INTO servers (hostname, fqdn, os_family, os_version, machine_type,
|
||||
etat, responsable_nom, commentaire, site, ssh_port, ssh_user, ssh_method, tier)
|
||||
VALUES (:h, :f, :osf, :osv, 'physical', 'en_production', :resp, :desc, :site,
|
||||
VALUES (:h, :f, :osf, :osv, 'physical', 'production', :resp, :desc, :site,
|
||||
22, 'root', 'ssh_key', 'tier0')"""),
|
||||
{"h": hostname, "f": s.get("name", hostname),
|
||||
"osf": "linux" if "linux" in s.get("osfamily_id_friendlyname", "").lower() else "windows",
|
||||
"osv": s.get("osversion_id_friendlyname", ""),
|
||||
{"h": hostname, "f": s.get("name", hostname), "osf": osf, "osv": osv,
|
||||
"resp": resp, "desc": s.get("description", ""),
|
||||
"site": s.get("location_name", "")})
|
||||
db.flush()
|
||||
ip = s.get("managementip", "")
|
||||
if ip:
|
||||
new_srv = db.execute(text("SELECT id FROM servers WHERE hostname=:h"), {"h": hostname}).fetchone()
|
||||
if new_srv:
|
||||
@ -414,26 +561,42 @@ def sync_to_itop(db, itop_url, itop_user, itop_pass):
|
||||
for v in client.get_all("VirtualMachine", "name"):
|
||||
itop_vms[v["name"].split(".")[0].lower()] = v
|
||||
|
||||
status_map = {"en_production": "production", "decommissionne": "obsolete",
|
||||
"stock": "stock", "en_cours": "implementation"}
|
||||
status_map = {"production": "production", "implementation": "implementation",
|
||||
"stock": "stock", "obsolete": "obsolete"}
|
||||
tier_map = {"tier0": "Tier 0", "tier1": "Tier 1", "tier2": "Tier 2", "tier3": "Tier 3"}
|
||||
|
||||
# Build OSVersion cache: name.lower() → itop_id
|
||||
itop_osversions = {}
|
||||
for ov in client.get_all("OSVersion", "name,osfamily_name"):
|
||||
itop_osversions[ov["name"].lower()] = ov
|
||||
|
||||
# Build OSFamily cache
|
||||
itop_osfamilies = {}
|
||||
for of in client.get_all("OSFamily", "name"):
|
||||
itop_osfamilies[of["name"].lower()] = of["itop_id"]
|
||||
|
||||
# Build Person name → itop_id lookup for responsable sync
|
||||
itop_persons = {}
|
||||
for p in client.get_all("Person", "name,first_name"):
|
||||
fullname = f"{p.get('first_name','')} {p.get('name','')}".strip()
|
||||
itop_persons[fullname.lower()] = p["itop_id"]
|
||||
|
||||
rows = db.execute(text("""SELECT hostname, fqdn, os_version, etat, commentaire, tier,
|
||||
ssh_method, ssh_user, patch_frequency, patch_excludes, domain_ltd,
|
||||
pref_patch_jour, pref_patch_heure, responsable_nom, referent_nom
|
||||
FROM servers WHERE machine_type='vm'""")).fetchall()
|
||||
rows = db.execute(text("""SELECT s.hostname, s.fqdn, s.os_version, s.os_family, s.etat, s.commentaire, s.tier,
|
||||
s.ssh_method, s.ssh_user, s.patch_frequency, s.patch_excludes, s.domain_ltd,
|
||||
s.pref_patch_jour, s.pref_patch_heure, s.responsable_nom, s.referent_nom,
|
||||
s.application_id,
|
||||
(SELECT si.ip_address::text FROM server_ips si WHERE si.server_id = s.id AND si.ip_type = 'primary' LIMIT 1) as mgmt_ip,
|
||||
(SELECT sa.audit_date FROM server_audit sa WHERE sa.server_id = s.id ORDER BY sa.audit_date DESC LIMIT 1) as last_audit_date,
|
||||
(SELECT a.itop_id FROM applications a WHERE a.id = s.application_id) as app_itop_id
|
||||
FROM servers s WHERE s.machine_type='vm'""")).fetchall()
|
||||
|
||||
for srv in rows:
|
||||
hostname = (srv.hostname or "").lower()
|
||||
itop_vm = itop_vms.get(hostname)
|
||||
|
||||
fields = {}
|
||||
if srv.mgmt_ip:
|
||||
fields["managementip"] = srv.mgmt_ip.split("/")[0]
|
||||
if srv.etat:
|
||||
fields["status"] = status_map.get(srv.etat, "production")
|
||||
if srv.commentaire:
|
||||
@ -455,6 +618,29 @@ def sync_to_itop(db, itop_url, itop_user, itop_pass):
|
||||
fields["patch_window"] = srv.pref_patch_heure
|
||||
if srv.domain_ltd:
|
||||
fields["domain_ldap_id"] = f"SELECT DomainLdap WHERE name = '{srv.domain_ltd}'"
|
||||
# Date dernier audit
|
||||
if srv.last_audit_date:
|
||||
fields["last_patch_date"] = str(srv.last_audit_date)[:10]
|
||||
# ApplicationSolution : pousser si défini (replace la liste)
|
||||
if srv.app_itop_id:
|
||||
fields["applicationsolution_list"] = [{"applicationsolution_id": int(srv.app_itop_id)}]
|
||||
# OS version — chercher/créer dans iTop
|
||||
if srv.os_version:
|
||||
osv_name = _normalize_os_for_itop(srv.os_version)
|
||||
osf_name = "Linux" if srv.os_family == "linux" else "Windows" if srv.os_family == "windows" else "Linux"
|
||||
match = itop_osversions.get(osv_name.lower())
|
||||
if match:
|
||||
fields["osversion_id"] = match["itop_id"]
|
||||
else:
|
||||
# Créer l'OSVersion dans iTop
|
||||
osf_id = itop_osfamilies.get(osf_name.lower())
|
||||
if osf_id:
|
||||
cr = client.create("OSVersion", {"name": osv_name, "osfamily_id": osf_id})
|
||||
if cr.get("code") == 0 and cr.get("objects"):
|
||||
new_id = list(cr["objects"].values())[0]["key"]
|
||||
fields["osversion_id"] = new_id
|
||||
itop_osversions[osv_name.lower()] = {"itop_id": new_id, "name": osv_name}
|
||||
stats["ref_created"] += 1
|
||||
# Responsable serveur
|
||||
if srv.responsable_nom:
|
||||
pid = itop_persons.get(srv.responsable_nom.lower())
|
||||
|
||||
113
app/services/ldap_service.py
Normal file
113
app/services/ldap_service.py
Normal file
@ -0,0 +1,113 @@
|
||||
"""Service LDAP/AD — authentification via annuaire.
|
||||
|
||||
Configuration via settings (clés) :
|
||||
- ldap_enabled : "true" / "false"
|
||||
- ldap_server : URL du serveur (ex: ldaps://ad.sanef.com:636)
|
||||
- ldap_base_dn : DN de base (ex: DC=sanef,DC=com)
|
||||
- ldap_bind_dn : DN du compte de bind (ex: CN=svc_pc,OU=SVC,DC=sanef,DC=com)
|
||||
- ldap_bind_pwd : mot de passe (stocké chiffré via secrets_service)
|
||||
- ldap_user_filter : filtre utilisateur (ex: (sAMAccountName={username}))
|
||||
- ldap_tls : "true" / "false"
|
||||
"""
|
||||
import logging
|
||||
from sqlalchemy import text
|
||||
|
||||
log = logging.getLogger(__name__)
|
||||
|
||||
try:
|
||||
from ldap3 import Server, Connection, ALL, NTLM, SIMPLE, Tls
|
||||
import ssl
|
||||
LDAP_OK = True
|
||||
except ImportError:
|
||||
LDAP_OK = False
|
||||
|
||||
|
||||
def _get_config(db):
|
||||
"""Retourne la config LDAP depuis app_secrets (via get_secret)."""
|
||||
from .secrets_service import get_secret
|
||||
|
||||
def s(k, default=""):
|
||||
return get_secret(db, k) or default
|
||||
|
||||
return {
|
||||
"enabled": s("ldap_enabled", "false").lower() == "true",
|
||||
"server": s("ldap_server"),
|
||||
"base_dn": s("ldap_base_dn"),
|
||||
"bind_dn": s("ldap_bind_dn"),
|
||||
"bind_pwd": s("ldap_bind_pwd"),
|
||||
"user_filter": s("ldap_user_filter", "(sAMAccountName={username})"),
|
||||
"tls": s("ldap_tls", "true").lower() == "true",
|
||||
"email_attr": s("ldap_email_attr", "mail"),
|
||||
"name_attr": s("ldap_name_attr", "displayName"),
|
||||
}
|
||||
|
||||
|
||||
def is_enabled(db):
|
||||
"""LDAP activé et configuré ?"""
|
||||
cfg = _get_config(db)
|
||||
return cfg["enabled"] and cfg["server"] and cfg["base_dn"]
|
||||
|
||||
|
||||
def authenticate(db, username, password):
|
||||
"""Tente l'authentification LDAP.
|
||||
Retourne dict {ok, email, name, dn} ou {ok: False, msg: '...'}"""
|
||||
if not LDAP_OK:
|
||||
return {"ok": False, "msg": "Module ldap3 non installé"}
|
||||
|
||||
cfg = _get_config(db)
|
||||
if not cfg["enabled"]:
|
||||
return {"ok": False, "msg": "LDAP désactivé"}
|
||||
if not cfg["server"] or not cfg["base_dn"]:
|
||||
return {"ok": False, "msg": "LDAP non configuré"}
|
||||
|
||||
# 1. Bind service account pour chercher le DN de l'utilisateur
|
||||
try:
|
||||
server = Server(cfg["server"], get_info=ALL, use_ssl=cfg["server"].startswith("ldaps://"))
|
||||
conn = Connection(server, user=cfg["bind_dn"], password=cfg["bind_pwd"], auto_bind=True)
|
||||
except Exception as e:
|
||||
log.error(f"LDAP bind failed: {e}")
|
||||
return {"ok": False, "msg": f"Connexion LDAP échouée: {e}"}
|
||||
|
||||
# 2. Recherche de l'utilisateur
|
||||
user_filter = cfg["user_filter"].replace("{username}", username)
|
||||
try:
|
||||
conn.search(cfg["base_dn"], user_filter,
|
||||
attributes=[cfg["email_attr"], cfg["name_attr"], "distinguishedName"])
|
||||
except Exception as e:
|
||||
conn.unbind()
|
||||
return {"ok": False, "msg": f"Recherche LDAP échouée: {e}"}
|
||||
|
||||
if not conn.entries:
|
||||
conn.unbind()
|
||||
return {"ok": False, "msg": "Utilisateur introuvable dans LDAP"}
|
||||
|
||||
entry = conn.entries[0]
|
||||
user_dn = str(entry.distinguishedName) if hasattr(entry, "distinguishedName") else entry.entry_dn
|
||||
email = str(getattr(entry, cfg["email_attr"], "")) or ""
|
||||
name = str(getattr(entry, cfg["name_attr"], "")) or username
|
||||
conn.unbind()
|
||||
|
||||
# 3. Bind avec les credentials fournis
|
||||
try:
|
||||
user_conn = Connection(server, user=user_dn, password=password, auto_bind=True)
|
||||
user_conn.unbind()
|
||||
except Exception as e:
|
||||
return {"ok": False, "msg": "Mot de passe incorrect"}
|
||||
|
||||
return {"ok": True, "dn": user_dn, "email": email, "name": name}
|
||||
|
||||
|
||||
def test_connection(db):
|
||||
"""Test la connexion LDAP avec le compte de bind (pour admin)."""
|
||||
if not LDAP_OK:
|
||||
return {"ok": False, "msg": "Module ldap3 non installé"}
|
||||
cfg = _get_config(db)
|
||||
if not cfg["server"]:
|
||||
return {"ok": False, "msg": "Serveur non configuré"}
|
||||
try:
|
||||
server = Server(cfg["server"], get_info=ALL, use_ssl=cfg["server"].startswith("ldaps://"))
|
||||
conn = Connection(server, user=cfg["bind_dn"], password=cfg["bind_pwd"], auto_bind=True)
|
||||
conn.unbind()
|
||||
return {"ok": True, "msg": "Connexion réussie"}
|
||||
except Exception as e:
|
||||
return {"ok": False, "msg": str(e)[:200]}
|
||||
@ -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
|
||||
@ -142,24 +142,24 @@ def _check_server(s):
|
||||
result["disk_var_mb"] = 3000
|
||||
result["disk_ok"] = True
|
||||
# Quand meme exclure les EOL et decom
|
||||
if s.licence_support == 'eol':
|
||||
if s.licence_support == 'obsolete':
|
||||
result["eligible"] = False
|
||||
result["exclude_reason"] = "eol"
|
||||
result["exclude_reason"] = "obsolete"
|
||||
result["exclude_detail"] = "Licence EOL"
|
||||
elif s.etat != 'en_production':
|
||||
elif s.etat != 'production':
|
||||
result["eligible"] = False
|
||||
result["exclude_reason"] = "non_patchable"
|
||||
result["exclude_detail"] = f"Etat: {s.etat}"
|
||||
return result
|
||||
|
||||
# 1. Eligibilite de base
|
||||
if s.licence_support == 'eol':
|
||||
if s.licence_support == 'obsolete':
|
||||
result["eligible"] = False
|
||||
result["exclude_reason"] = "eol"
|
||||
result["exclude_reason"] = "obsolete"
|
||||
result["exclude_detail"] = "Licence EOL — serveur non supporte"
|
||||
return result
|
||||
|
||||
if s.etat != 'en_production':
|
||||
if s.etat != 'production':
|
||||
result["eligible"] = False
|
||||
result["exclude_reason"] = "non_patchable"
|
||||
result["exclude_detail"] = f"Etat: {s.etat}"
|
||||
@ -255,17 +255,17 @@ def _auto_exclude(db, campaign_id):
|
||||
FROM patch_sessions ps
|
||||
JOIN servers s ON ps.server_id = s.id
|
||||
WHERE ps.campaign_id = :cid AND ps.status = 'pending'
|
||||
AND (s.licence_support = 'eol'
|
||||
OR s.etat != 'en_production'
|
||||
AND (s.licence_support = 'obsolete'
|
||||
OR s.etat != 'production'
|
||||
OR ps.prereq_ssh = 'ko'
|
||||
OR ps.prereq_disk_ok = false)
|
||||
"""), {"cid": campaign_id}).fetchall()
|
||||
|
||||
count = 0
|
||||
for s in non_eligible:
|
||||
if s.licence_support == 'eol':
|
||||
reason, detail = "eol", "Licence EOL — auto-exclu"
|
||||
elif s.etat != 'en_production':
|
||||
if s.licence_support == 'obsolete':
|
||||
reason, detail = "obsolete", "Licence EOL — auto-exclu"
|
||||
elif s.etat != 'production':
|
||||
reason, detail = "non_patchable", f"Etat {s.etat} — auto-exclu"
|
||||
elif s.prereq_disk_ok is False:
|
||||
reason, detail = "creneau_inadequat", "Espace disque insuffisant — auto-exclu"
|
||||
|
||||
58
app/services/profile_service.py
Normal file
58
app/services/profile_service.py
Normal file
@ -0,0 +1,58 @@
|
||||
"""Profils utilisateurs PatchCenter — mapping role → permissions pré-définies.
|
||||
|
||||
4 profils :
|
||||
- admin : tout (view/edit/admin sur tous les modules)
|
||||
- coordinator : SecOps + coordination (Patcheur + gestion campagnes/planning)
|
||||
- operator : Patcheur (intervenant SecOps — exécution patching)
|
||||
- viewer : Invité (view-only : dashboard, servers, qualys, audit)
|
||||
"""
|
||||
|
||||
# Matrix profil → {module: level}
|
||||
# level: "view" | "edit" | "admin"
|
||||
PROFILES = {
|
||||
"admin": {
|
||||
"dashboard": "admin", "servers": "admin", "campaigns": "admin",
|
||||
"planning": "admin", "specifics": "admin", "audit": "admin",
|
||||
"contacts": "admin", "qualys": "admin", "quickwin": "admin",
|
||||
"users": "admin", "settings": "admin", "referentiel": "admin",
|
||||
},
|
||||
# Coordinateur = SecOps + gestion campagnes/planning
|
||||
"coordinator": {
|
||||
"dashboard": "view", "servers": "edit", "campaigns": "admin",
|
||||
"planning": "edit", "specifics": "edit", "audit": "edit",
|
||||
"contacts": "view", "qualys": "edit", "quickwin": "admin",
|
||||
"users": "view", "referentiel": "view",
|
||||
},
|
||||
# Patcheur = intervenant SecOps
|
||||
"operator": {
|
||||
"dashboard": "view", "servers": "view", "campaigns": "view",
|
||||
"planning": "view", "audit": "edit", "qualys": "view",
|
||||
"quickwin": "edit", "contacts": "view",
|
||||
},
|
||||
# Invité = view-only (pas d'accès à l'audit)
|
||||
"viewer": {
|
||||
"dashboard": "view", "servers": "view", "qualys": "view",
|
||||
"contacts": "view", "planning": "view", "quickwin": "view",
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
def get_profile_perms(role: str) -> dict:
|
||||
"""Retourne les permissions pour un profil donné."""
|
||||
return dict(PROFILES.get(role, {}))
|
||||
|
||||
|
||||
PROFILE_LABELS = {
|
||||
"admin": "Admin",
|
||||
"coordinator": "Coordinateur",
|
||||
"operator": "Patcheur",
|
||||
"viewer": "Invité",
|
||||
}
|
||||
|
||||
|
||||
PROFILE_DESCRIPTIONS = {
|
||||
"admin": "Accès complet : gestion des utilisateurs, paramètres, tous les modules en admin",
|
||||
"coordinator": "SecOps + coordination : gestion des campagnes, planning, exécution patching",
|
||||
"operator": "Patcheur (intervenant SecOps) : exécution du patching, audit des serveurs",
|
||||
"viewer": "Invité : consultation en lecture seule (dashboard, serveurs, Qualys, audit)",
|
||||
}
|
||||
@ -72,7 +72,7 @@ def delete_server_config(db, config_id):
|
||||
|
||||
|
||||
def get_eligible_servers(db):
|
||||
"""Serveurs Linux en_production, patch_os_owner=secops"""
|
||||
"""Serveurs Linux production, patch_os_owner=secops"""
|
||||
return db.execute(text("""
|
||||
SELECT s.id, s.hostname, s.os_family, s.os_version, s.machine_type,
|
||||
s.tier, s.etat, s.patch_excludes, s.is_flux_libre, s.is_podman,
|
||||
@ -86,7 +86,7 @@ def get_eligible_servers(db):
|
||||
LEFT JOIN environments e ON de.environment_id = e.id
|
||||
LEFT JOIN quickwin_server_config qc ON qc.server_id = s.id
|
||||
WHERE s.os_family = 'linux'
|
||||
AND s.etat = 'en_production'
|
||||
AND s.etat = 'production'
|
||||
AND s.patch_os_owner = 'secops'
|
||||
ORDER BY e.display_order, d.display_order, s.hostname
|
||||
""")).fetchall()
|
||||
@ -140,25 +140,26 @@ def create_run(db, year, week_number, label, user_id, server_ids, notes=""):
|
||||
"""), {"y": year, "w": week_number, "l": label, "uid": user_id, "n": notes}).fetchone()
|
||||
run_id = row.id
|
||||
|
||||
# Lire les reboot packages globaux (source: app_secrets)
|
||||
from .secrets_service import get_secret
|
||||
reboot_pkgs = get_secret(db, "patching_reboot_packages") or DEFAULT_GENERAL_EXCLUDES
|
||||
|
||||
for sid in server_ids:
|
||||
srv = db.execute(text("""
|
||||
SELECT s.id, e.name as env_name,
|
||||
COALESCE(qc.general_excludes, '') as ge,
|
||||
COALESCE(qc.specific_excludes, '') as se
|
||||
SELECT s.id, e.name as env_name, COALESCE(s.patch_excludes, '') as pe
|
||||
FROM servers s
|
||||
LEFT JOIN domain_environments de ON s.domain_env_id = de.id
|
||||
LEFT JOIN environments e ON de.environment_id = e.id
|
||||
LEFT JOIN quickwin_server_config qc ON qc.server_id = s.id
|
||||
WHERE s.id = :sid
|
||||
"""), {"sid": sid}).fetchone()
|
||||
if not srv:
|
||||
continue
|
||||
branch = "prod" if srv.env_name and "production" in srv.env_name.lower() else "hprod"
|
||||
ge = srv.ge if srv.ge else DEFAULT_GENERAL_EXCLUDES
|
||||
# QuickWin : reboot globaux + exclusions iTop du serveur
|
||||
db.execute(text("""
|
||||
INSERT INTO quickwin_entries (run_id, server_id, branch, general_excludes, specific_excludes)
|
||||
VALUES (:rid, :sid, :br, :ge, :se)
|
||||
"""), {"rid": run_id, "sid": sid, "br": branch, "ge": ge, "se": srv.se})
|
||||
"""), {"rid": run_id, "sid": sid, "br": branch, "ge": reboot_pkgs, "se": srv.pe})
|
||||
|
||||
db.commit()
|
||||
return run_id
|
||||
@ -183,7 +184,7 @@ def get_available_servers(db, run_id, search="", domains=None, envs=None, zones=
|
||||
LEFT JOIN environments e ON de.environment_id = e.id
|
||||
LEFT JOIN zones z ON s.zone_id = z.id
|
||||
WHERE s.os_family = 'linux'
|
||||
AND s.etat = 'en_production'
|
||||
AND s.etat = 'production'
|
||||
AND s.patch_os_owner = 'secops'
|
||||
AND s.id NOT IN (SELECT server_id FROM quickwin_entries WHERE run_id = :rid)
|
||||
ORDER BY d.name, e.name, s.hostname
|
||||
@ -210,7 +211,7 @@ def get_available_filters(db, run_id):
|
||||
LEFT JOIN environments e ON de.environment_id = e.id
|
||||
LEFT JOIN zones z ON s.zone_id = z.id
|
||||
WHERE s.os_family = 'linux'
|
||||
AND s.etat = 'en_production'
|
||||
AND s.etat = 'production'
|
||||
AND s.patch_os_owner = 'secops'
|
||||
AND s.id NOT IN (SELECT server_id FROM quickwin_entries WHERE run_id = :rid)
|
||||
"""), {"rid": run_id}).fetchall()
|
||||
@ -241,29 +242,28 @@ def add_entries_to_run(db, run_id, server_ids, user=None):
|
||||
), {"rid": run_id}).fetchall())
|
||||
|
||||
by = user.get("display_name", user.get("username", "")) if user else ""
|
||||
from .secrets_service import get_secret
|
||||
reboot_pkgs = get_secret(db, "patching_reboot_packages") or DEFAULT_GENERAL_EXCLUDES
|
||||
|
||||
added = 0
|
||||
hostnames = []
|
||||
for sid in server_ids:
|
||||
if sid in existing:
|
||||
continue
|
||||
srv = db.execute(text("""
|
||||
SELECT s.id, s.hostname, e.name as env_name,
|
||||
COALESCE(qc.general_excludes, '') as ge,
|
||||
COALESCE(qc.specific_excludes, '') as se
|
||||
SELECT s.id, s.hostname, e.name as env_name, COALESCE(s.patch_excludes, '') as pe
|
||||
FROM servers s
|
||||
LEFT JOIN domain_environments de ON s.domain_env_id = de.id
|
||||
LEFT JOIN environments e ON de.environment_id = e.id
|
||||
LEFT JOIN quickwin_server_config qc ON qc.server_id = s.id
|
||||
WHERE s.id = :sid
|
||||
"""), {"sid": sid}).fetchone()
|
||||
if not srv:
|
||||
continue
|
||||
branch = "prod" if srv.env_name and "production" in srv.env_name.lower() else "hprod"
|
||||
ge = srv.ge if srv.ge else DEFAULT_GENERAL_EXCLUDES
|
||||
db.execute(text("""
|
||||
INSERT INTO quickwin_entries (run_id, server_id, branch, general_excludes, specific_excludes)
|
||||
VALUES (:rid, :sid, :br, :ge, :se)
|
||||
"""), {"rid": run_id, "sid": sid, "br": branch, "ge": ge, "se": srv.se})
|
||||
"""), {"rid": run_id, "sid": sid, "br": branch, "ge": reboot_pkgs, "se": srv.pe})
|
||||
added += 1
|
||||
hostnames.append(srv.hostname)
|
||||
if added:
|
||||
@ -395,6 +395,23 @@ def update_entry_status(db, entry_id, status, patch_output="", packages_count=0,
|
||||
"pp": packages, "rb": reboot_required, "n": notes})
|
||||
db.commit()
|
||||
|
||||
# Création auto d'une entrée patch_validation (en_attente) pour les serveurs patchés
|
||||
if status == "patched":
|
||||
row = db.execute(text("SELECT server_id, run_id FROM quickwin_entries WHERE id=:id"),
|
||||
{"id": entry_id}).fetchone()
|
||||
if row:
|
||||
# Éviter les doublons (même run + même server dans la dernière heure)
|
||||
existing = db.execute(text("""SELECT id FROM patch_validation
|
||||
WHERE server_id=:sid AND campaign_id=:cid AND campaign_type='quickwin'
|
||||
AND patch_date >= NOW() - INTERVAL '1 hour'"""),
|
||||
{"sid": row.server_id, "cid": row.run_id}).fetchone()
|
||||
if not existing:
|
||||
db.execute(text("""INSERT INTO patch_validation (server_id, campaign_id,
|
||||
campaign_type, patch_date, status)
|
||||
VALUES (:sid, :cid, 'quickwin', NOW(), 'en_attente')"""),
|
||||
{"sid": row.server_id, "cid": row.run_id})
|
||||
db.commit()
|
||||
|
||||
|
||||
def update_entry_field(db, entry_id, field, value):
|
||||
"""Mise a jour d'un champ unique (pour inline edit)"""
|
||||
@ -417,6 +434,31 @@ def can_start_prod(db, run_id):
|
||||
return pending.cnt == 0
|
||||
|
||||
|
||||
def check_prod_validations(db, run_id):
|
||||
"""Vérifie que chaque prod du run a ses non-prod liés validés (via server_correspondance + patch_validation).
|
||||
Retourne (ok, blockers) où blockers = liste [{prod_hostname, nonprod_hostname, status}].
|
||||
Ignore les prods sans non-prod lié (OK par défaut)."""
|
||||
rows = db.execute(text("""
|
||||
SELECT qe.id as entry_id, ps.id as prod_id, ps.hostname as prod_host
|
||||
FROM quickwin_entries qe JOIN servers ps ON qe.server_id = ps.id
|
||||
WHERE qe.run_id = :rid AND qe.branch = 'prod' AND qe.status NOT IN ('excluded','skipped','patched')
|
||||
"""), {"rid": run_id}).fetchall()
|
||||
|
||||
blockers = []
|
||||
for r in rows:
|
||||
corrs = db.execute(text("""SELECT ns.hostname,
|
||||
(SELECT pv.status FROM patch_validation pv WHERE pv.server_id = ns.id
|
||||
ORDER BY pv.patch_date DESC LIMIT 1) as last_status
|
||||
FROM server_correspondance sc JOIN servers ns ON sc.nonprod_server_id = ns.id
|
||||
WHERE sc.prod_server_id = :pid"""), {"pid": r.prod_id}).fetchall()
|
||||
for c in corrs:
|
||||
if c.last_status not in ("validated_ok", "forced"):
|
||||
blockers.append({"prod_hostname": r.prod_host,
|
||||
"nonprod_hostname": c.hostname,
|
||||
"status": c.last_status or "aucun_patching"})
|
||||
return (len(blockers) == 0), blockers
|
||||
|
||||
|
||||
def get_run_stats(db, run_id):
|
||||
return db.execute(text("""
|
||||
SELECT
|
||||
|
||||
@ -32,7 +32,7 @@ def _get_ssh_settings():
|
||||
|
||||
# Commandes d'audit (simplifiees pour le temps reel)
|
||||
AUDIT_CMDS = {
|
||||
"os_release": "cat /etc/redhat-release 2>/dev/null || head -1 /etc/os-release 2>/dev/null",
|
||||
"os_release": "cat /etc/redhat-release 2>/dev/null || grep '^PRETTY_NAME=' /etc/os-release 2>/dev/null | cut -d'\"' -f2",
|
||||
"kernel": "uname -r",
|
||||
"uptime": "uptime -p 2>/dev/null || uptime",
|
||||
"selinux": "getenforce 2>/dev/null || echo N/A",
|
||||
@ -193,6 +193,124 @@ def audit_servers_list(hostnames):
|
||||
return results
|
||||
|
||||
|
||||
# ═══════════════════════════════════════════════
|
||||
# Background audit job manager
|
||||
# ═══════════════════════════════════════════════
|
||||
import threading
|
||||
import uuid
|
||||
import time as _time
|
||||
|
||||
_audit_jobs = {}
|
||||
|
||||
|
||||
def start_audit_job(hostnames):
|
||||
"""Lance un audit en arriere-plan. Retourne le job_id."""
|
||||
job_id = str(uuid.uuid4())[:8]
|
||||
job = {
|
||||
"id": job_id,
|
||||
"started_at": _time.time(),
|
||||
"total": len(hostnames),
|
||||
"done": 0,
|
||||
"servers": {},
|
||||
"results": [],
|
||||
"finished": False,
|
||||
}
|
||||
for hn in hostnames:
|
||||
job["servers"][hn] = {"hostname": hn, "stage": "pending", "detail": "En attente", "status": None}
|
||||
_audit_jobs[job_id] = job
|
||||
|
||||
def _run():
|
||||
threads = []
|
||||
for hn in hostnames:
|
||||
t = threading.Thread(target=_audit_one, args=(job, hn.strip()), daemon=True)
|
||||
threads.append(t)
|
||||
t.start()
|
||||
for t in threads:
|
||||
t.join()
|
||||
job["finished"] = True
|
||||
job["finished_at"] = _time.time()
|
||||
|
||||
threading.Thread(target=_run, daemon=True).start()
|
||||
return job_id
|
||||
|
||||
|
||||
def _audit_one(job, hostname):
|
||||
job["servers"][hostname]["stage"] = "resolving"
|
||||
job["servers"][hostname]["detail"] = "Résolution DNS"
|
||||
|
||||
target = _resolve(hostname)
|
||||
if not target:
|
||||
job["servers"][hostname]["stage"] = "failed"
|
||||
job["servers"][hostname]["detail"] = "DNS: aucun suffixe résolu"
|
||||
job["servers"][hostname]["status"] = "CONNECTION_FAILED"
|
||||
result = {"hostname": hostname, "status": "CONNECTION_FAILED",
|
||||
"connection_method": f"DNS: aucun suffixe résolu ({hostname})", "resolved_fqdn": None}
|
||||
job["results"].append(result)
|
||||
job["done"] += 1
|
||||
return
|
||||
|
||||
job["servers"][hostname]["stage"] = "connecting"
|
||||
job["servers"][hostname]["detail"] = f"Connexion SSH → {target}"
|
||||
|
||||
client = _connect(target)
|
||||
if not client:
|
||||
job["servers"][hostname]["stage"] = "failed"
|
||||
job["servers"][hostname]["detail"] = f"SSH refusé ({target})"
|
||||
job["servers"][hostname]["status"] = "CONNECTION_FAILED"
|
||||
result = {"hostname": hostname, "status": "CONNECTION_FAILED",
|
||||
"connection_method": f"SSH: connexion refusée ({target})", "resolved_fqdn": target}
|
||||
job["results"].append(result)
|
||||
job["done"] += 1
|
||||
return
|
||||
|
||||
job["servers"][hostname]["stage"] = "auditing"
|
||||
job["servers"][hostname]["detail"] = "Collecte des données"
|
||||
|
||||
result = {"hostname": hostname, "status": "OK", "resolved_fqdn": target,
|
||||
"audit_date": datetime.now().strftime("%Y-%m-%d %H:%M")}
|
||||
ssh_key, ssh_user = _get_ssh_settings()
|
||||
result["connection_method"] = f"ssh_key ({ssh_user}@{target})"
|
||||
|
||||
for key, cmd in AUDIT_CMDS.items():
|
||||
result[key] = _run(client, cmd)
|
||||
|
||||
try:
|
||||
client.close()
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
# Post-traitement
|
||||
agents = result.get("agents", "")
|
||||
result["qualys_active"] = "qualys" in agents and "active" in agents
|
||||
result["sentinelone_active"] = "sentinelone" in agents and "active" in agents
|
||||
result["disk_alert"] = False
|
||||
for line in (result.get("disk_space") or "").split("\n"):
|
||||
parts = line.split()
|
||||
pcts = [p for p in parts if "%" in p]
|
||||
if pcts:
|
||||
try:
|
||||
pct = int(pcts[0].replace("%", ""))
|
||||
if pct >= 90:
|
||||
result["disk_alert"] = True
|
||||
except ValueError:
|
||||
pass
|
||||
|
||||
job["servers"][hostname]["stage"] = "success"
|
||||
job["servers"][hostname]["detail"] = result.get("os_release", "OK")
|
||||
job["servers"][hostname]["status"] = "OK"
|
||||
job["results"].append(result)
|
||||
job["done"] += 1
|
||||
|
||||
|
||||
def get_audit_job(job_id):
|
||||
return _audit_jobs.get(job_id)
|
||||
|
||||
|
||||
def list_audit_jobs():
|
||||
now = _time.time()
|
||||
return {jid: j for jid, j in _audit_jobs.items() if now - j["started_at"] < 3600}
|
||||
|
||||
|
||||
def save_audit_to_db(db, results):
|
||||
"""Sauvegarde/met a jour les resultats d'audit en base"""
|
||||
updated = 0
|
||||
@ -274,9 +392,10 @@ def save_audit_to_db(db, results):
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
from .itop_service import _normalize_os_for_itop
|
||||
updates = {}
|
||||
if r.get("os_release"):
|
||||
updates["os_version"] = r["os_release"].strip()
|
||||
updates["os_version"] = _normalize_os_for_itop(r["os_release"].strip())
|
||||
if ip_addr:
|
||||
updates["fqdn"] = resolved
|
||||
|
||||
|
||||
@ -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()
|
||||
@ -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
|
||||
@ -115,15 +115,17 @@ def list_servers(db, filters, page=1, per_page=50, sort="hostname", sort_dir="as
|
||||
if filters.get("tier"):
|
||||
where.append("s.tier = :tier"); params["tier"] = filters["tier"]
|
||||
if filters.get("etat"):
|
||||
if filters["etat"] == "eol":
|
||||
where.append("s.licence_support = 'eol'")
|
||||
if filters["etat"] == "obsolete":
|
||||
where.append("s.licence_support = 'obsolete'")
|
||||
else:
|
||||
where.append("s.etat = :etat"); params["etat"] = filters["etat"]
|
||||
where.append("COALESCE(s.licence_support, '') != 'eol'")
|
||||
where.append("COALESCE(s.licence_support, '') != 'obsolete'")
|
||||
if filters.get("os"):
|
||||
where.append("s.os_family = :os"); params["os"] = filters["os"]
|
||||
if filters.get("owner"):
|
||||
where.append("s.patch_os_owner = :owner"); params["owner"] = filters["owner"]
|
||||
if filters.get("application"):
|
||||
where.append("s.application_name = :application"); params["application"] = filters["application"]
|
||||
if filters.get("search"):
|
||||
where.append("s.hostname ILIKE :search"); params["search"] = f"%{filters['search']}%"
|
||||
|
||||
@ -136,14 +138,20 @@ def list_servers(db, filters, page=1, per_page=50, sort="hostname", sort_dir="as
|
||||
SELECT s.id, s.hostname, s.fqdn, d.name as domaine, e.name as environnement,
|
||||
z.name as zone, s.os_family, s.os_version, s.tier, s.etat,
|
||||
s.licence_support, s.patch_os_owner, s.responsable_nom, s.machine_type,
|
||||
s.application_name,
|
||||
CASE
|
||||
WHEN s.os_version ILIKE '%Red Hat%' THEN
|
||||
'Red Hat ' || COALESCE((regexp_match(s.os_version, '(\d+\.\d+)'))[1], '')
|
||||
WHEN s.os_version ILIKE '%Oracle%Linux%' THEN
|
||||
'Oracle ' || COALESCE((regexp_match(s.os_version, '(\d+\.\d+)'))[1], '')
|
||||
WHEN s.os_version ILIKE '%CentOS Stream%' THEN
|
||||
'CentOS Stream ' || COALESCE((regexp_match(s.os_version, '(\d+[\.\d]*)'))[1], '')
|
||||
WHEN s.os_version ILIKE '%CentOS%' THEN
|
||||
'CentOS ' || COALESCE((regexp_match(s.os_version, '(\d+\.\d[\d.]*)'))[1], '')
|
||||
WHEN s.os_version ILIKE '%Ubuntu%' THEN 'Ubuntu'
|
||||
'CentOS ' || COALESCE((regexp_match(s.os_version, '(\d+[\.\d]*)'))[1], '')
|
||||
WHEN s.os_version ILIKE '%Debian%' THEN
|
||||
'Debian ' || COALESCE((regexp_match(s.os_version, '(\d+)'))[1], '')
|
||||
WHEN s.os_version ILIKE '%Ubuntu%' THEN
|
||||
'Ubuntu ' || COALESCE((regexp_match(s.os_version, '(\d+\.\d+)'))[1], '')
|
||||
WHEN s.os_version ILIKE '%Windows Server 2022 Standard%' THEN '2022 Standard'
|
||||
WHEN s.os_version ILIKE '%Windows Server 2022 Datacenter%' THEN '2022 Datacenter'
|
||||
WHEN s.os_version ILIKE '%Windows Server 2019 Standard%' THEN '2019 Standard'
|
||||
|
||||
@ -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 %}
|
||||
@ -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> → <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 %}
|
||||
@ -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"><</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">></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 %}
|
||||
@ -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"><</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">></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 %}
|
||||
126
app/templates/audit_realtime_progress.html
Normal file
126
app/templates/audit_realtime_progress.html
Normal file
@ -0,0 +1,126 @@
|
||||
{% extends 'base.html' %}
|
||||
{% block title %}Audit en cours{% endblock %}
|
||||
{% block content %}
|
||||
<div class="flex justify-between items-center mb-4">
|
||||
<div>
|
||||
<a href="/audit" class="text-xs text-gray-500 hover:text-gray-300">← Retour audit</a>
|
||||
<h2 class="text-xl font-bold text-cyber-accent" id="page-title">Audit en cours...</h2>
|
||||
</div>
|
||||
<div class="flex gap-2" id="actions-zone" style="display:none">
|
||||
<form method="POST" action="/audit/realtime/save">
|
||||
<button class="btn-primary px-4 py-2 text-sm" onclick="return confirm('Mettre à jour la base avec ces résultats ?')">Mettre à jour la base</button>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- KPIs -->
|
||||
<div style="display:flex;gap:8px;margin-bottom:16px">
|
||||
<div class="card p-3 text-center" style="flex:1">
|
||||
<div class="text-2xl font-bold text-cyber-accent" id="kpi-total">{{ total }}</div>
|
||||
<div class="text-xs text-gray-500">Total</div>
|
||||
</div>
|
||||
<div class="card p-3 text-center" style="flex:1">
|
||||
<div class="text-2xl font-bold text-cyber-green" id="kpi-ok">0</div>
|
||||
<div class="text-xs text-gray-500">Connectés</div>
|
||||
</div>
|
||||
<div class="card p-3 text-center" style="flex:1">
|
||||
<div class="text-2xl font-bold text-cyber-red" id="kpi-fail">0</div>
|
||||
<div class="text-xs text-gray-500">Échoués</div>
|
||||
</div>
|
||||
<div class="card p-3 text-center" style="flex:1">
|
||||
<div class="text-2xl font-bold text-gray-400 font-mono" id="kpi-timer">0s</div>
|
||||
<div class="text-xs text-gray-500">Temps</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Barre de progression -->
|
||||
<div class="card p-4 mb-4">
|
||||
<div class="flex justify-between items-center mb-2">
|
||||
<span class="text-xs text-gray-400" id="progress-text">0 / {{ total }}</span>
|
||||
<span class="text-xs text-gray-500" id="progress-pct">0%</span>
|
||||
</div>
|
||||
<div style="background:#1e293b;border-radius:4px;height:10px;overflow:hidden">
|
||||
<div id="progress-bar" style="height:100%;background:#00ffc8;width:0%;transition:width 0.5s ease"></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Tableau progression par serveur -->
|
||||
<div class="card overflow-hidden">
|
||||
<table class="w-full table-cyber text-xs">
|
||||
<thead><tr>
|
||||
<th class="p-2 text-left">Hostname</th>
|
||||
<th class="p-2">Étape</th>
|
||||
<th class="p-2 text-left">Détail</th>
|
||||
</tr></thead>
|
||||
<tbody id="progress-body"></tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
var JOB_ID = '{{ job_id }}';
|
||||
var TOTAL = {{ total }};
|
||||
var _pollTimer = null;
|
||||
|
||||
var STAGE_MAP = {
|
||||
'pending': {label: 'En attente', cls: 'badge-gray', icon: '●'},
|
||||
'resolving': {label: 'Résolution DNS', cls: 'badge-yellow', icon: '↻'},
|
||||
'connecting': {label: 'Connexion SSH', cls: 'badge-yellow', icon: '↻'},
|
||||
'auditing': {label: 'Audit', cls: 'badge-yellow', icon: '↻'},
|
||||
'success': {label: 'OK', cls: 'badge-green', icon: '✓'},
|
||||
'failed': {label: 'Échec', cls: 'badge-red', icon: '✗'},
|
||||
};
|
||||
|
||||
function stageBadge(stage) {
|
||||
var s = STAGE_MAP[stage] || {label: stage, cls: 'badge-gray', icon: '?'};
|
||||
var anim = (stage !== 'pending' && stage !== 'success' && stage !== 'failed')
|
||||
? ' style="animation:pulse 1.5s ease-in-out infinite"' : '';
|
||||
return '<span class="badge ' + s.cls + '"' + anim + '>' + s.icon + ' ' + s.label + '</span>';
|
||||
}
|
||||
|
||||
function poll() {
|
||||
fetch('/audit/realtime/status/' + JOB_ID, {credentials: 'same-origin'})
|
||||
.then(function(r){ return r.json(); })
|
||||
.then(function(data){
|
||||
if (!data.ok) return;
|
||||
|
||||
var pct = TOTAL > 0 ? Math.round((data.done / TOTAL) * 100) : 0;
|
||||
document.getElementById('progress-bar').style.width = pct + '%';
|
||||
document.getElementById('progress-text').textContent = data.done + ' / ' + TOTAL;
|
||||
document.getElementById('progress-pct').textContent = pct + '%';
|
||||
document.getElementById('kpi-timer').textContent = data.elapsed + 's';
|
||||
|
||||
var ok = 0, fail = 0;
|
||||
var hostnames = Object.keys(data.servers).sort();
|
||||
var rows = '';
|
||||
hostnames.forEach(function(hn){
|
||||
var s = data.servers[hn];
|
||||
if (s.stage === 'success') ok++;
|
||||
else if (s.stage === 'failed') fail++;
|
||||
rows += '<tr class="border-t border-cyber-border/30">';
|
||||
rows += '<td class="p-2 font-mono">' + hn + '</td>';
|
||||
rows += '<td class="p-2 text-center">' + stageBadge(s.stage) + '</td>';
|
||||
rows += '<td class="p-2 text-gray-400 text-xs">' + (s.detail || '') + '</td>';
|
||||
rows += '</tr>';
|
||||
});
|
||||
document.getElementById('progress-body').innerHTML = rows;
|
||||
document.getElementById('kpi-ok').textContent = ok;
|
||||
document.getElementById('kpi-fail').textContent = fail;
|
||||
|
||||
if (data.finished) {
|
||||
if (_pollTimer) { clearInterval(_pollTimer); _pollTimer = null; }
|
||||
document.getElementById('page-title').innerHTML =
|
||||
'Audit terminé — <span class="text-cyber-green">' + ok + ' OK</span> / <span class="text-cyber-red">' + fail + ' échec(s)</span>';
|
||||
document.getElementById('progress-bar').style.background = fail > 0 ? '#ff3366' : '#00ffc8';
|
||||
document.getElementById('actions-zone').style.display = 'flex';
|
||||
}
|
||||
})
|
||||
.catch(function(){});
|
||||
}
|
||||
|
||||
poll();
|
||||
_pollTimer = setInterval(poll, 2000);
|
||||
</script>
|
||||
<style>
|
||||
@keyframes pulse { 0%,100%{opacity:1} 50%{opacity:0.5} }
|
||||
</style>
|
||||
{% endblock %}
|
||||
@ -36,6 +36,7 @@
|
||||
.inline-edit:focus { background: #0a0e17; border-color: #00d4ff; }
|
||||
.toast { position: fixed; bottom: 20px; right: 20px; padding: 12px 24px; border-radius: 8px; z-index: 1000; animation: fadeIn 0.3s; }
|
||||
@keyframes fadeIn { from { opacity: 0; transform: translateY(10px); } to { opacity: 1; transform: translateY(0); } }
|
||||
[x-cloak] { display: none !important; }
|
||||
</style>
|
||||
</head>
|
||||
<body class="min-h-screen" hx-headers='{"X-Requested-With": "htmx"}'>
|
||||
@ -49,31 +50,111 @@
|
||||
<h1 class="text-cyber-accent font-bold text-lg mt-1">PatchCenter</h1>
|
||||
<p class="text-xs text-gray-500">v2.0 — SecOps</p>
|
||||
</div>
|
||||
<nav class="flex-1 p-3 space-y-1">
|
||||
<nav class="flex-1 p-3 space-y-1" x-data='{
|
||||
open: localStorage.getItem("menu_open") || "",
|
||||
subOpen: localStorage.getItem("menu_sub_open") || "",
|
||||
toggle(k){ this.open = (this.open === k) ? "" : k; this.subOpen = ""; localStorage.setItem("menu_open", this.open); localStorage.setItem("menu_sub_open", ""); },
|
||||
toggleSub(k){ this.subOpen = (this.subOpen === k) ? "" : k; localStorage.setItem("menu_sub_open", this.subOpen); }
|
||||
}'>
|
||||
{% set p = perms if perms is defined else request.state.perms %}
|
||||
{% if p.servers or p.qualys or p.audit or p.planning %}<a href="/dashboard" class="block px-3 py-2 rounded-md text-sm hover:bg-cyber-border/30 {% if 'dashboard' in request.url.path %}bg-cyber-border/30 text-cyber-accent{% else %}text-gray-400{% endif %}">Dashboard</a>{% endif %}
|
||||
{% if p.servers %}<a href="/servers" class="block px-3 py-2 rounded-md text-sm hover:bg-cyber-border/30 {% if request.url.path == '/servers' or '/servers/' in request.url.path %}bg-cyber-border/30 text-cyber-accent{% else %}text-gray-400{% endif %}">Serveurs</a>{% endif %}
|
||||
{% set path = request.url.path %}
|
||||
|
||||
{% if p.campaigns %}<a href="/campaigns" class="block px-3 py-2 rounded-md text-sm hover:bg-cyber-border/30 {% if 'campaigns' in request.url.path and 'assignments' not in request.url.path %}bg-cyber-border/30 text-cyber-accent{% else %}text-gray-400{% endif %}">Campagnes</a>{% endif %}
|
||||
{% if p.campaigns in ('edit', 'admin') %}<a href="/assignments" class="block px-3 py-2 rounded-md text-sm hover:bg-cyber-border/30 {% if 'assignments' in request.url.path %}bg-cyber-border/30 text-cyber-accent{% else %}text-gray-400{% endif %} pl-6 text-xs">Assignations</a>{% endif %}
|
||||
{% if p.qualys %}<a href="/qualys/search" class="block px-3 py-2 rounded-md text-sm hover:bg-cyber-border/30 {% if '/qualys/' in request.url.path and 'tags' not in request.url.path and 'decoder' not in request.url.path %}bg-cyber-border/30 text-cyber-accent{% else %}text-gray-400{% endif %}">Qualys</a>{% endif %}
|
||||
{% if p.qualys %}<a href="/qualys/tags" class="block px-3 py-2 rounded-md text-sm hover:bg-cyber-border/30 {% if '/qualys/tags' in request.url.path %}bg-cyber-border/30 text-cyber-accent{% else %}text-gray-400{% endif %} pl-6 text-xs">Tags</a>{% endif %}
|
||||
{% if p.qualys %}<a href="/qualys/decoder" class="block px-3 py-2 rounded-md text-sm hover:bg-cyber-border/30 {% if 'decoder' in request.url.path %}bg-cyber-border/30 text-cyber-accent{% else %}text-gray-400{% endif %} pl-6 text-xs">Décodeur</a>{% endif %}
|
||||
{% if p.qualys %}<a href="/qualys/agents" class="block px-3 py-2 rounded-md text-sm hover:bg-cyber-border/30 {% if 'agents' in request.url.path and 'deploy' not in request.url.path %}bg-cyber-border/30 text-cyber-accent{% else %}text-gray-400{% endif %} pl-6 text-xs">Agents</a>{% endif %}
|
||||
{% if p.qualys in ('edit', 'admin') %}<a href="/qualys/deploy" class="block px-3 py-2 rounded-md text-sm hover:bg-cyber-border/30 {% if 'deploy' in request.url.path %}bg-cyber-border/30 text-cyber-accent{% else %}text-gray-400{% endif %} pl-6 text-xs">Déployer Agent</a>{% endif %}
|
||||
{% if p.campaigns %}<a href="/safe-patching" class="block px-3 py-2 rounded-md text-sm hover:bg-cyber-border/30 {% if 'safe-patching' in request.url.path %}bg-cyber-border/30 text-cyber-accent{% else %}text-gray-400{% endif %}">Safe Patching</a>{% endif %}
|
||||
{% if p.campaigns or p.quickwin %}<a href="/quickwin" class="block px-3 py-2 rounded-md text-sm hover:bg-cyber-border/30 {% if 'quickwin' in request.url.path and 'safe' not in request.url.path and 'config' not in request.url.path and 'correspondance' not in request.url.path %}bg-cyber-border/30 text-cyber-accent{% else %}text-gray-400{% endif %}">QuickWin</a>{% endif %}
|
||||
{% if p.campaigns or p.quickwin %}<a href="/quickwin/config" class="block px-3 py-2 rounded-md text-sm hover:bg-cyber-border/30 {% if '/quickwin/config' in request.url.path %}bg-cyber-border/30 text-cyber-accent{% else %}text-gray-400{% endif %} pl-6 text-xs">Config exclusions</a>{% endif %}
|
||||
{% if p.campaigns or p.quickwin %}<a href="/quickwin/correspondance" class="block px-3 py-2 rounded-md text-sm hover:bg-cyber-border/30 {% if 'correspondance' in request.url.path %}bg-cyber-border/30 text-cyber-accent{% else %}text-gray-400{% endif %} pl-6 text-xs">Correspondance</a>{% endif %}
|
||||
{% if p.planning %}<a href="/planning" class="block px-3 py-2 rounded-md text-sm hover:bg-cyber-border/30 {% if 'planning' in request.url.path %}bg-cyber-border/30 text-cyber-accent{% else %}text-gray-400{% endif %}">Planning</a>{% endif %}
|
||||
{% if p.audit %}<a href="/audit" class="block px-3 py-2 rounded-md text-sm hover:bg-cyber-border/30 {% if request.url.path == '/audit' %}bg-cyber-border/30 text-cyber-accent{% else %}text-gray-400{% endif %}">Audit</a>{% endif %}
|
||||
{% if p.audit in ('edit', 'admin') %}<a href="/audit/specific" class="block px-3 py-2 rounded-md text-sm hover:bg-cyber-border/30 {% if 'specific' in request.url.path %}bg-cyber-border/30 text-cyber-accent{% else %}text-gray-400{% endif %} pl-6 text-xs">Spécifique</a>{% endif %}
|
||||
{% if p.audit %}<a href="/audit-full" class="block px-3 py-2 rounded-md text-sm hover:bg-cyber-border/30 {% if request.url.path == '/audit-full' or (request.url.path.startswith('/audit-full/') and 'patching' not in request.url.path and 'flow-map' not in request.url.path) %}bg-cyber-border/30 text-cyber-accent{% else %}text-gray-400{% endif %} pl-6 text-xs">Complet</a>{% endif %}
|
||||
{% if p.audit %}<a href="/audit-full/patching" class="block px-3 py-2 rounded-md text-sm hover:bg-cyber-border/30 {% if 'patching' in request.url.path %}bg-cyber-border/30 text-cyber-accent{% else %}text-gray-400{% endif %} pl-6 text-xs">Patching</a>{% endif %}
|
||||
{% if p.servers %}<a href="/contacts" class="block px-3 py-2 rounded-md text-sm hover:bg-cyber-border/30 {% if 'contacts' in request.url.path %}bg-cyber-border/30 text-cyber-accent{% else %}text-gray-400{% endif %}">Contacts</a>{% endif %}
|
||||
{% if p.users %}<a href="/users" class="block px-3 py-2 rounded-md text-sm hover:bg-cyber-border/30 {% if 'users' in request.url.path %}bg-cyber-border/30 text-cyber-accent{% else %}text-gray-400{% endif %}">Utilisateurs</a>{% endif %}
|
||||
{% if p.settings %}<a href="/settings" class="block px-3 py-2 rounded-md text-sm hover:bg-cyber-border/30 {% if 'settings' in request.url.path %}bg-cyber-border/30 text-cyber-accent{% else %}text-gray-400{% endif %}">Settings</a>{% endif %}
|
||||
{% if p.settings %}<a href="/referentiel" class="block px-3 py-2 rounded-md text-sm hover:bg-cyber-border/30 {% if 'referentiel' in request.url.path %}bg-cyber-border/30 text-cyber-accent{% else %}text-gray-400{% endif %} pl-6 text-xs">Référentiel</a>{% endif %}
|
||||
{# Dashboard principal #}
|
||||
{% if p.servers or p.qualys or p.audit or p.planning %}<a href="/dashboard" class="block px-3 py-2 rounded-md text-sm hover:bg-cyber-border/30 {% if 'dashboard' in path %}bg-cyber-border/30 text-cyber-accent{% else %}text-gray-400{% endif %}">Dashboard</a>{% endif %}
|
||||
|
||||
{# Serveurs (groupe repliable avec Correspondance) #}
|
||||
{% if p.servers %}
|
||||
<div>
|
||||
<button @click="toggle('servers')" class="w-full flex justify-between items-center px-3 py-2 rounded-md text-sm hover:bg-cyber-border/30 text-cyber-accent font-bold">
|
||||
<span>Serveurs</span>
|
||||
<span x-text="open === 'servers' ? '▾' : '▸'" class="text-xs"></span>
|
||||
</button>
|
||||
<div x-show="open === 'servers'" x-cloak class="space-y-1 pl-1">
|
||||
<a href="/servers" class="block px-3 py-1.5 rounded-md text-xs hover:bg-cyber-border/30 {% if path == '/servers' or path.startswith('/servers/') %}bg-cyber-border/30 text-cyber-accent{% else %}text-gray-400{% endif %} pl-6">Liste</a>
|
||||
{% if p.campaigns or p.quickwin %}<a href="/patching/correspondance" class="block px-3 py-1.5 rounded-md text-xs hover:bg-cyber-border/30 {% if '/patching/correspondance' in path %}bg-cyber-border/30 text-cyber-accent{% else %}text-gray-400{% endif %} pl-6">Correspondance prod ↔ hors-prod</a>{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
{# ===== PATCHING (groupe repliable) ===== #}
|
||||
{% if p.campaigns or p.planning or p.quickwin %}
|
||||
<div>
|
||||
<button @click="toggle('patching')" class="w-full flex justify-between items-center px-3 py-2 rounded-md text-sm hover:bg-cyber-border/30 text-cyber-accent font-bold">
|
||||
<span>Patching</span>
|
||||
<span x-text="open === 'patching' ? '▾' : '▸'" class="text-xs"></span>
|
||||
</button>
|
||||
<div x-show="open === 'patching'" x-cloak class="space-y-1 pl-1">
|
||||
{% if p.planning %}<a href="/planning" class="block px-3 py-1.5 rounded-md text-xs hover:bg-cyber-border/30 {% if 'planning' in path %}bg-cyber-border/30 text-cyber-accent{% else %}text-gray-400{% endif %} pl-6">Planning</a>{% endif %}
|
||||
{% if p.campaigns in ('edit', 'admin') %}<a href="/assignments" class="block px-3 py-1.5 rounded-md text-xs hover:bg-cyber-border/30 {% if 'assignments' in path %}bg-cyber-border/30 text-cyber-accent{% else %}text-gray-400{% endif %} pl-6">Assignation</a>{% endif %}
|
||||
{% if p.campaigns %}<a href="/campaigns" class="block px-3 py-1.5 rounded-md text-xs hover:bg-cyber-border/30 {% if 'campaigns' in path and 'assignments' not in path %}bg-cyber-border/30 text-cyber-accent{% else %}text-gray-400{% endif %} pl-6">Campagnes</a>{% endif %}
|
||||
{% if p.servers in ('edit','admin') or p.campaigns in ('edit','admin') or p.quickwin in ('edit','admin') %}<a href="/patching/config-exclusions" class="block px-3 py-1.5 rounded-md text-xs hover:bg-cyber-border/30 {% if 'config-exclusions' in path %}bg-cyber-border/30 text-cyber-accent{% else %}text-gray-400{% endif %} pl-6">Config exclusions</a>{% endif %}
|
||||
{% if p.campaigns in ('edit','admin') or p.quickwin in ('edit','admin') %}<a href="/patching/validations" class="block px-3 py-1.5 rounded-md text-xs hover:bg-cyber-border/30 {% if '/patching/validations' in path %}bg-cyber-border/30 text-cyber-accent{% else %}text-gray-400{% endif %} pl-6">Validations</a>{% endif %}
|
||||
|
||||
{# Quickwin sous-groupe #}
|
||||
{% if p.campaigns or p.quickwin %}
|
||||
<div>
|
||||
<button @click="toggleSub('quickwin')" class="w-full flex justify-between items-center px-3 py-1.5 rounded-md text-xs hover:bg-cyber-border/30 pl-6 {% if 'quickwin' in path %}text-cyber-accent{% else %}text-gray-400{% endif %}">
|
||||
<span>QuickWin</span>
|
||||
<span x-text="subOpen === 'quickwin' ? '▾' : '▸'" class="text-xs opacity-60"></span>
|
||||
</button>
|
||||
<div x-show="subOpen === 'quickwin'" x-cloak class="space-y-1">
|
||||
<a href="/quickwin" style="padding-left:3rem" class="block py-1 pr-3 rounded-md text-xs hover:bg-cyber-border/30 {% if 'quickwin' in path and 'config' not in path and 'correspondance' not in path %}bg-cyber-border/30 text-cyber-accent{% else %}text-gray-500{% endif %}">Vue d'ensemble</a>
|
||||
{% if p.quickwin in ('edit', 'admin') or p.campaigns in ('edit', 'admin') %}
|
||||
<a href="/quickwin/config" style="padding-left:3rem" class="block py-1 pr-3 rounded-md text-xs hover:bg-cyber-border/30 {% if '/quickwin/config' in path %}bg-cyber-border/30 text-cyber-accent{% else %}text-gray-500{% endif %}">Config exclusion</a>
|
||||
<a href="/quickwin/correspondance" style="padding-left:3rem" class="block py-1 pr-3 rounded-md text-xs hover:bg-cyber-border/30 {% if 'correspondance' in path %}bg-cyber-border/30 text-cyber-accent{% else %}text-gray-500{% endif %}">Correspondance</a>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
{# ===== AUDIT (au meme niveau que Patching, repliable) ===== #}
|
||||
{% if p.audit %}
|
||||
<div>
|
||||
<button @click="toggle('audit')" class="w-full flex justify-between items-center px-3 py-2 rounded-md text-sm hover:bg-cyber-border/30 text-cyber-accent font-bold">
|
||||
<span>Audit</span>
|
||||
<span x-text="open === 'audit' ? '▾' : '▸'" class="text-xs"></span>
|
||||
</button>
|
||||
<div x-show="open === 'audit'" x-cloak class="space-y-1 pl-1">
|
||||
<a href="/audit" class="block px-3 py-1.5 rounded-md text-xs hover:bg-cyber-border/30 {% if path == '/audit' %}bg-cyber-border/30 text-cyber-accent{% else %}text-gray-400{% endif %} pl-6">Audit global</a>
|
||||
{% if p.audit in ('edit', 'admin') %}<a href="/audit/specific" class="block px-3 py-1.5 rounded-md text-xs hover:bg-cyber-border/30 {% if 'specific' in path %}bg-cyber-border/30 text-cyber-accent{% else %}text-gray-400{% endif %} pl-6">Spécifique</a>{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
{# ===== QUALYS (groupe repliable) ===== #}
|
||||
{% if p.qualys %}
|
||||
<div>
|
||||
<button @click="toggle('qualys')" class="w-full flex justify-between items-center px-3 py-2 rounded-md text-sm hover:bg-cyber-border/30 text-cyber-accent font-bold">
|
||||
<span>Qualys</span>
|
||||
<span x-text="open === 'qualys' ? '▾' : '▸'" class="text-xs"></span>
|
||||
</button>
|
||||
<div x-show="open === 'qualys'" x-cloak class="space-y-1 pl-1">
|
||||
<a href="/qualys/search" class="block px-3 py-1.5 rounded-md text-xs hover:bg-cyber-border/30 {% if '/qualys/search' in path %}bg-cyber-border/30 text-cyber-accent{% else %}text-gray-400{% endif %} pl-6">Recherche</a>
|
||||
<a href="/qualys/tags" class="block px-3 py-1.5 rounded-md text-xs hover:bg-cyber-border/30 {% if '/qualys/tags' in path %}bg-cyber-border/30 text-cyber-accent{% else %}text-gray-400{% endif %} pl-6">Tags</a>
|
||||
<a href="/qualys/agents" class="block px-3 py-1.5 rounded-md text-xs hover:bg-cyber-border/30 {% if 'agents' in path and 'deploy' not in path %}bg-cyber-border/30 text-cyber-accent{% else %}text-gray-400{% endif %} pl-6">Agents</a>
|
||||
{% if p.qualys in ('edit', 'admin') %}<a href="/qualys/deploy" class="block px-3 py-1.5 rounded-md text-xs hover:bg-cyber-border/30 {% if 'deploy' in path %}bg-cyber-border/30 text-cyber-accent{% else %}text-gray-400{% endif %} pl-6">Déployer Agent</a>{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
{# ===== ADMIN (groupe repliable) ===== #}
|
||||
{% if p.users or p.settings or p.servers or p.contacts %}
|
||||
<div>
|
||||
<button @click="toggle('admin')" class="w-full flex justify-between items-center px-3 py-2 rounded-md text-sm hover:bg-cyber-border/30 text-cyber-accent font-bold">
|
||||
<span>Administration</span>
|
||||
<span x-text="open === 'admin' ? '▾' : '▸'" class="text-xs"></span>
|
||||
</button>
|
||||
<div x-show="open === 'admin'" x-cloak class="space-y-1 pl-1">
|
||||
{% if p.servers or p.contacts %}<a href="/contacts" class="block px-3 py-1.5 rounded-md text-xs hover:bg-cyber-border/30 {% if 'contacts' in path %}bg-cyber-border/30 text-cyber-accent{% else %}text-gray-400{% endif %} pl-6">Contacts</a>{% endif %}
|
||||
{% if p.users %}<a href="/users" class="block px-3 py-1.5 rounded-md text-xs hover:bg-cyber-border/30 {% if 'users' in path %}bg-cyber-border/30 text-cyber-accent{% else %}text-gray-400{% endif %} pl-6">Utilisateurs</a>{% endif %}
|
||||
{% if p.settings %}<a href="/settings" class="block px-3 py-1.5 rounded-md text-xs hover:bg-cyber-border/30 {% if 'settings' in path %}bg-cyber-border/30 text-cyber-accent{% else %}text-gray-400{% endif %} pl-6">Settings</a>{% endif %}
|
||||
{% if p.settings or p.referentiel %}<a href="/referentiel" class="block px-3 py-1.5 rounded-md text-xs hover:bg-cyber-border/30 {% if 'referentiel' in path %}bg-cyber-border/30 text-cyber-accent{% else %}text-gray-400{% endif %} pl-6">Référentiel</a>{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
{% endif %}
|
||||
</nav>
|
||||
</aside>
|
||||
<main class="flex-1 flex flex-col overflow-hidden">
|
||||
|
||||
@ -213,7 +213,7 @@ function showForm(id, type) {
|
||||
<span class="badge {% if s.status == 'patched' %}badge-green{% elif s.status == 'failed' %}badge-red{% elif s.status == 'excluded' %}badge-gray{% elif s.status == 'in_progress' %}badge-yellow{% else %}badge-gray{% endif %}">{{ s.status }}</span>
|
||||
{% if s.exclusion_reason %}
|
||||
<div class="text-[9px] text-gray-500" title="{{ s.exclusion_detail or '' }}">
|
||||
{% if s.exclusion_reason == 'eol' %}EOL{% elif s.exclusion_reason == 'creneau_inadequat' %}Prereq KO{% elif s.exclusion_reason == 'non_patchable' %}Non patchable{% else %}{{ s.exclusion_reason }}{% endif %}
|
||||
{% if s.exclusion_reason == 'obsolete' %}EOL{% elif s.exclusion_reason == 'creneau_inadequat' %}Prereq KO{% elif s.exclusion_reason == 'non_patchable' %}Non patchable{% else %}{{ s.exclusion_reason }}{% endif %}
|
||||
{% if s.excluded_by %}<span class="text-gray-600">({{ s.excluded_by }})</span>{% endif %}
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
54
app/templates/change_password.html
Normal file
54
app/templates/change_password.html
Normal file
@ -0,0 +1,54 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="fr">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>{{ app_name }} - Changement de mot de passe</title>
|
||||
<link rel="icon" type="image/svg+xml" href="/static/favicon.svg">
|
||||
<link rel="stylesheet" href="/static/css/tailwind.css">
|
||||
<style>
|
||||
body { background: #0a0e17; color: #e2e8f0; font-family: 'Segoe UI', system-ui, sans-serif; }
|
||||
.card { background: #111827; border: 1px solid #1e3a5f; border-radius: 8px; }
|
||||
.btn-primary { background: #00d4ff; color: #0a0e17; font-weight: 600; border-radius: 6px; }
|
||||
.btn-primary:hover { background: #00b8e6; }
|
||||
input { background: #0a0e17; border: 1px solid #1e3a5f; color: #e2e8f0; border-radius: 6px; padding: 6px 12px; font-size: 0.85rem; }
|
||||
input:focus { outline: none; border-color: #00d4ff; box-shadow: 0 0 0 2px #00d4ff33; }
|
||||
</style>
|
||||
</head>
|
||||
<body class="min-h-screen">
|
||||
<div class="min-h-screen flex items-center justify-center">
|
||||
<div class="card p-8 w-96">
|
||||
<div class="text-center mb-6">
|
||||
<img src="/static/logo_sanef.jpg" alt="SANEF" class="h-12 mx-auto mb-3 rounded" style="opacity:0.9">
|
||||
<h1 class="text-xl font-bold text-cyber-accent">Changement de mot de passe requis</h1>
|
||||
<p class="text-xs text-gray-500 mt-1">Bienvenue <b>{{ user.sub }}</b></p>
|
||||
<p class="text-xs text-gray-500 mt-1">Vous devez définir un nouveau mot de passe avant de continuer.</p>
|
||||
</div>
|
||||
|
||||
{% if error %}
|
||||
<div class="bg-cyber-red/20 text-cyber-red text-sm p-3 rounded mb-4">{{ error }}</div>
|
||||
{% endif %}
|
||||
|
||||
<form method="POST" action="/me/change-password" class="space-y-4">
|
||||
<div>
|
||||
<label class="text-xs text-gray-400 block mb-1">Mot de passe actuel</label>
|
||||
<input type="password" name="current_password" required autofocus class="w-full">
|
||||
</div>
|
||||
<div>
|
||||
<label class="text-xs text-gray-400 block mb-1">Nouveau mot de passe <span class="text-gray-600">(min 8 caractères)</span></label>
|
||||
<input type="password" name="new_password" required minlength="8" class="w-full">
|
||||
</div>
|
||||
<div>
|
||||
<label class="text-xs text-gray-400 block mb-1">Confirmer le nouveau mot de passe</label>
|
||||
<input type="password" name="confirm_password" required minlength="8" class="w-full">
|
||||
</div>
|
||||
<button type="submit" class="btn-primary w-full py-2 rounded-md">Enregistrer</button>
|
||||
</form>
|
||||
|
||||
<div class="mt-4 text-center">
|
||||
<a href="/logout" class="text-xs text-gray-500 hover:text-cyber-accent">Se déconnecter</a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</body>
|
||||
</html>
|
||||
@ -51,7 +51,7 @@
|
||||
<th class="p-2">Rôle</th>
|
||||
<th class="text-left p-2">Scopes</th>
|
||||
<th class="p-2">Actif</th>
|
||||
<th class="p-2">Actions</th>
|
||||
{% if can_edit_contacts %}<th class="p-2">Actions</th>{% endif %}
|
||||
</tr></thead>
|
||||
<tbody>
|
||||
{% for c in contacts %}
|
||||
@ -61,6 +61,7 @@
|
||||
<td class="p-2 text-center"><span class="badge {% if 'responsable' in c.role %}badge-blue{% elif 'referent' in c.role %}badge-yellow{% elif 'ra_' in c.role %}badge-green{% else %}badge-gray{% endif %}">{{ c.role }}</span></td>
|
||||
<td class="p-2 text-xs text-gray-400" style="max-width:300px">{{ (c.scopes_summary or '-')[:80] }}</td>
|
||||
<td class="p-2 text-center"><span class="badge {% if c.is_active %}badge-green{% else %}badge-red{% endif %}">{{ 'Oui' if c.is_active else 'Non' }}</span></td>
|
||||
{% if can_edit_contacts %}
|
||||
<td class="p-2 text-center">
|
||||
<div class="flex gap-1 justify-center">
|
||||
<button class="btn-sm bg-cyber-border text-cyber-accent"
|
||||
@ -71,12 +72,14 @@
|
||||
</form>
|
||||
</div>
|
||||
</td>
|
||||
{% endif %}
|
||||
</tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
{% if can_edit_contacts %}
|
||||
<!-- Ajouter -->
|
||||
<div class="card p-4 mt-4">
|
||||
<h4 class="text-sm font-bold text-cyber-accent mb-3">Ajouter un contact</h4>
|
||||
@ -98,4 +101,5 @@
|
||||
<button type="submit" class="btn-primary px-4 py-1 text-sm">Ajouter</button>
|
||||
</form>
|
||||
</div>
|
||||
{% endif %}
|
||||
{% endblock %}
|
||||
|
||||
@ -31,6 +31,21 @@
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
{% if ldap_enabled %}
|
||||
<div>
|
||||
<label class="text-xs text-gray-400 block mb-1">Méthode</label>
|
||||
<div class="flex gap-4">
|
||||
<label class="flex items-center gap-1 text-sm text-gray-300 cursor-pointer">
|
||||
<input type="radio" name="auth_method" value="local" checked class="accent-cyan-500"> Locale
|
||||
</label>
|
||||
<label class="flex items-center gap-1 text-sm text-gray-300 cursor-pointer">
|
||||
<input type="radio" name="auth_method" value="ldap" class="accent-cyan-500"> LDAP/AD
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
{% else %}
|
||||
<input type="hidden" name="auth_method" value="local">
|
||||
{% endif %}
|
||||
<button type="submit" class="btn-primary w-full py-2 rounded-md">Connexion</button>
|
||||
</form>
|
||||
<p class="text-center text-xs text-gray-600 mt-4">SANEF — Direction des Systèmes d'Information</p>
|
||||
|
||||
@ -28,7 +28,7 @@
|
||||
</tr></thead>
|
||||
<tbody>
|
||||
{% for s in servers %}
|
||||
<tr class="{% if s.licence_support == 'eol' %}bg-red-900/10{% elif s.licence_support == 'els' %}bg-yellow-900/10{% endif %}">
|
||||
<tr class="{% if s.licence_support == 'obsolete' %}bg-red-900/10{% elif s.licence_support == 'els' %}bg-yellow-900/10{% endif %}">
|
||||
<td class="p-1 text-center"><input type="checkbox" name="include_{{ s.id }}" checked></td>
|
||||
<td class="p-1 font-mono text-cyber-accent">{{ s.hostname }}</td>
|
||||
<td class="p-1 text-center">{{ s.domaine or '-' }}</td>
|
||||
@ -36,7 +36,7 @@
|
||||
<td class="p-1 text-center">{{ s.os_family or '-' }}</td>
|
||||
<td class="p-1 text-center"><span class="badge {% if s.tier == 'tier0' %}badge-red{% elif s.tier == 'tier1' %}badge-yellow{% else %}badge-blue{% endif %}">{{ s.tier }}</span></td>
|
||||
<td class="p-1 text-center">{{ s.ssh_method or '-' }}</td>
|
||||
<td class="p-1 text-center"><span class="badge {% if s.licence_support == 'active' %}badge-green{% elif s.licence_support == 'eol' %}badge-red{% else %}badge-yellow{% endif %}">{{ s.licence_support }}</span></td>
|
||||
<td class="p-1 text-center"><span class="badge {% if s.licence_support == 'active' %}badge-green{% elif s.licence_support == 'obsolete' %}badge-red{% else %}badge-yellow{% endif %}">{{ s.licence_support }}</span></td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
|
||||
@ -51,7 +51,7 @@
|
||||
<div class="flex justify-between"><span class="text-gray-500">Environnement</span><span class="badge {% if s.environnement == 'Production' %}badge-green{% else %}badge-yellow{% endif %}">{{ s.environnement }}</span></div>
|
||||
<div class="flex justify-between"><span class="text-gray-500">Zone</span><span class="badge {% if s.zone == 'DMZ' %}badge-red{% else %}badge-blue{% endif %}">{{ s.zone or 'LAN' }}</span></div>
|
||||
<div class="flex justify-between"><span class="text-gray-500">Tier</span><span class="badge {% if s.tier == 'tier0' %}badge-red{% elif s.tier == 'tier1' %}badge-yellow{% else %}badge-blue{% endif %}">{{ s.tier }}</span></div>
|
||||
<div class="flex justify-between"><span class="text-gray-500">Etat</span><span class="badge {% if s.etat == 'en_production' %}badge-green{% elif s.etat == 'decommissionne' %}badge-red{% else %}badge-yellow{% endif %}">{{ s.etat }}</span></div>
|
||||
<div class="flex justify-between"><span class="text-gray-500">Etat</span><span class="badge {% if s.etat == 'production' %}badge-green{% elif s.etat == 'obsolete' %}badge-red{% else %}badge-yellow{% endif %}">{{ s.etat }}</span></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@ -61,7 +61,7 @@
|
||||
<div class="space-y-1 text-sm">
|
||||
<div class="flex justify-between"><span class="text-gray-500">OS</span><span>{{ s.os_family or '-' }}</span></div>
|
||||
<div class="text-xs text-gray-400 mt-1">{{ s.os_version or '' }}</div>
|
||||
<div class="flex justify-between"><span class="text-gray-500">Licence</span><span class="badge {% if s.licence_support == 'active' %}badge-green{% elif s.licence_support == 'eol' %}badge-red{% else %}badge-yellow{% endif %}">{{ s.licence_support }}</span></div>
|
||||
<div class="flex justify-between"><span class="text-gray-500">Licence</span><span class="badge {% if s.licence_support == 'active' %}badge-green{% elif s.licence_support == 'obsolete' %}badge-red{% else %}badge-yellow{% endif %}">{{ s.licence_support }}</span></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@ -88,6 +88,46 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Correspondance prod ↔ hors-prod -->
|
||||
{% if links and (links.as_prod or links.as_nonprod) %}
|
||||
<div class="mb-4">
|
||||
<h4 class="text-xs text-cyber-accent font-bold uppercase mb-2 border-b border-cyber-border pb-1">Correspondance Prod ↔ Hors-Prod</h4>
|
||||
{% if links.as_prod %}
|
||||
<div class="text-xs mb-2">
|
||||
<div class="text-cyber-green font-bold mb-1">Ce serveur est un PROD — hors-prod liés :</div>
|
||||
<ul class="ml-2 space-y-1">
|
||||
{% for l in links.as_prod %}
|
||||
<li class="flex gap-2 items-center">
|
||||
<span class="font-mono text-cyber-accent">{{ l.hostname }}</span>
|
||||
<span class="badge badge-yellow" style="font-size:9px">{{ l.env_name or l.environment_code or '?' }}</span>
|
||||
<span class="text-gray-600" style="font-size:9px">{{ l.source }}</span>
|
||||
</li>
|
||||
{% endfor %}
|
||||
</ul>
|
||||
</div>
|
||||
{% endif %}
|
||||
{% if links.as_nonprod %}
|
||||
<div class="text-xs mb-2">
|
||||
<div class="text-cyber-yellow font-bold mb-1">Ce serveur est un HORS-PROD — prod(s) lié(s) :</div>
|
||||
<ul class="ml-2 space-y-1">
|
||||
{% for l in links.as_nonprod %}
|
||||
<li class="flex gap-2 items-center">
|
||||
<span class="font-mono text-cyber-accent">{{ l.hostname }}</span>
|
||||
<span class="badge badge-green" style="font-size:9px">{{ l.env_name or 'Production' }}</span>
|
||||
</li>
|
||||
{% endfor %}
|
||||
</ul>
|
||||
</div>
|
||||
{% endif %}
|
||||
<a href="/patching/correspondance?search={{ s.hostname }}" class="text-xs text-cyber-accent hover:underline">Gérer dans le builder →</a>
|
||||
</div>
|
||||
{% else %}
|
||||
<div class="mb-4">
|
||||
<h4 class="text-xs text-cyber-accent font-bold uppercase mb-2 border-b border-cyber-border pb-1">Correspondance</h4>
|
||||
<p class="text-xs text-gray-500">Aucune correspondance prod ↔ hors-prod définie. <a href="/patching/correspondance?search={{ s.hostname }}" class="text-cyber-accent hover:underline">Créer →</a></p>
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
{% if s.mode_operatoire %}
|
||||
<div class="mb-4">
|
||||
<h4 class="text-xs text-cyber-accent font-bold uppercase mb-2 border-b border-cyber-border pb-1">Mode operatoire</h4>
|
||||
|
||||
@ -58,7 +58,7 @@
|
||||
<div>
|
||||
<label class="text-xs text-gray-500">Etat</label>
|
||||
<select name="etat" class="w-full">
|
||||
{% for e in ['en_production','en_implementation','en_decommissionnement','decommissionne'] %}<option value="{{ e }}" {% if e == s.etat %}selected{% endif %}>{{ e }}</option>{% endfor %}
|
||||
{% for e in ['production','implementation','stock','obsolete'] %}<option value="{{ e }}" {% if e == s.etat %}selected{% endif %}>{{ e }}</option>{% endfor %}
|
||||
</select>
|
||||
</div>
|
||||
<div>
|
||||
@ -67,6 +67,13 @@
|
||||
{% for o in ['secops','ipop','editeur','tiers','na','a_definir'] %}<option value="{{ o }}" {% if o == s.patch_os_owner %}selected{% endif %}>{{ o }}</option>{% endfor %}
|
||||
</select>
|
||||
</div>
|
||||
<div>
|
||||
<label class="text-xs text-gray-500">Solution applicative (iTop) <span class="text-gray-600">— modifie aussi iTop</span></label>
|
||||
<select name="application_id" class="w-full">
|
||||
<option value="">-- Aucune --</option>
|
||||
{% for a in applications %}<option value="{{ a.id }}" {% if a.id == s.application_id %}selected{% endif %}>{{ a.nom_court }}</option>{% endfor %}
|
||||
</select>
|
||||
</div>
|
||||
<div>
|
||||
<label class="text-xs text-gray-500">Responsable</label>
|
||||
<input type="text" name="responsable_nom" value="{{ s.responsable_nom or '' }}" class="w-full">
|
||||
|
||||
225
app/templates/patching_config_exclusions.html
Normal file
225
app/templates/patching_config_exclusions.html
Normal file
@ -0,0 +1,225 @@
|
||||
{% extends 'base.html' %}
|
||||
{% block title %}Patching — Config exclusions{% endblock %}
|
||||
{% block content %}
|
||||
<div class="flex justify-between items-center mb-4">
|
||||
<div>
|
||||
<h2 class="text-xl font-bold text-cyber-accent">Config exclusions — par serveur</h2>
|
||||
<p class="text-xs text-gray-500 mt-1">Exclusions de packages lors du <code>yum update</code>. Stockées dans iTop (champ <code>patch_excludes</code>) et poussées en temps réel.</p>
|
||||
</div>
|
||||
<div class="flex gap-2">
|
||||
<a href="/quickwin/config" class="btn-sm bg-cyber-border text-gray-300 px-3 py-2">Packages reboot (QuickWin)</a>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{% if msg %}
|
||||
<div class="mb-3 p-2 rounded text-sm bg-green-900/30 text-cyber-green">{{ msg }}</div>
|
||||
{% endif %}
|
||||
|
||||
<!-- KPIs -->
|
||||
<div class="flex gap-2 mb-4">
|
||||
<div class="card p-3 text-center" style="flex:1"><div class="text-2xl font-bold text-cyber-accent">{{ stats.total_servers }}</div><div class="text-xs text-gray-500">Serveurs total</div></div>
|
||||
<div class="card p-3 text-center" style="flex:1"><div class="text-2xl font-bold text-cyber-green">{{ stats.with_excludes }}</div><div class="text-xs text-gray-500">Avec exclusions</div></div>
|
||||
<div class="card p-3 text-center" style="flex:1"><div class="text-2xl font-bold text-cyber-yellow">{{ stats.total_servers - stats.with_excludes }}</div><div class="text-xs text-gray-500">Sans exclusions</div></div>
|
||||
<div class="card p-3 text-center" style="flex:1"><div class="text-2xl font-bold text-cyber-blue">{{ total }}</div><div class="text-xs text-gray-500">Filtrés</div></div>
|
||||
</div>
|
||||
|
||||
<!-- Filtres -->
|
||||
<div class="card p-3 mb-4">
|
||||
<form method="GET" class="flex gap-2 items-center flex-wrap">
|
||||
<input type="text" name="search" value="{{ filters.search }}" placeholder="Hostname..." class="text-xs py-1 px-2" style="width:200px">
|
||||
<select name="domain" class="text-xs py-1 px-2" style="width:150px">
|
||||
<option value="">Tous domaines</option>
|
||||
{% for d in domains %}<option value="{{ d.code }}" {% if filters.domain == d.code %}selected{% endif %}>{{ d.name }}</option>{% endfor %}
|
||||
</select>
|
||||
<select name="env" class="text-xs py-1 px-2" style="width:130px">
|
||||
<option value="">Tous envs</option>
|
||||
{% for e in envs %}<option value="{{ e.code }}" {% if filters.env == e.code %}selected{% endif %}>{{ e.name }}</option>{% endfor %}
|
||||
</select>
|
||||
<select name="zone" class="text-xs py-1 px-2" style="width:100px">
|
||||
<option value="">Toutes zones</option>
|
||||
{% for z in zones %}<option value="{{ z }}" {% if filters.zone == z %}selected{% endif %}>{{ z }}</option>{% endfor %}
|
||||
</select>
|
||||
<select name="tier" class="text-xs py-1 px-2" style="width:90px">
|
||||
<option value="">Tier</option>
|
||||
{% for t in ['tier0','tier1','tier2','tier3'] %}<option value="{{ t }}" {% if filters.tier == t %}selected{% endif %}>{{ t }}</option>{% endfor %}
|
||||
</select>
|
||||
<select name="os" class="text-xs py-1 px-2" style="width:100px">
|
||||
<option value="">Tous OS</option>
|
||||
<option value="linux" {% if filters.os == 'linux' %}selected{% endif %}>Linux</option>
|
||||
<option value="windows" {% if filters.os == 'windows' %}selected{% endif %}>Windows</option>
|
||||
</select>
|
||||
<select name="application" class="text-xs py-1 px-2" style="width:220px">
|
||||
<option value="">Toutes solutions applicatives</option>
|
||||
{% for a in applications %}<option value="{{ a.application_name }}" {% if filters.application == a.application_name %}selected{% endif %}>{{ a.application_name }} ({{ a.c }})</option>{% endfor %}
|
||||
</select>
|
||||
<select name="has_excludes" class="text-xs py-1 px-2" style="width:140px">
|
||||
<option value="">Tous</option>
|
||||
<option value="yes" {% if filters.has_excludes == 'yes' %}selected{% endif %}>Avec exclusions</option>
|
||||
<option value="no" {% if filters.has_excludes == 'no' %}selected{% endif %}>Sans exclusions</option>
|
||||
</select>
|
||||
<button type="submit" class="btn-primary px-3 py-1 text-xs">Filtrer</button>
|
||||
<a href="/patching/config-exclusions" class="text-xs text-gray-500 hover:text-cyber-accent">Reset</a>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
<!-- Bulk actions -->
|
||||
<div id="bulk-bar" class="card p-3 mb-2" style="display:none">
|
||||
<div class="flex gap-2 items-center flex-wrap mb-2">
|
||||
<span class="text-xs text-gray-400"><b id="bulk-count">0</b> sélectionné(s)</span>
|
||||
<span class="text-xs text-gray-500 font-bold ml-2">Exclusions :</span>
|
||||
<input type="text" id="bulk-pattern" placeholder="pattern (ex: oracle*, nginx*)" class="text-xs py-1 px-2" style="width:220px">
|
||||
<button onclick="bulkAction('add')" class="btn-sm bg-cyber-green text-black">Ajouter</button>
|
||||
<button onclick="bulkAction('remove')" class="btn-sm bg-red-900/40 text-cyber-red">Retirer</button>
|
||||
<button onclick="bulkAction('replace')" class="btn-sm bg-cyber-border text-cyber-accent">Remplacer tout</button>
|
||||
</div>
|
||||
<div class="flex gap-2 items-center flex-wrap">
|
||||
<span class="text-xs text-gray-500 font-bold">Solution applicative :</span>
|
||||
<select id="bulk-app" class="text-xs py-1 px-2" style="max-width:260px">
|
||||
<option value="">-- Aucune (désassocier) --</option>
|
||||
{% for a in all_apps %}<option value="{{ a.id }}">{{ a.nom_court }}</option>{% endfor %}
|
||||
</select>
|
||||
<button onclick="bulkChangeApp()" class="btn-sm bg-cyber-blue text-black">Appliquer à tous les sélectionnés</button>
|
||||
</div>
|
||||
<div id="bulk-result" class="text-xs text-gray-400 mt-2"></div>
|
||||
</div>
|
||||
|
||||
<!-- Table -->
|
||||
<div class="card overflow-x-auto">
|
||||
<table class="w-full table-cyber text-xs">
|
||||
<thead><tr>
|
||||
<th class="p-2 w-8"><input type="checkbox" id="check-all" onchange="toggleAll(this)"></th>
|
||||
<th class="text-left p-2">Hostname</th>
|
||||
<th class="text-left p-2">Solution applicative</th>
|
||||
<th class="p-2">Domaine</th>
|
||||
<th class="p-2">Env</th>
|
||||
<th class="p-2">Zone</th>
|
||||
<th class="p-2">Tier</th>
|
||||
<th class="p-2">OS</th>
|
||||
<th class="text-left p-2" style="min-width:300px">Exclusions</th>
|
||||
<th class="p-2">Action</th>
|
||||
</tr></thead>
|
||||
<tbody>
|
||||
{% for s in servers %}
|
||||
<tr id="row-{{ s.id }}" class="border-t border-cyber-border/30">
|
||||
<td class="p-2 text-center"><input type="checkbox" class="srv-check" value="{{ s.id }}" onchange="updateBulk()"></td>
|
||||
<td class="p-2 font-mono text-cyber-accent">{{ s.hostname }}</td>
|
||||
<td class="p-2 text-xs text-gray-300" title="{{ s.application_name or '' }}">{{ (s.application_name or '-')[:40] }}</td>
|
||||
<td class="p-2 text-center text-gray-400">{{ s.domain_name or '-' }}</td>
|
||||
<td class="p-2 text-center">{{ s.env_name or '-' }}</td>
|
||||
<td class="p-2 text-center">{{ s.zone_name or '-' }}</td>
|
||||
<td class="p-2 text-center">{{ s.tier or '-' }}</td>
|
||||
<td class="p-2 text-center">{{ s.os_family or '-' }}</td>
|
||||
<td class="p-2">
|
||||
<textarea id="excl-{{ s.id }}" rows="2" class="w-full font-mono text-xs" placeholder="ex: oracle* tomcat* (un ou plusieurs patterns séparés par espace ou retour ligne)" style="resize:vertical;min-height:36px">{{ s.patch_excludes or '' }}</textarea>
|
||||
</td>
|
||||
<td class="p-2 text-center">
|
||||
<button onclick="saveExcl({{ s.id }})" class="btn-sm bg-cyber-border text-cyber-accent">Sauver</button>
|
||||
<span id="status-{{ s.id }}" class="text-xs ml-1"></span>
|
||||
</td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
<!-- Pagination -->
|
||||
{% if total_pages > 1 %}
|
||||
<div class="flex justify-center gap-2 mt-4">
|
||||
{% for p in range(1, total_pages + 1) %}
|
||||
<a href="?page={{ p }}{% for k,v in filters.items() %}{% if v %}&{{ k }}={{ v }}{% endif %}{% endfor %}"
|
||||
class="btn-sm {% if p == page %}bg-cyber-accent text-black{% else %}bg-cyber-border text-gray-300{% endif %} px-2 py-1">{{ p }}</a>
|
||||
{% endfor %}
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
<script>
|
||||
function toggleAll(cb) {
|
||||
document.querySelectorAll('.srv-check').forEach(c => c.checked = cb.checked);
|
||||
updateBulk();
|
||||
}
|
||||
|
||||
function updateBulk() {
|
||||
const ids = Array.from(document.querySelectorAll('.srv-check:checked')).map(c => parseInt(c.value));
|
||||
const bar = document.getElementById('bulk-bar');
|
||||
const count = document.getElementById('bulk-count');
|
||||
count.textContent = ids.length;
|
||||
bar.style.display = ids.length > 0 ? 'flex' : 'none';
|
||||
window._selectedIds = ids;
|
||||
}
|
||||
|
||||
function saveExcl(id) {
|
||||
const inp = document.getElementById('excl-' + id);
|
||||
const status = document.getElementById('status-' + id);
|
||||
status.textContent = '…'; status.className = 'text-xs ml-1 text-gray-400';
|
||||
const fd = new FormData();
|
||||
fd.append('patch_excludes', inp.value);
|
||||
fetch('/patching/config-exclusions/' + id + '/save', {method: 'POST', credentials: 'same-origin', body: fd})
|
||||
.then(r => r.json())
|
||||
.then(d => {
|
||||
if (d.ok) {
|
||||
status.textContent = d.itop && d.itop.pushed ? '✓ iTop' : '⚠ local';
|
||||
status.className = 'text-xs ml-1 ' + (d.itop && d.itop.pushed ? 'text-cyber-green' : 'text-cyber-yellow');
|
||||
status.title = d.itop ? d.itop.msg : '';
|
||||
} else {
|
||||
status.textContent = '✗';
|
||||
status.className = 'text-xs ml-1 text-cyber-red';
|
||||
status.title = d.msg || '';
|
||||
}
|
||||
})
|
||||
.catch(e => { status.textContent = '✗'; status.className = 'text-xs ml-1 text-cyber-red'; status.title = e.message; });
|
||||
}
|
||||
|
||||
function bulkChangeApp() {
|
||||
const ids = window._selectedIds || [];
|
||||
const app = document.getElementById('bulk-app').value;
|
||||
const appName = document.getElementById('bulk-app').selectedOptions[0].text;
|
||||
if (!ids.length) return alert('Aucun serveur sélectionné');
|
||||
if (!confirm('Changer solution applicative vers "' + appName + '" sur ' + ids.length + ' serveur(s) ?')) return;
|
||||
const res = document.getElementById('bulk-result');
|
||||
res.textContent = 'En cours...'; res.className = 'text-xs text-gray-400 mt-2';
|
||||
fetch('/patching/config-exclusions/bulk-application', {
|
||||
method: 'POST', credentials: 'same-origin',
|
||||
headers: {'Content-Type': 'application/json'},
|
||||
body: JSON.stringify({server_ids: ids, application_id: app})
|
||||
})
|
||||
.then(r => r.json())
|
||||
.then(d => {
|
||||
if (d.ok) {
|
||||
res.innerHTML = '✓ ' + d.updated + ' serveurs → ' + d.app_name + ' — iTop: <b class="text-cyber-green">' + d.itop_pushed + '</b> OK / <b class="text-cyber-red">' + d.itop_errors + '</b> KO';
|
||||
res.className = 'text-xs mt-2';
|
||||
setTimeout(() => location.reload(), 2000);
|
||||
} else {
|
||||
res.textContent = '✗ ' + (d.msg || 'Erreur'); res.className = 'text-xs text-cyber-red mt-2';
|
||||
}
|
||||
})
|
||||
.catch(e => { res.textContent = '✗ ' + e.message; res.className = 'text-xs text-cyber-red mt-2'; });
|
||||
}
|
||||
|
||||
function bulkAction(action) {
|
||||
const ids = window._selectedIds || [];
|
||||
const pattern = document.getElementById('bulk-pattern').value.trim();
|
||||
if (!ids.length) return alert('Aucun serveur sélectionné');
|
||||
if (action !== 'replace' && !pattern) return alert('Saisir un pattern');
|
||||
const label = action === 'add' ? 'Ajouter' : action === 'remove' ? 'Retirer' : 'Remplacer tout par';
|
||||
if (!confirm(label + ' "' + pattern + '" sur ' + ids.length + ' serveur(s) ?')) return;
|
||||
const res = document.getElementById('bulk-result');
|
||||
res.textContent = 'En cours...'; res.className = 'text-xs text-gray-400 ml-2';
|
||||
fetch('/patching/config-exclusions/bulk', {
|
||||
method: 'POST', credentials: 'same-origin',
|
||||
headers: {'Content-Type': 'application/json'},
|
||||
body: JSON.stringify({server_ids: ids, pattern: pattern, action: action})
|
||||
})
|
||||
.then(r => r.json())
|
||||
.then(d => {
|
||||
if (d.ok) {
|
||||
res.innerHTML = '✓ ' + d.updated + ' serveurs mis à jour — iTop: <b class="text-cyber-green">' + d.itop_pushed + '</b> OK / <b class="text-cyber-red">' + d.itop_errors + '</b> KO';
|
||||
res.className = 'text-xs ml-2';
|
||||
setTimeout(() => location.reload(), 2000);
|
||||
} else {
|
||||
res.textContent = '✗ ' + (d.msg || 'Erreur'); res.className = 'text-xs text-cyber-red ml-2';
|
||||
}
|
||||
})
|
||||
.catch(e => { res.textContent = '✗ ' + e.message; res.className = 'text-xs text-cyber-red ml-2'; });
|
||||
}
|
||||
</script>
|
||||
{% endblock %}
|
||||
302
app/templates/patching_correspondance.html
Normal file
302
app/templates/patching_correspondance.html
Normal file
@ -0,0 +1,302 @@
|
||||
{% extends 'base.html' %}
|
||||
{% block title %}Builder correspondance prod ↔ hors-prod{% endblock %}
|
||||
{% block content %}
|
||||
<div class="flex justify-between items-center mb-4">
|
||||
<div>
|
||||
<h2 class="text-xl font-bold text-cyber-accent">Builder correspondance Prod ↔ Hors-Prod</h2>
|
||||
<p class="text-xs text-gray-500 mt-1">Filtrer les serveurs, les désigner comme <b class="text-cyber-green">Prod</b> ou <b class="text-cyber-yellow">Non-Prod</b>, puis générer les liens en masse.</p>
|
||||
</div>
|
||||
<div class="flex gap-2">
|
||||
<a href="/patching/validations" class="btn-sm bg-cyber-border text-cyber-accent px-4 py-2">Validations</a>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- KPIs -->
|
||||
<div class="flex gap-2 mb-4">
|
||||
<div class="card p-3 text-center" style="flex:1"><div class="text-2xl font-bold text-cyber-blue">{{ stats.total_links }}</div><div class="text-xs text-gray-500">Liens existants (toutes apps)</div></div>
|
||||
<div class="card p-3 text-center" style="flex:1"><div class="text-2xl font-bold text-cyber-accent">{{ stats.filtered }}</div><div class="text-xs text-gray-500">Serveurs filtrés</div></div>
|
||||
<div class="card p-3 text-center" style="flex:1"><div class="text-2xl font-bold text-cyber-green" id="selected-prod-count">0</div><div class="text-xs text-gray-500">Marqués PROD</div></div>
|
||||
<div class="card p-3 text-center" style="flex:1"><div class="text-2xl font-bold text-cyber-yellow" id="selected-nonprod-count">0</div><div class="text-xs text-gray-500">Marqués NON-PROD</div></div>
|
||||
</div>
|
||||
|
||||
<!-- Filtres -->
|
||||
<div class="card p-3 mb-4">
|
||||
<form method="GET" class="flex gap-2 items-center flex-wrap">
|
||||
<input type="text" name="search" value="{{ search }}" placeholder="Hostname..." class="text-xs py-1 px-2" style="width:180px">
|
||||
<select name="application" class="text-xs py-1 px-2" style="max-width:260px">
|
||||
<option value="">Toutes applications</option>
|
||||
{% for a in applications %}<option value="{{ a.application_name }}" {% if application == a.application_name %}selected{% endif %}>{{ a.application_name }}</option>{% endfor %}
|
||||
</select>
|
||||
<select name="domain" class="text-xs py-1 px-2" style="width:160px">
|
||||
<option value="">Tous domaines</option>
|
||||
{% for d in domains %}<option value="{{ d }}" {% if domain == d %}selected{% endif %}>{{ d }}</option>{% endfor %}
|
||||
</select>
|
||||
<select name="env" class="text-xs py-1 px-2" style="width:140px">
|
||||
<option value="">Tous envs</option>
|
||||
{% for e in envs %}<option value="{{ e }}" {% if env == e %}selected{% endif %}>{{ e }}</option>{% endfor %}
|
||||
</select>
|
||||
<button type="submit" class="btn-primary px-3 py-1 text-xs">Filtrer</button>
|
||||
<a href="/patching/correspondance" class="text-xs text-gray-500 hover:text-cyber-accent">Reset</a>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
{% if can_edit %}
|
||||
<!-- Barre actions bulk -->
|
||||
<div class="card p-3 mb-2" id="bulk-bar" style="display:none">
|
||||
<div class="flex gap-2 items-center flex-wrap mb-2">
|
||||
<span class="text-xs text-gray-400"><b id="bulk-count">0</b> sélectionné(s)</span>
|
||||
</div>
|
||||
<!-- Section 1: Normaliser iTop (env + app) -->
|
||||
<div class="flex gap-2 items-center flex-wrap mb-2" style="border-left:3px solid #00d4ff;padding-left:8px">
|
||||
<span class="text-xs text-cyber-accent font-bold">Normaliser iTop :</span>
|
||||
<select id="bulk-env" class="text-xs py-1 px-2" style="width:180px">
|
||||
<option value="">-- Changer env vers --</option>
|
||||
{% for e in envs %}<option value="{{ e }}">{{ e }}</option>{% endfor %}
|
||||
</select>
|
||||
<button onclick="bulkChangeEnv()" class="btn-sm bg-cyber-blue text-black">Appliquer env</button>
|
||||
<span class="text-gray-600">|</span>
|
||||
<select id="bulk-app" class="text-xs py-1 px-2" style="max-width:260px">
|
||||
<option value="">-- Changer solution app. vers --</option>
|
||||
<option value="__none__">Aucune (désassocier)</option>
|
||||
{% for a in all_apps %}<option value="{{ a.id }}">{{ a.nom_court }}</option>{% endfor %}
|
||||
</select>
|
||||
<button onclick="bulkChangeApp()" class="btn-sm bg-cyber-blue text-black">Appliquer app.</button>
|
||||
</div>
|
||||
<!-- Section 2: Correspondance -->
|
||||
<div class="flex gap-2 items-center flex-wrap" style="border-left:3px solid #00ff88;padding-left:8px">
|
||||
<span class="text-xs text-cyber-green font-bold">Marquer pour correspondance :</span>
|
||||
<button onclick="markSelected('prod')" class="btn-sm bg-cyber-green text-black">Marquer PROD</button>
|
||||
<button onclick="markSelected('nonprod')" class="btn-sm bg-cyber-yellow text-black">Marquer NON-PROD</button>
|
||||
<button onclick="markSelected('none')" class="btn-sm bg-cyber-border text-gray-300">Démarquer</button>
|
||||
</div>
|
||||
<div id="bulk-result" class="text-xs mt-2"></div>
|
||||
</div>
|
||||
|
||||
<!-- Générer correspondances -->
|
||||
<div class="card p-4 mb-4" style="border-color:#00d4ff55">
|
||||
<div class="flex gap-3 items-center">
|
||||
<div style="flex:1">
|
||||
<b class="text-cyber-accent">Générer correspondances</b>
|
||||
<p class="text-xs text-gray-400 mt-1">
|
||||
<span class="text-cyber-green" id="preview-prod">0 prod</span> ×
|
||||
<span class="text-cyber-yellow" id="preview-nonprod">0 non-prod</span> =
|
||||
<b class="text-cyber-accent" id="preview-links">0 liens</b>
|
||||
</p>
|
||||
</div>
|
||||
<button onclick="generateCorrespondances()" class="btn-primary px-4 py-2 text-sm" id="btn-generate" disabled>Créer les correspondances</button>
|
||||
</div>
|
||||
<div id="gen-result" class="text-xs mt-2"></div>
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
<!-- Tableau -->
|
||||
<div class="card overflow-x-auto">
|
||||
<table class="w-full table-cyber text-xs">
|
||||
<thead><tr>
|
||||
{% if can_edit %}<th class="p-2 w-8"><input type="checkbox" onchange="toggleAll(this)"></th>{% endif %}
|
||||
<th class="p-2 text-left">Hostname</th>
|
||||
<th class="p-2">Env</th>
|
||||
<th class="p-2 text-left">Application</th>
|
||||
<th class="p-2">Domaine</th>
|
||||
<th class="p-2">Zone</th>
|
||||
<th class="p-2">Liens existants</th>
|
||||
{% if can_edit %}<th class="p-2">Rôle</th>{% endif %}
|
||||
</tr></thead>
|
||||
<tbody>
|
||||
{% for s in servers %}
|
||||
<tr class="border-t border-cyber-border/30" id="row-{{ s.id }}" data-env="{{ s.env_name or '' }}">
|
||||
{% if can_edit %}<td class="p-2 text-center"><input type="checkbox" class="srv-check" value="{{ s.id }}" onchange="updateBulk()"></td>{% endif %}
|
||||
<td class="p-2 font-mono text-cyber-accent">{{ s.hostname }}</td>
|
||||
<td class="p-2 text-center">
|
||||
{% if s.env_name == 'Production' %}<span class="badge badge-green">{{ s.env_name }}</span>
|
||||
{% elif s.env_name %}<span class="badge badge-yellow">{{ s.env_name }}</span>
|
||||
{% else %}<span class="text-gray-600">-</span>{% endif %}
|
||||
</td>
|
||||
<td class="p-2 text-xs text-gray-300" title="{{ s.application_name or '' }}">{{ (s.application_name or '-')[:35] }}</td>
|
||||
<td class="p-2 text-center text-gray-400">{{ s.domain_name or '-' }}</td>
|
||||
<td class="p-2 text-center text-gray-400">{{ s.zone_name or '-' }}</td>
|
||||
<td class="p-2 text-center">
|
||||
{% if s.n_as_prod %}<span class="badge badge-green" style="font-size:9px" title="Liés comme prod">{{ s.n_as_prod }}P</span>{% endif %}
|
||||
{% if s.n_as_nonprod %}<span class="badge badge-yellow" style="font-size:9px" title="Liés comme non-prod">{{ s.n_as_nonprod }}N</span>{% endif %}
|
||||
{% if not s.n_as_prod and not s.n_as_nonprod %}<span class="text-gray-600">-</span>{% endif %}
|
||||
</td>
|
||||
{% if can_edit %}
|
||||
<td class="p-2 text-center">
|
||||
<span id="role-{{ s.id }}" class="badge badge-gray" style="font-size:9px">—</span>
|
||||
</td>
|
||||
{% endif %}
|
||||
</tr>
|
||||
{% endfor %}
|
||||
{% if not servers %}
|
||||
<tr><td colspan="8" class="p-6 text-center text-gray-500">Aucun serveur pour ces filtres</td></tr>
|
||||
{% endif %}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
{% if can_edit %}
|
||||
<script>
|
||||
const markedProd = new Set();
|
||||
const markedNonProd = new Set();
|
||||
|
||||
function toggleAll(cb) {
|
||||
document.querySelectorAll('.srv-check').forEach(c => c.checked = cb.checked);
|
||||
updateBulk();
|
||||
}
|
||||
|
||||
function updateBulk() {
|
||||
const checked = Array.from(document.querySelectorAll('.srv-check:checked')).map(c => parseInt(c.value));
|
||||
const bar = document.getElementById('bulk-bar');
|
||||
bar.style.display = checked.length > 0 ? 'block' : 'none';
|
||||
document.getElementById('bulk-count').textContent = checked.length;
|
||||
window._selectedIds = checked;
|
||||
}
|
||||
|
||||
function markSelected(role) {
|
||||
const ids = window._selectedIds || [];
|
||||
if (!ids.length) return;
|
||||
// Contrôle de cohérence
|
||||
const warnings = [];
|
||||
ids.forEach(id => {
|
||||
const row = document.getElementById('row-' + id);
|
||||
const env = row ? (row.dataset.env || '') : '';
|
||||
if (role === 'prod' && env !== 'Production') {
|
||||
warnings.push('⚠ ' + row.querySelector('.font-mono').textContent.trim() + ' (env: ' + (env || 'aucun') + ') n\'est pas en Production');
|
||||
} else if (role === 'nonprod' && env === 'Production') {
|
||||
warnings.push('⚠ ' + row.querySelector('.font-mono').textContent.trim() + ' est en Production, pas hors-prod');
|
||||
}
|
||||
});
|
||||
if (warnings.length > 0) {
|
||||
if (!confirm('Incohérence détectée :\n\n' + warnings.join('\n') +
|
||||
'\n\nVoulez-vous continuer quand même ?\n(Recommandé : corriger d\'abord l\'environnement via "Normaliser iTop")')) return;
|
||||
}
|
||||
|
||||
ids.forEach(id => {
|
||||
const badge = document.getElementById('role-' + id);
|
||||
const row = document.getElementById('row-' + id);
|
||||
if (role === 'prod') {
|
||||
markedProd.add(id); markedNonProd.delete(id);
|
||||
badge.className = 'badge badge-green';
|
||||
badge.textContent = 'PROD';
|
||||
row.style.background = 'rgba(0, 255, 136, 0.05)';
|
||||
} else if (role === 'nonprod') {
|
||||
markedNonProd.add(id); markedProd.delete(id);
|
||||
badge.className = 'badge badge-yellow';
|
||||
badge.textContent = 'NON-PROD';
|
||||
row.style.background = 'rgba(255, 204, 0, 0.05)';
|
||||
} else {
|
||||
markedProd.delete(id); markedNonProd.delete(id);
|
||||
badge.className = 'badge badge-gray'; badge.style.fontSize = '9px';
|
||||
badge.textContent = '—';
|
||||
row.style.background = '';
|
||||
}
|
||||
});
|
||||
updateCounters();
|
||||
// Décocher
|
||||
document.querySelectorAll('.srv-check').forEach(c => c.checked = false);
|
||||
document.querySelector('thead input[type=checkbox]').checked = false;
|
||||
updateBulk();
|
||||
}
|
||||
|
||||
function updateCounters() {
|
||||
const np = markedProd.size, nn = markedNonProd.size;
|
||||
document.getElementById('selected-prod-count').textContent = np;
|
||||
document.getElementById('selected-nonprod-count').textContent = nn;
|
||||
document.getElementById('preview-prod').textContent = np + ' prod';
|
||||
document.getElementById('preview-nonprod').textContent = nn + ' non-prod';
|
||||
document.getElementById('preview-links').textContent = (np * nn) + ' liens';
|
||||
document.getElementById('btn-generate').disabled = (np === 0 || nn === 0);
|
||||
}
|
||||
|
||||
function bulkChangeEnv() {
|
||||
const ids = window._selectedIds || [];
|
||||
const env = document.getElementById('bulk-env').value;
|
||||
if (!ids.length) return alert('Aucun serveur sélectionné');
|
||||
if (!env) return alert('Choisir un environnement');
|
||||
if (!confirm('Changer l\'environnement vers "' + env + '" sur ' + ids.length + ' serveur(s) ?\n(PatchCenter + push iTop)')) return;
|
||||
const res = document.getElementById('bulk-result');
|
||||
res.textContent = 'Changement env en cours...'; res.className = 'text-xs mt-2 text-gray-400';
|
||||
fetch('/patching/correspondance/bulk-env', {
|
||||
method: 'POST', credentials: 'same-origin',
|
||||
headers: {'Content-Type': 'application/json'},
|
||||
body: JSON.stringify({server_ids: ids, env_name: env})
|
||||
})
|
||||
.then(r => r.json())
|
||||
.then(d => {
|
||||
if (d.ok) {
|
||||
res.innerHTML = '✓ ' + d.updated + ' → ' + d.env_name + ' — iTop: <b class="text-cyber-green">' + d.itop_pushed + '</b> OK / <b class="text-cyber-red">' + d.itop_errors + '</b> KO';
|
||||
res.className = 'text-xs mt-2';
|
||||
setTimeout(() => location.reload(), 1500);
|
||||
} else {
|
||||
res.textContent = '✗ ' + (d.msg || 'Erreur'); res.className = 'text-xs mt-2 text-cyber-red';
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
function bulkChangeApp() {
|
||||
const ids = window._selectedIds || [];
|
||||
let app = document.getElementById('bulk-app').value;
|
||||
if (!ids.length) return alert('Aucun serveur sélectionné');
|
||||
if (!app) return alert('Choisir une application');
|
||||
const appText = document.getElementById('bulk-app').selectedOptions[0].text;
|
||||
if (!confirm('Changer solution applicative vers "' + appText + '" sur ' + ids.length + ' serveur(s) ?\n(PatchCenter + push iTop)')) return;
|
||||
if (app === '__none__') app = '';
|
||||
const res = document.getElementById('bulk-result');
|
||||
res.textContent = 'Changement app en cours...'; res.className = 'text-xs mt-2 text-gray-400';
|
||||
fetch('/patching/correspondance/bulk-application', {
|
||||
method: 'POST', credentials: 'same-origin',
|
||||
headers: {'Content-Type': 'application/json'},
|
||||
body: JSON.stringify({server_ids: ids, application_id: app})
|
||||
})
|
||||
.then(r => r.json())
|
||||
.then(d => {
|
||||
if (d.ok) {
|
||||
res.innerHTML = '✓ ' + d.updated + ' → ' + d.app_name + ' — iTop: <b class="text-cyber-green">' + d.itop_pushed + '</b> OK / <b class="text-cyber-red">' + d.itop_errors + '</b> KO';
|
||||
res.className = 'text-xs mt-2';
|
||||
setTimeout(() => location.reload(), 1500);
|
||||
} else {
|
||||
res.textContent = '✗ ' + (d.msg || 'Erreur'); res.className = 'text-xs mt-2 text-cyber-red';
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
function generateCorrespondances() {
|
||||
if (markedProd.size === 0 || markedNonProd.size === 0) return;
|
||||
const n = markedProd.size * markedNonProd.size;
|
||||
if (!confirm('Créer ' + n + ' correspondances ?')) return;
|
||||
|
||||
// Récupérer les env labels des non-prod depuis le data-env de chaque row
|
||||
const envLabels = {};
|
||||
markedNonProd.forEach(id => {
|
||||
const row = document.getElementById('row-' + id);
|
||||
if (row) envLabels[id] = row.dataset.env || '';
|
||||
});
|
||||
|
||||
const res = document.getElementById('gen-result');
|
||||
res.textContent = 'En cours...'; res.className = 'text-xs mt-2 text-gray-400';
|
||||
|
||||
fetch('/patching/correspondance/bulk-create', {
|
||||
method: 'POST', credentials: 'same-origin',
|
||||
headers: {'Content-Type': 'application/json'},
|
||||
body: JSON.stringify({
|
||||
prod_ids: Array.from(markedProd),
|
||||
nonprod_ids: Array.from(markedNonProd),
|
||||
env_labels: envLabels,
|
||||
})
|
||||
})
|
||||
.then(r => r.json())
|
||||
.then(d => {
|
||||
if (d.ok) {
|
||||
res.innerHTML = '✓ ' + d.created + ' liens créés, ' + d.skipped + ' déjà existants';
|
||||
res.className = 'text-xs mt-2 text-cyber-green';
|
||||
setTimeout(() => location.reload(), 2000);
|
||||
} else {
|
||||
res.textContent = '✗ ' + (d.msg || 'Erreur');
|
||||
res.className = 'text-xs mt-2 text-cyber-red';
|
||||
}
|
||||
})
|
||||
.catch(e => { res.textContent = '✗ ' + e.message; res.className = 'text-xs mt-2 text-cyber-red'; });
|
||||
}
|
||||
</script>
|
||||
{% endif %}
|
||||
{% endblock %}
|
||||
179
app/templates/patching_validations.html
Normal file
179
app/templates/patching_validations.html
Normal file
@ -0,0 +1,179 @@
|
||||
{% extends 'base.html' %}
|
||||
{% block title %}Validations post-patching{% endblock %}
|
||||
{% block content %}
|
||||
<div class="flex justify-between items-center mb-4">
|
||||
<div>
|
||||
<h2 class="text-xl font-bold text-cyber-accent">Validations post-patching</h2>
|
||||
<p class="text-xs text-gray-500 mt-1">Serveurs patchés en attente de validation par les responsables applicatifs.</p>
|
||||
</div>
|
||||
<a href="/patching/correspondance" class="btn-sm bg-cyber-border text-cyber-accent px-4 py-2">Correspondance</a>
|
||||
</div>
|
||||
|
||||
<!-- KPIs -->
|
||||
<div class="flex gap-2 mb-4">
|
||||
<a href="?status=en_attente" class="card p-3 text-center hover:border-cyber-accent" style="flex:1">
|
||||
<div class="text-2xl font-bold text-cyber-yellow">{{ stats.en_attente }}</div>
|
||||
<div class="text-xs text-gray-500">En attente</div>
|
||||
</a>
|
||||
<a href="?status=validated_ok" class="card p-3 text-center hover:border-cyber-accent" style="flex:1">
|
||||
<div class="text-2xl font-bold text-cyber-green">{{ stats.validated_ok }}</div>
|
||||
<div class="text-xs text-gray-500">Validés OK</div>
|
||||
</a>
|
||||
<a href="?status=validated_ko" class="card p-3 text-center hover:border-cyber-accent" style="flex:1">
|
||||
<div class="text-2xl font-bold text-cyber-red">{{ stats.validated_ko }}</div>
|
||||
<div class="text-xs text-gray-500">KO</div>
|
||||
</a>
|
||||
<a href="?status=forced" class="card p-3 text-center hover:border-cyber-accent" style="flex:1">
|
||||
<div class="text-2xl font-bold text-cyber-blue">{{ stats.forced }}</div>
|
||||
<div class="text-xs text-gray-500">Forcés</div>
|
||||
</a>
|
||||
</div>
|
||||
|
||||
<!-- Filtres -->
|
||||
<div class="card p-3 mb-4">
|
||||
<form method="GET" class="flex gap-2 items-center flex-wrap">
|
||||
<select name="status" class="text-xs py-1 px-2">
|
||||
<option value="en_attente" {% if status == 'en_attente' %}selected{% endif %}>En attente</option>
|
||||
<option value="validated_ok" {% if status == 'validated_ok' %}selected{% endif %}>Validés OK</option>
|
||||
<option value="validated_ko" {% if status == 'validated_ko' %}selected{% endif %}>Validés KO</option>
|
||||
<option value="forced" {% if status == 'forced' %}selected{% endif %}>Forcés</option>
|
||||
</select>
|
||||
<select name="env" class="text-xs py-1 px-2">
|
||||
<option value="">Tous environnements</option>
|
||||
{% for e in envs %}<option value="{{ e }}" {% if env == e %}selected{% endif %}>{{ e }}</option>{% endfor %}
|
||||
</select>
|
||||
{% if campaign_id %}<input type="hidden" name="campaign_id" value="{{ campaign_id }}">{% endif %}
|
||||
<button type="submit" class="btn-primary px-3 py-1 text-xs">Filtrer</button>
|
||||
<a href="/patching/validations" class="text-xs text-gray-500 hover:text-cyber-accent">Reset</a>
|
||||
{% if campaign_id %}<span class="text-xs text-cyber-accent">Campagne #{{ campaign_id }}</span>{% endif %}
|
||||
</form>
|
||||
</div>
|
||||
|
||||
<!-- Bulk bar -->
|
||||
<div id="bulk-bar" class="card p-3 mb-2 flex gap-2 items-center flex-wrap" style="display:none">
|
||||
<span class="text-xs text-gray-400"><b id="bulk-count">0</b> sélectionné(s)</span>
|
||||
<button onclick="openValidateModal('validated_ok')" class="btn-sm bg-cyber-green text-black">Marquer OK</button>
|
||||
<button onclick="openValidateModal('validated_ko')" class="btn-sm bg-red-900/40 text-cyber-red">Marquer KO</button>
|
||||
{% if can_force %}<button onclick="openValidateModal('forced')" class="btn-sm bg-cyber-yellow text-black">Forcer</button>{% endif %}
|
||||
</div>
|
||||
|
||||
<!-- Tableau -->
|
||||
<div class="card overflow-x-auto">
|
||||
<table class="w-full table-cyber text-xs">
|
||||
<thead><tr>
|
||||
<th class="p-2 w-8"><input type="checkbox" id="check-all" onchange="toggleAll(this)"></th>
|
||||
<th class="p-2 text-left">Hostname</th>
|
||||
<th class="p-2 text-left">Application</th>
|
||||
<th class="p-2">Env</th>
|
||||
<th class="p-2">Domaine</th>
|
||||
<th class="p-2">Patched</th>
|
||||
<th class="p-2">Jours</th>
|
||||
<th class="p-2">Statut</th>
|
||||
<th class="p-2">Validé par</th>
|
||||
<th class="p-2">Action</th>
|
||||
</tr></thead>
|
||||
<tbody>
|
||||
{% for v in validations %}
|
||||
<tr class="border-t border-cyber-border/30">
|
||||
<td class="p-2 text-center"><input type="checkbox" class="val-check" value="{{ v.id }}" onchange="updateBulk()"></td>
|
||||
<td class="p-2 font-mono text-cyber-accent">{{ v.hostname }}</td>
|
||||
<td class="p-2 text-xs text-gray-300">{{ (v.application_name or '-')[:30] }}</td>
|
||||
<td class="p-2 text-center">{{ v.env_name or '-' }}</td>
|
||||
<td class="p-2 text-center text-gray-400">{{ v.domain_name or '-' }}</td>
|
||||
<td class="p-2 text-center text-gray-400">{% if v.patch_date %}{{ v.patch_date.strftime('%Y-%m-%d %H:%M') }}{% endif %}</td>
|
||||
<td class="p-2 text-center {% if v.days_pending and v.days_pending > 7 %}text-cyber-red font-bold{% endif %}">{{ v.days_pending|int if v.days_pending else '-' }}</td>
|
||||
<td class="p-2 text-center">
|
||||
{% if v.status == 'en_attente' %}<span class="badge badge-yellow">En attente</span>
|
||||
{% elif v.status == 'validated_ok' %}<span class="badge badge-green">✓ OK</span>
|
||||
{% elif v.status == 'validated_ko' %}<span class="badge badge-red">✗ KO</span>
|
||||
{% elif v.status == 'forced' %}<span class="badge badge-yellow" title="{{ v.forced_reason }}">Forcé</span>
|
||||
{% endif %}
|
||||
</td>
|
||||
<td class="p-2 text-xs text-gray-300">
|
||||
{% if v.validated_by_name %}{{ v.validated_by_name }}{% if v.validated_at %}<div class="text-gray-500" style="font-size:10px">{{ v.validated_at.strftime('%Y-%m-%d') }}</div>{% endif %}
|
||||
{% else %}—{% endif %}
|
||||
</td>
|
||||
<td class="p-2 text-center">
|
||||
<a href="/patching/validations/history/{{ v.server_id }}" class="text-xs text-cyber-accent hover:underline">Historique</a>
|
||||
</td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
{% if not validations %}
|
||||
<tr><td colspan="10" class="p-6 text-center text-gray-500">Aucune validation dans ce filtre</td></tr>
|
||||
{% endif %}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
<!-- Modal validation -->
|
||||
<div id="validate-modal" style="display:none;position:fixed;inset:0;background:rgba(0,0,0,0.7);z-index:9999;justify-content:center;align-items:center">
|
||||
<div class="card p-5" style="width:420px;max-width:95vw">
|
||||
<h3 class="text-sm font-bold text-cyber-accent mb-3" id="validate-title">Valider</h3>
|
||||
<div id="validator-zone">
|
||||
<label class="text-xs text-gray-500 block mb-1">Validé par (contact iTop obligatoire)</label>
|
||||
<select id="val-contact-id" class="w-full text-xs">
|
||||
<option value="">-- Sélectionner --</option>
|
||||
{% for c in contacts %}<option value="{{ c.id }}">{{ c.name }} ({{ c.team or c.role }})</option>{% endfor %}
|
||||
</select>
|
||||
</div>
|
||||
<div id="forced-zone" style="display:none" class="mt-3">
|
||||
<label class="text-xs text-gray-500 block mb-1">Raison du forçage (obligatoire)</label>
|
||||
<textarea id="val-reason" rows="3" class="w-full text-xs"></textarea>
|
||||
</div>
|
||||
<div class="mt-3">
|
||||
<label class="text-xs text-gray-500 block mb-1">Notes (optionnel)</label>
|
||||
<textarea id="val-notes" rows="2" class="w-full text-xs"></textarea>
|
||||
</div>
|
||||
<div class="flex gap-2 mt-4 justify-end">
|
||||
<button onclick="document.getElementById('validate-modal').style.display='none'" class="btn-sm bg-cyber-border text-gray-300">Annuler</button>
|
||||
<button onclick="submitValidate()" class="btn-primary px-3 py-1 text-xs">Enregistrer</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
let _valStatus = null;
|
||||
function toggleAll(cb) {
|
||||
document.querySelectorAll('.val-check').forEach(c => c.checked = cb.checked);
|
||||
updateBulk();
|
||||
}
|
||||
function updateBulk() {
|
||||
const ids = Array.from(document.querySelectorAll('.val-check:checked')).map(c => parseInt(c.value));
|
||||
const bar = document.getElementById('bulk-bar');
|
||||
bar.style.display = ids.length > 0 ? 'flex' : 'none';
|
||||
document.getElementById('bulk-count').textContent = ids.length;
|
||||
window._selectedValIds = ids;
|
||||
}
|
||||
function openValidateModal(status) {
|
||||
_valStatus = status;
|
||||
const titles = {'validated_ok':'Marquer OK', 'validated_ko':'Marquer KO', 'forced':'Forcer'};
|
||||
document.getElementById('validate-title').textContent = titles[status] + ' — ' + (window._selectedValIds || []).length + ' serveur(s)';
|
||||
document.getElementById('forced-zone').style.display = (status === 'forced') ? 'block' : 'none';
|
||||
document.getElementById('validator-zone').style.display = (status === 'forced') ? 'none' : 'block';
|
||||
document.getElementById('val-reason').value = '';
|
||||
document.getElementById('val-notes').value = '';
|
||||
document.getElementById('validate-modal').style.display = 'flex';
|
||||
}
|
||||
function submitValidate() {
|
||||
const ids = window._selectedValIds || [];
|
||||
if (!ids.length) return alert('Aucun sélectionné');
|
||||
const payload = {
|
||||
validation_ids: ids,
|
||||
status: _valStatus,
|
||||
contact_id: document.getElementById('val-contact-id').value || null,
|
||||
forced_reason: document.getElementById('val-reason').value,
|
||||
notes: document.getElementById('val-notes').value,
|
||||
};
|
||||
fetch('/patching/validations/mark', {
|
||||
method: 'POST', credentials: 'same-origin',
|
||||
headers: {'Content-Type': 'application/json'},
|
||||
body: JSON.stringify(payload)
|
||||
})
|
||||
.then(r => r.json())
|
||||
.then(d => {
|
||||
if (d.ok) { alert(d.updated + ' marqué(s)'); location.reload(); }
|
||||
else alert('Erreur: ' + (d.msg || ''));
|
||||
});
|
||||
}
|
||||
</script>
|
||||
{% endblock %}
|
||||
48
app/templates/patching_validations_history.html
Normal file
48
app/templates/patching_validations_history.html
Normal file
@ -0,0 +1,48 @@
|
||||
{% extends 'base.html' %}
|
||||
{% block title %}Historique validations — {{ server.hostname }}{% endblock %}
|
||||
{% block content %}
|
||||
<div class="flex justify-between items-center mb-4">
|
||||
<div>
|
||||
<a href="/patching/validations" class="text-xs text-gray-500 hover:text-gray-300">← Validations</a>
|
||||
<h2 class="text-xl font-bold text-cyber-accent">Historique — <span class="font-mono">{{ server.hostname }}</span></h2>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="card overflow-x-auto">
|
||||
<table class="w-full table-cyber text-xs">
|
||||
<thead><tr>
|
||||
<th class="p-2">Patched</th>
|
||||
<th class="p-2">Campagne</th>
|
||||
<th class="p-2">Statut</th>
|
||||
<th class="p-2">Validé par</th>
|
||||
<th class="p-2">Le</th>
|
||||
<th class="p-2 text-left">Raison forcée</th>
|
||||
<th class="p-2 text-left">Notes</th>
|
||||
<th class="p-2">Marqué par</th>
|
||||
</tr></thead>
|
||||
<tbody>
|
||||
{% for h in history %}
|
||||
<tr class="border-t border-cyber-border/30">
|
||||
<td class="p-2 text-center">{{ h.patch_date.strftime('%Y-%m-%d %H:%M') if h.patch_date }}</td>
|
||||
<td class="p-2 text-center text-gray-400">{{ h.campaign_type or '-' }} #{{ h.campaign_id or '' }}</td>
|
||||
<td class="p-2 text-center">
|
||||
{% if h.status == 'en_attente' %}<span class="badge badge-yellow">En attente</span>
|
||||
{% elif h.status == 'validated_ok' %}<span class="badge badge-green">OK</span>
|
||||
{% elif h.status == 'validated_ko' %}<span class="badge badge-red">KO</span>
|
||||
{% elif h.status == 'forced' %}<span class="badge badge-yellow">Forcé</span>
|
||||
{% endif %}
|
||||
</td>
|
||||
<td class="p-2 text-center">{{ h.validated_by_name or '—' }}</td>
|
||||
<td class="p-2 text-center text-gray-400">{{ h.validated_at.strftime('%Y-%m-%d %H:%M') if h.validated_at else '—' }}</td>
|
||||
<td class="p-2 text-gray-300">{{ h.forced_reason or '' }}</td>
|
||||
<td class="p-2 text-gray-300">{{ h.notes or '' }}</td>
|
||||
<td class="p-2 text-center text-gray-400">{{ h.marked_by or '—' }}</td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
{% if not history %}
|
||||
<tr><td colspan="8" class="p-6 text-center text-gray-500">Aucun historique</td></tr>
|
||||
{% endif %}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
{% endblock %}
|
||||
@ -7,26 +7,71 @@
|
||||
<p class="text-xs text-gray-500 mt-1">Activation keys et versions des agents déployés</p>
|
||||
</div>
|
||||
<div style="display:flex;gap:8px">
|
||||
<form method="POST" action="/qualys/agents/refresh" style="display:inline">
|
||||
<button type="submit" class="btn-primary px-4 py-2 text-sm"
|
||||
onclick="this.disabled=true;this.textContent='Rafraîchissement...'">
|
||||
Rafraîchir depuis Qualys
|
||||
</button>
|
||||
</form>
|
||||
<button id="btn-refresh" class="btn-primary px-4 py-2 text-sm" onclick="refreshAgents()">
|
||||
Rafraîchir depuis Qualys
|
||||
</button>
|
||||
<a href="/qualys/deploy" class="btn-sm bg-cyber-border text-gray-300 px-4 py-2">Déployer</a>
|
||||
<a href="/qualys/search" class="btn-sm bg-cyber-border text-gray-300 px-4 py-2">Recherche</a>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{% if 'refresh_ok' in msg %}
|
||||
<div style="background:#1a5a2e;color:#8f8;padding:8px 16px;border-radius:6px;margin-bottom:12px;font-size:0.85rem">
|
||||
Données rafraîchies depuis Qualys.
|
||||
<!-- Overlay chargement -->
|
||||
<div id="refresh-overlay" style="display:none;position:fixed;inset:0;background:rgba(0,0,0,0.7);z-index:9999;justify-content:center;align-items:center">
|
||||
<div class="card p-6 text-center" style="min-width:320px">
|
||||
<div style="margin-bottom:12px">
|
||||
<svg style="display:inline;animation:spin 1s linear infinite;width:36px;height:36px" viewBox="0 0 24 24" fill="none" stroke="#00ffc8" stroke-width="2"><circle cx="12" cy="12" r="10" stroke-opacity="0.3"/><path d="M12 2a10 10 0 0 1 10 10" stroke-linecap="round"/></svg>
|
||||
</div>
|
||||
<div class="text-cyber-accent font-bold text-sm" id="refresh-title">Rafraîchissement en cours...</div>
|
||||
<div class="text-gray-400 text-xs mt-2" id="refresh-detail">Synchronisation des agents depuis l'API Qualys</div>
|
||||
<div class="text-gray-500 text-xs mt-3" id="refresh-timer">0s</div>
|
||||
</div>
|
||||
</div>
|
||||
{% elif msg == 'refresh_error' %}
|
||||
<div style="background:#5a1a1a;color:#ff3366;padding:8px 16px;border-radius:6px;margin-bottom:12px;font-size:0.85rem">
|
||||
Erreur lors du rafraîchissement.
|
||||
</div>
|
||||
{% endif %}
|
||||
<style>@keyframes spin{to{transform:rotate(360deg)}}</style>
|
||||
|
||||
<!-- Message résultat -->
|
||||
<div id="refresh-msg" style="display:none;padding:8px 16px;border-radius:6px;margin-bottom:12px;font-size:0.85rem"></div>
|
||||
|
||||
<script>
|
||||
function refreshAgents() {
|
||||
var btn = document.getElementById('btn-refresh');
|
||||
var overlay = document.getElementById('refresh-overlay');
|
||||
var timer = document.getElementById('refresh-timer');
|
||||
var msgDiv = document.getElementById('refresh-msg');
|
||||
btn.disabled = true;
|
||||
overlay.style.display = 'flex';
|
||||
msgDiv.style.display = 'none';
|
||||
var t0 = Date.now();
|
||||
var iv = setInterval(function(){ timer.textContent = Math.floor((Date.now()-t0)/1000) + 's'; }, 1000);
|
||||
fetch('/qualys/agents/refresh', {method:'POST', credentials:'same-origin'})
|
||||
.then(function(r){ return r.json().then(function(d){ return {ok:r.ok, data:d}; }); })
|
||||
.then(function(res){
|
||||
clearInterval(iv);
|
||||
overlay.style.display = 'none';
|
||||
btn.disabled = false;
|
||||
if(res.ok && res.data.ok){
|
||||
msgDiv.style.background = '#1a5a2e';
|
||||
msgDiv.style.color = '#8f8';
|
||||
msgDiv.textContent = 'Données rafraîchies : ' + res.data.msg;
|
||||
msgDiv.style.display = 'block';
|
||||
setTimeout(function(){ location.reload(); }, 1500);
|
||||
} else {
|
||||
msgDiv.style.background = '#5a1a1a';
|
||||
msgDiv.style.color = '#ff3366';
|
||||
msgDiv.textContent = 'Erreur : ' + (res.data.msg || 'Erreur inconnue');
|
||||
msgDiv.style.display = 'block';
|
||||
}
|
||||
})
|
||||
.catch(function(err){
|
||||
clearInterval(iv);
|
||||
overlay.style.display = 'none';
|
||||
btn.disabled = false;
|
||||
msgDiv.style.background = '#5a1a1a';
|
||||
msgDiv.style.color = '#ff3366';
|
||||
msgDiv.textContent = 'Erreur réseau : ' + err.message;
|
||||
msgDiv.style.display = 'block';
|
||||
});
|
||||
}
|
||||
</script>
|
||||
|
||||
<!-- KPIs agents -->
|
||||
<div style="display:flex;flex-wrap:nowrap;gap:8px;margin-bottom:16px;">
|
||||
@ -156,7 +201,7 @@
|
||||
<td class="p-2 text-center text-gray-400">{{ s.domain or '-' }}</td>
|
||||
<td class="p-2 text-center">{{ s.env or '-' }}</td>
|
||||
<td class="p-2 text-center">{% if s.zone == 'DMZ' %}<span class="badge badge-red">DMZ</span>{% else %}{{ s.zone or '-' }}{% endif %}</td>
|
||||
<td class="p-2 text-center" title="{{ s.etat or '' }}"><span class="badge {% if s.etat == 'en_production' %}badge-green{% elif s.etat == 'decommissionne' %}badge-red{% elif s.etat == 'eteint' %}badge-gray{% else %}badge-yellow{% endif %}">{{ (s.etat or '-')[:8] }}</span></td>
|
||||
<td class="p-2 text-center" title="{{ s.etat or '' }}"><span class="badge {% if s.etat == 'production' %}badge-green{% elif s.etat == 'obsolete' %}badge-red{% elif s.etat == 'stock' %}badge-gray{% else %}badge-yellow{% endif %}">{{ (s.etat or '-')[:8] }}</span></td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
|
||||
@ -78,28 +78,20 @@
|
||||
</div>
|
||||
|
||||
<!-- Actions -->
|
||||
<div style="display:flex;gap:8px">
|
||||
<form method="POST" action="/qualys/deploy/run" id="deployForm">
|
||||
<input type="hidden" name="server_ids" :value="selectedIds.join(',')">
|
||||
<input type="hidden" name="activation_id" :value="document.getElementById('activation_id').value">
|
||||
<input type="hidden" name="customer_id" :value="document.getElementById('customer_id').value">
|
||||
<input type="hidden" name="server_uri" :value="document.getElementById('server_uri').value">
|
||||
<input type="hidden" name="package_deb" :value="document.getElementById('package_deb').value">
|
||||
<input type="hidden" name="package_rpm" :value="document.getElementById('package_rpm').value">
|
||||
<button type="submit" class="btn-primary px-4 py-2 text-sm"
|
||||
:disabled="selectedIds.length === 0"
|
||||
onclick="if(!confirm('Déployer l\'agent sur ' + selectedIds.length + ' serveur(s) ?')) return false; this.textContent='Déploiement en cours...'">
|
||||
Déployer l'agent
|
||||
</button>
|
||||
</form>
|
||||
<form method="POST" action="/qualys/deploy/check">
|
||||
<input type="hidden" name="server_ids" :value="selectedIds.join(',')">
|
||||
<button type="submit" style="padding:8px 16px;font-size:0.85rem;background:#334155;color:#e2e8f0;border:1px solid #475569;border-radius:6px;cursor:pointer"
|
||||
:disabled="selectedIds.length === 0"
|
||||
onclick="this.textContent='Vérification...'">
|
||||
Vérifier l'agent
|
||||
</button>
|
||||
</form>
|
||||
<div style="display:flex;gap:8px;align-items:center">
|
||||
<button id="btn-deploy" class="btn-primary px-4 py-2 text-sm"
|
||||
:disabled="selectedIds.length === 0"
|
||||
@click="deployAgent(selectedIds)">
|
||||
Déployer l'agent
|
||||
</button>
|
||||
<button id="btn-check" style="padding:8px 16px;font-size:0.85rem;background:#334155;color:#e2e8f0;border:1px solid #475569;border-radius:6px;cursor:pointer"
|
||||
:disabled="selectedIds.length === 0"
|
||||
@click="checkAgent(selectedIds)">
|
||||
Vérifier l'agent
|
||||
</button>
|
||||
<label class="text-xs text-gray-400 ml-4" style="display:flex;align-items:center;gap:4px">
|
||||
<input type="checkbox" id="force_downgrade"> Forcer le downgrade
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<!-- Serveurs -->
|
||||
@ -109,10 +101,11 @@
|
||||
<th class="p-2 w-8"><input type="checkbox" @change="selectAll = $event.target.checked; selectedIds = selectAll ? servers.map(s => s.id) : []"
|
||||
x-init="servers = {{ servers | tojson }}"></th>
|
||||
<th class="p-2 text-left">Hostname</th>
|
||||
<th class="p-2">OS</th>
|
||||
<th class="p-2">OS / Version</th>
|
||||
<th class="p-2">Domaine</th>
|
||||
<th class="p-2">Env</th>
|
||||
<th class="p-2">État</th>
|
||||
<th class="p-2">Agent installé</th>
|
||||
<th class="p-2">SSH</th>
|
||||
</tr></thead>
|
||||
<tbody>
|
||||
@ -129,13 +122,20 @@
|
||||
<td class="p-2 text-center">
|
||||
{% if s.os_family == 'linux' %}<span class="badge badge-green">Linux</span>
|
||||
{% else %}<span class="badge badge-blue">{{ s.os_family or '?' }}</span>{% endif %}
|
||||
{% if s.os_version %}<div class="text-gray-400 mt-1" style="font-size:10px">{{ s.os_version }}</div>{% endif %}
|
||||
</td>
|
||||
<td class="p-2 text-center text-gray-400">{{ s.domain or '-' }}</td>
|
||||
<td class="p-2 text-center">{{ s.env or '-' }}</td>
|
||||
<td class="p-2 text-center">
|
||||
{% if s.etat == 'en_production' %}<span class="badge badge-green">Prod</span>
|
||||
{% if s.etat == 'production' %}<span class="badge badge-green">Prod</span>
|
||||
{% else %}{{ s.etat or '-' }}{% endif %}
|
||||
</td>
|
||||
<td class="p-2 text-center">
|
||||
{% if s.agent_version %}
|
||||
<span class="font-mono text-cyber-green">{{ s.agent_version }}</span>
|
||||
<span class="ml-1 badge {% if s.agent_status and 'ACTIVE' in (s.agent_status|upper) and 'INACTIVE' not in (s.agent_status|upper) %}badge-green{% elif s.agent_status and 'INACTIVE' in (s.agent_status|upper) %}badge-red{% else %}badge-gray{% endif %}" style="font-size:9px">{{ s.agent_status or '?' }}</span>
|
||||
{% else %}<span class="text-gray-600">—</span>{% endif %}
|
||||
</td>
|
||||
<td class="p-2 text-center text-gray-500">{{ s.ssh_user or 'root' }}:{{ s.ssh_port or 22 }}</td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
@ -143,4 +143,248 @@
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Zone progression / résultats -->
|
||||
<div id="progress-zone" style="display:none" class="mt-4 space-y-4">
|
||||
<div class="card p-4">
|
||||
<div class="flex justify-between items-center mb-3">
|
||||
<h3 class="text-sm font-bold text-cyber-accent" id="progress-title">Déploiement en cours...</h3>
|
||||
<span class="text-xs text-gray-400 font-mono" id="progress-timer">0s</span>
|
||||
</div>
|
||||
<!-- Barre de progression globale -->
|
||||
<div style="background:#1e293b;border-radius:4px;height:8px;margin-bottom:16px;overflow:hidden">
|
||||
<div id="progress-bar" style="height:100%;background:#00ffc8;width:0%;transition:width 0.5s ease"></div>
|
||||
</div>
|
||||
<div class="text-xs text-gray-400 mb-3" id="progress-summary"></div>
|
||||
<!-- Tableau progression par serveur -->
|
||||
<table class="w-full table-cyber text-xs">
|
||||
<thead><tr>
|
||||
<th class="p-2 text-left">Hostname</th>
|
||||
<th class="p-2">Étape</th>
|
||||
<th class="p-2 text-left">Détail</th>
|
||||
</tr></thead>
|
||||
<tbody id="progress-body"></tbody>
|
||||
</table>
|
||||
</div>
|
||||
<!-- Log détaillé (affiché une fois terminé) -->
|
||||
<div id="progress-log" class="card p-4" style="display:none">
|
||||
<h3 class="text-sm font-bold text-cyber-accent mb-2">Log détaillé</h3>
|
||||
<div id="progress-log-content" style="background:#0a0a23;border-radius:6px;padding:12px;max-height:400px;overflow-y:auto;font-family:monospace;font-size:0.75rem;color:#00ff88;white-space:pre-wrap"></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Overlay vérification (check reste synchrone, c'est rapide) -->
|
||||
<div id="check-overlay" style="display:none;position:fixed;inset:0;background:rgba(0,0,0,0.75);z-index:9999;justify-content:center;align-items:center">
|
||||
<div class="card p-6 text-center" style="min-width:360px">
|
||||
<div style="margin-bottom:12px">
|
||||
<svg style="display:inline;animation:opspin 1s linear infinite;width:40px;height:40px" viewBox="0 0 24 24" fill="none" stroke="#00ffc8" stroke-width="2"><circle cx="12" cy="12" r="10" stroke-opacity="0.3"/><path d="M12 2a10 10 0 0 1 10 10" stroke-linecap="round"/></svg>
|
||||
</div>
|
||||
<div class="text-cyber-accent font-bold text-sm" id="check-title">Vérification en cours...</div>
|
||||
<div class="text-gray-400 text-xs mt-2" id="check-detail"></div>
|
||||
<div class="text-gray-500 text-xs mt-3 font-mono" id="check-timer">0s</div>
|
||||
</div>
|
||||
</div>
|
||||
<style>@keyframes opspin{to{transform:rotate(360deg)}}</style>
|
||||
|
||||
<!-- Zone résultats check -->
|
||||
<div id="check-results" style="display:none" class="mt-4 space-y-4">
|
||||
<div id="check-kpis" style="display:flex;gap:8px;margin-bottom:16px"></div>
|
||||
<div id="check-table" class="card overflow-hidden"></div>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
var _pollTimer = null;
|
||||
var _checkTimer = null;
|
||||
|
||||
var STAGE_LABELS = {
|
||||
'pending': {label: 'En attente', cls: 'badge-gray', icon: '●'},
|
||||
'connecting': {label: 'Connexion SSH', cls: 'badge-yellow', icon: '↻'},
|
||||
'checking': {label: 'Vérification', cls: 'badge-yellow', icon: '↻'},
|
||||
'copying': {label: 'Copie package', cls: 'badge-yellow', icon: '↻'},
|
||||
'installing': {label: 'Installation', cls: 'badge-yellow', icon: '↻'},
|
||||
'activating': {label: 'Activation', cls: 'badge-yellow', icon: '↻'},
|
||||
'restarting': {label: 'Redémarrage', cls: 'badge-yellow', icon: '↻'},
|
||||
'verifying': {label: 'Vérification', cls: 'badge-yellow', icon: '↻'},
|
||||
'success': {label: 'Succès', cls: 'badge-green', icon: '✓'},
|
||||
'already_installed': {label: 'Déjà installé', cls: 'badge-blue', icon: '✓'},
|
||||
'downgrade_refused': {label: 'Downgrade refusé', cls: 'badge-yellow', icon: '⚠'},
|
||||
'partial': {label: 'Partiel', cls: 'badge-yellow', icon: '⚠'},
|
||||
'failed': {label: 'Échec', cls: 'badge-red', icon: '✗'},
|
||||
};
|
||||
|
||||
function _stageBadge(stage) {
|
||||
var s = STAGE_LABELS[stage] || {label: stage, cls: 'badge-gray', icon: '?'};
|
||||
var anim = (stage !== 'pending' && stage !== 'success' && stage !== 'failed' && stage !== 'partial' && stage !== 'already_installed')
|
||||
? ' style="animation:pulse 1.5s ease-in-out infinite"' : '';
|
||||
return '<span class="badge ' + s.cls + '"' + anim + '>' + s.icon + ' ' + s.label + '</span>';
|
||||
}
|
||||
|
||||
function deployAgent(ids) {
|
||||
if (!ids.length) return;
|
||||
if (!confirm('Déployer l\'agent sur ' + ids.length + ' serveur(s) ?\n\nLe déploiement se fait en arrière-plan.')) return;
|
||||
|
||||
document.getElementById('btn-deploy').disabled = true;
|
||||
|
||||
fetch('/qualys/deploy/run', {
|
||||
method: 'POST', credentials: 'same-origin',
|
||||
headers: {'Content-Type': 'application/json'},
|
||||
body: JSON.stringify({
|
||||
server_ids: ids.join(','),
|
||||
activation_id: document.getElementById('activation_id').value,
|
||||
customer_id: document.getElementById('customer_id').value,
|
||||
server_uri: document.getElementById('server_uri').value,
|
||||
package_deb: document.getElementById('package_deb').value,
|
||||
package_rpm: document.getElementById('package_rpm').value,
|
||||
force_downgrade: document.getElementById('force_downgrade').checked,
|
||||
})
|
||||
})
|
||||
.then(function(r){
|
||||
var ct = r.headers.get('content-type') || '';
|
||||
if (ct.indexOf('json') === -1) throw new Error('Erreur serveur (HTTP ' + r.status + ')');
|
||||
return r.json();
|
||||
})
|
||||
.then(function(data){
|
||||
if (data.ok && data.job_id) {
|
||||
startPolling(data.job_id, data.total);
|
||||
} else {
|
||||
alert('Erreur: ' + (data.msg || 'Erreur inconnue'));
|
||||
document.getElementById('btn-deploy').disabled = false;
|
||||
}
|
||||
})
|
||||
.catch(function(err){
|
||||
alert('Erreur: ' + err.message);
|
||||
document.getElementById('btn-deploy').disabled = false;
|
||||
});
|
||||
}
|
||||
|
||||
function startPolling(jobId, total) {
|
||||
var zone = document.getElementById('progress-zone');
|
||||
var title = document.getElementById('progress-title');
|
||||
var timer = document.getElementById('progress-timer');
|
||||
var bar = document.getElementById('progress-bar');
|
||||
var summary = document.getElementById('progress-summary');
|
||||
var tbody = document.getElementById('progress-body');
|
||||
|
||||
zone.style.display = 'block';
|
||||
zone.scrollIntoView({behavior: 'smooth'});
|
||||
title.textContent = 'Déploiement en cours... (' + total + ' serveur(s))';
|
||||
|
||||
function poll() {
|
||||
fetch('/qualys/deploy/status/' + jobId, {credentials: 'same-origin'})
|
||||
.then(function(r){ return r.json(); })
|
||||
.then(function(data){
|
||||
if (!data.ok) return;
|
||||
|
||||
timer.textContent = data.elapsed + 's';
|
||||
var pct = data.total > 0 ? Math.round((data.done / data.total) * 100) : 0;
|
||||
bar.style.width = pct + '%';
|
||||
summary.textContent = data.done + ' / ' + data.total + ' terminé(s)';
|
||||
|
||||
// Build rows
|
||||
var rows = '';
|
||||
var hostnames = Object.keys(data.servers).sort();
|
||||
hostnames.forEach(function(hn){
|
||||
var s = data.servers[hn];
|
||||
rows += '<tr class="border-t border-cyber-border/30">';
|
||||
rows += '<td class="p-2 font-mono">' + hn + '</td>';
|
||||
rows += '<td class="p-2 text-center">' + _stageBadge(s.stage) + '</td>';
|
||||
rows += '<td class="p-2 text-gray-400 text-xs">' + (s.detail || '') + '</td>';
|
||||
rows += '</tr>';
|
||||
});
|
||||
tbody.innerHTML = rows;
|
||||
|
||||
if (data.finished) {
|
||||
if (_pollTimer) { clearInterval(_pollTimer); _pollTimer = null; }
|
||||
var ok = 0, fail = 0;
|
||||
hostnames.forEach(function(hn){
|
||||
var st = data.servers[hn].stage;
|
||||
if (st === 'success' || st === 'already_installed') ok++;
|
||||
else if (st === 'failed') fail++;
|
||||
});
|
||||
title.innerHTML = 'Déploiement terminé — <span class="text-cyber-green">' + ok + ' OK</span> / <span class="text-cyber-red">' + fail + ' échec(s)</span>';
|
||||
bar.style.background = fail > 0 ? '#ff3366' : '#00ffc8';
|
||||
document.getElementById('btn-deploy').disabled = false;
|
||||
|
||||
// Show log
|
||||
if (data.log && data.log.length > 0) {
|
||||
document.getElementById('progress-log-content').textContent = data.log.join('\n');
|
||||
document.getElementById('progress-log').style.display = 'block';
|
||||
}
|
||||
}
|
||||
})
|
||||
.catch(function(){});
|
||||
}
|
||||
|
||||
poll();
|
||||
_pollTimer = setInterval(poll, 2000);
|
||||
}
|
||||
|
||||
// === Check (reste synchrone, c'est rapide ~5-15s par serveur) ===
|
||||
|
||||
function checkAgent(ids) {
|
||||
if (!ids.length) return;
|
||||
var ov = document.getElementById('check-overlay');
|
||||
var timer = document.getElementById('check-timer');
|
||||
document.getElementById('check-title').textContent = 'Vérification de ' + ids.length + ' serveur(s)...';
|
||||
document.getElementById('check-detail').textContent = 'Connexion SSH et vérification du statut';
|
||||
timer.textContent = '0s';
|
||||
ov.style.display = 'flex';
|
||||
var t0 = Date.now();
|
||||
if (_checkTimer) clearInterval(_checkTimer);
|
||||
_checkTimer = setInterval(function(){ timer.textContent = Math.floor((Date.now()-t0)/1000) + 's'; }, 1000);
|
||||
|
||||
fetch('/qualys/deploy/check', {
|
||||
method: 'POST', credentials: 'same-origin',
|
||||
headers: {'Content-Type': 'application/json'},
|
||||
body: JSON.stringify({server_ids: ids.join(',')})
|
||||
})
|
||||
.then(function(r){
|
||||
var ct = r.headers.get('content-type') || '';
|
||||
if (ct.indexOf('json') === -1) throw new Error('Erreur serveur (HTTP ' + r.status + ')');
|
||||
return r.json();
|
||||
})
|
||||
.then(function(data){
|
||||
clearInterval(_checkTimer);
|
||||
ov.style.display = 'none';
|
||||
if (data.ok) showCheckResults(data);
|
||||
else alert('Erreur: ' + (data.msg || ''));
|
||||
})
|
||||
.catch(function(err){
|
||||
clearInterval(_checkTimer);
|
||||
ov.style.display = 'none';
|
||||
alert('Erreur: ' + err.message);
|
||||
});
|
||||
}
|
||||
|
||||
function showCheckResults(data) {
|
||||
var zone = document.getElementById('check-results');
|
||||
var kpis = document.getElementById('check-kpis');
|
||||
var tbl = document.getElementById('check-table');
|
||||
|
||||
kpis.innerHTML =
|
||||
'<div class="card p-3 text-center" style="flex:1"><div class="text-2xl font-bold text-cyber-accent">' + data.total + '</div><div class="text-xs text-gray-500">Total</div></div>' +
|
||||
'<div class="card p-3 text-center" style="flex:1"><div class="text-2xl font-bold text-cyber-green">' + (data.active||0) + '</div><div class="text-xs text-gray-500">Actifs</div></div>' +
|
||||
'<div class="card p-3 text-center" style="flex:1"><div class="text-2xl font-bold text-cyber-yellow">' + (data.not_installed||0) + '</div><div class="text-xs text-gray-500">Non installés</div></div>' +
|
||||
'<div class="card p-3 text-center" style="flex:1"><div class="text-2xl font-bold text-cyber-red">' + (data.failed||0) + '</div><div class="text-xs text-gray-500">Connexion échouée</div></div>';
|
||||
|
||||
var rows = '';
|
||||
data.results.forEach(function(r){
|
||||
var cls = r.status==='ACTIVE'?'badge-green': r.status==='NOT_INSTALLED'?'badge-yellow': r.status==='INACTIVE'?'badge-yellow': 'badge-red';
|
||||
rows += '<tr class="border-t border-cyber-border/30">';
|
||||
rows += '<td class="p-2 font-mono">' + r.hostname + '</td>';
|
||||
rows += '<td class="p-2 text-center"><span class="badge ' + cls + '">' + r.status + '</span></td>';
|
||||
rows += '<td class="p-2 text-gray-400">' + (r.detail||'') + '</td>';
|
||||
rows += '<td class="p-2 text-center font-mono text-gray-400">' + (r.version||'-') + '</td>';
|
||||
rows += '<td class="p-2 text-center">' + (r.service_status||'-') + '</td>';
|
||||
rows += '</tr>';
|
||||
});
|
||||
tbl.innerHTML = '<table class="w-full table-cyber text-xs"><thead><tr><th class="p-2 text-left">Hostname</th><th class="p-2">Statut</th><th class="p-2 text-left">Détail</th><th class="p-2">Version</th><th class="p-2">Service</th></tr></thead><tbody>' + rows + '</tbody></table>';
|
||||
|
||||
zone.style.display = 'block';
|
||||
zone.scrollIntoView({behavior:'smooth'});
|
||||
}
|
||||
</script>
|
||||
<style>
|
||||
@keyframes pulse { 0%,100%{opacity:1} 50%{opacity:0.5} }
|
||||
</style>
|
||||
{% endblock %}
|
||||
|
||||
@ -1,206 +1,48 @@
|
||||
{% extends "base.html" %}
|
||||
{% block title %}QuickWin Config{% endblock %}
|
||||
|
||||
{% macro qs(p) -%}
|
||||
?page={{ p }}&per_page={{ per_page }}&search={{ filters.search or '' }}&env={{ filters.env or '' }}&domain={{ filters.domain or '' }}&zone={{ filters.zone or '' }}
|
||||
{%- endmacro %}
|
||||
|
||||
{% extends 'base.html' %}
|
||||
{% block title %}QuickWin — Packages avec reboot{% endblock %}
|
||||
{% block content %}
|
||||
<div class="flex items-center justify-between mb-6">
|
||||
<div class="flex justify-between items-center mb-4">
|
||||
<div>
|
||||
<a href="/quickwin" class="text-xs text-gray-500 hover:text-gray-300">← Retour QuickWin</a>
|
||||
<h1 class="text-xl font-bold" style="color:#00d4ff">Exclusions par serveur</h1>
|
||||
<p class="text-xs text-gray-500">Tous les serveurs Linux en_production / secops — exclusions générales par défaut pré-remplies — pas de reboot nécessaire</p>
|
||||
</div>
|
||||
<div class="flex gap-2 items-center">
|
||||
<span class="text-sm text-gray-400">{{ total_count }} serveur(s)</span>
|
||||
<button onclick="document.getElementById('bulkModal').style.display='flex'" class="btn-primary" style="padding:6px 16px;font-size:0.85rem">Modifier en masse</button>
|
||||
<h2 class="text-xl font-bold text-cyber-accent">Packages nécessitant un reboot</h2>
|
||||
<p class="text-xs text-gray-500 mt-1">Liste globale utilisée par QuickWin pour exclure ces packages du <code>yum update</code>.</p>
|
||||
</div>
|
||||
<a href="/quickwin" class="btn-sm bg-cyber-border text-cyber-accent px-4 py-2">← Retour QuickWin</a>
|
||||
</div>
|
||||
|
||||
{% if msg %}
|
||||
<div style="background:#1a5a2e;color:#8f8;padding:8px 16px;border-radius:6px;margin-bottom:12px;font-size:0.85rem">
|
||||
{% if 'saved' in msg %}Configuration sauvegardée{% elif 'deleted' in msg %}Exclusions spécifiques retirées{% elif 'added' in msg %}{{ msg.split('_')[1] }} serveur(s) mis à jour{% elif 'bulk' in msg %}Mise à jour groupée OK{% else %}{{ msg }}{% endif %}
|
||||
</div>
|
||||
{% if msg == 'saved' %}
|
||||
<div class="mb-3 p-2 rounded bg-green-900/30 text-cyber-green text-sm">Liste des reboot packages sauvegardée.</div>
|
||||
{% endif %}
|
||||
|
||||
<!-- Filtre -->
|
||||
<form method="GET" class="card mb-4" style="padding:10px 16px;display:flex;gap:12px;align-items:center">
|
||||
<input type="text" name="search" value="{{ filters.search or '' }}" placeholder="Recherche serveur..." style="width:200px">
|
||||
<select name="env" onchange="this.form.submit()" style="width:140px">
|
||||
<option value="">Tous env.</option>
|
||||
{% set envs = all_configs|map(attribute='environnement')|select('string')|unique|sort %}
|
||||
{% for e in envs %}<option value="{{ e }}" {% if filters.env == e %}selected{% endif %}>{{ e }}</option>{% endfor %}
|
||||
</select>
|
||||
<select name="domain" onchange="this.form.submit()" style="width:140px">
|
||||
<option value="">Tous domaines</option>
|
||||
{% set doms = all_configs|map(attribute='domaine')|select('string')|unique|sort %}
|
||||
{% for d in doms %}<option value="{{ d }}" {% if filters.domain == d %}selected{% endif %}>{{ d }}</option>{% endfor %}
|
||||
</select>
|
||||
<select name="zone" onchange="this.form.submit()" style="width:100px">
|
||||
<option value="">Zone</option>
|
||||
{% set zones = all_configs|map(attribute='zone')|select('string')|unique|sort %}
|
||||
{% for z in zones %}<option value="{{ z }}" {% if filters.zone == z %}selected{% endif %}>{{ z }}</option>{% endfor %}
|
||||
</select>
|
||||
<select name="per_page" onchange="this.form.submit()" style="width:140px">
|
||||
<option value="">Affichage / page</option>
|
||||
{% for n in [14, 25, 50, 100] %}<option value="{{ n }}" {% if per_page == n %}selected{% endif %}>{{ n }} par page</option>{% endfor %}
|
||||
</select>
|
||||
<button type="submit" class="btn-primary" style="padding:4px 14px;font-size:0.8rem">Filtrer</button>
|
||||
<a href="/quickwin/config" class="text-xs text-gray-500 hover:text-gray-300">Reset</a>
|
||||
<span class="text-xs text-gray-500">{{ total_count }} serveur(s)</span>
|
||||
</form>
|
||||
<!-- Info -->
|
||||
<div class="card p-3 mb-4 text-xs text-gray-400" style="background:#111827">
|
||||
<b class="text-cyber-accent">Fonctionnement :</b>
|
||||
<ul class="list-disc ml-5 mt-2 space-y-1">
|
||||
<li>Les <b>campagnes QuickWin</b> excluent ces packages + les exclusions par serveur (iTop / <a href="/patching/config-exclusions" class="text-cyber-accent hover:underline">Config exclusions</a>).</li>
|
||||
<li>Les <b>campagnes standard avec reboot</b> n'utilisent PAS cette liste — uniquement les exclusions par serveur.</li>
|
||||
<li>Format : patterns yum séparés par espace (ex: <code>kernel* glibc* systemd*</code>).</li>
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
<!-- Cartouche detail serveur -->
|
||||
<div id="srvDetail" class="card mb-4" style="display:none;border-left:3px solid #00d4ff;padding:12px 16px">
|
||||
<div class="flex items-center justify-between mb-2">
|
||||
<h3 style="color:#00d4ff;font-weight:bold;font-size:0.95rem" id="detailName"></h3>
|
||||
<button onclick="document.getElementById('srvDetail').style.display='none'" class="text-gray-500 hover:text-gray-300" style="font-size:1.2rem">×</button>
|
||||
</div>
|
||||
<div class="grid grid-cols-2 gap-4">
|
||||
<!-- Formulaire -->
|
||||
<div class="card p-4">
|
||||
<form method="POST" action="/quickwin/config/save" class="space-y-3">
|
||||
<div>
|
||||
<div class="text-xs text-gray-500 mb-1 font-bold">Exclusions générales (OS / reboot)</div>
|
||||
<pre id="detailGeneral" style="font-size:0.7rem;color:#ffcc00;white-space:pre-wrap;margin:0"></pre>
|
||||
<label class="text-xs text-gray-500 block mb-1">Patterns de packages à exclure (séparés par espace)</label>
|
||||
<textarea name="reboot_packages" rows="6" class="w-full font-mono text-xs" style="min-height:120px">{{ reboot_packages }}</textarea>
|
||||
</div>
|
||||
<div>
|
||||
<div class="text-xs text-gray-500 mb-1 font-bold">Exclusions spécifiques (applicatifs — hors périmètre secops)</div>
|
||||
<pre id="detailSpecific" style="font-size:0.7rem;color:#ff8800;white-space:pre-wrap;margin:0"></pre>
|
||||
<div class="flex gap-2 items-center">
|
||||
<button type="submit" class="btn-primary px-4 py-2 text-sm">Sauvegarder</button>
|
||||
<button type="button" onclick="document.querySelector('textarea[name=reboot_packages]').value = {{ default_packages|tojson }}" class="btn-sm bg-cyber-border text-gray-300 px-3 py-2">Réinitialiser aux valeurs par défaut</button>
|
||||
</div>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
<!-- Tableau serveurs -->
|
||||
<div class="card">
|
||||
<div class="table-wrap">
|
||||
<table class="table-cyber w-full" id="srvTable">
|
||||
<thead><tr>
|
||||
<th class="px-2 py-2" style="width:30px"><input type="checkbox" id="checkAll" onchange="toggleAll(this)"></th>
|
||||
<th class="px-2 py-2">Serveur</th>
|
||||
<th class="px-2 py-2">Domaine</th>
|
||||
<th class="px-2 py-2">Env</th>
|
||||
<th class="px-2 py-2">Zone</th>
|
||||
<th class="px-2 py-2">Tier</th>
|
||||
<th class="px-2 py-2">Exclusions générales</th>
|
||||
<th class="px-2 py-2">Exclusions spécifiques</th>
|
||||
<th class="px-2 py-2">Notes</th>
|
||||
<th class="px-2 py-2" style="width:60px">Save</th>
|
||||
<th class="px-2 py-2" style="width:60px">Cmd</th>
|
||||
</tr></thead>
|
||||
<tbody>
|
||||
{% for s in all_servers %}
|
||||
<tr>
|
||||
<td class="px-2 py-2"><input type="checkbox" class="srv-check" value="{{ s.server_id }}"></td>
|
||||
<td class="px-2 py-2 font-bold" style="color:#00d4ff;cursor:pointer" onclick="showDetail('{{ s.hostname }}', this)">{{ s.hostname }}</td>
|
||||
<td class="px-2 py-2 text-gray-400 text-xs">{{ s.domaine or '?' }}</td>
|
||||
<td class="px-2 py-2 text-gray-400 text-xs">{{ s.environnement or '?' }}</td>
|
||||
<td class="px-2 py-2 text-center"><span class="badge {% if s.zone == 'DMZ' %}badge-red{% else %}badge-blue{% endif %}">{{ s.zone or 'LAN' }}</span></td>
|
||||
<td class="px-2 py-2 text-gray-400 text-xs">{{ s.tier }}</td>
|
||||
<td class="px-2 py-2">
|
||||
<form method="post" action="/quickwin/config/save" class="inline-form" style="display:flex;gap:4px;align-items:center">
|
||||
<input type="hidden" name="server_id" value="{{ s.server_id }}">
|
||||
<input type="text" name="general_excludes" value="{{ s.general_excludes }}"
|
||||
style="width:200px;font-size:0.7rem;padding:2px 6px" title="{{ s.general_excludes }}">
|
||||
</td>
|
||||
<td class="px-2 py-2">
|
||||
<input type="text" name="specific_excludes" value="{{ s.specific_excludes }}"
|
||||
style="width:150px;font-size:0.7rem;padding:2px 6px" placeholder="sdcss* custom*...">
|
||||
</td>
|
||||
<td class="px-2 py-2">
|
||||
<input type="text" name="notes" value="{{ s.notes }}"
|
||||
style="width:80px;font-size:0.7rem;padding:2px 6px" placeholder="...">
|
||||
<button type="submit" class="btn-sm" style="background:#1e3a5f;color:#00d4ff;font-size:0.65rem">OK</button>
|
||||
</form>
|
||||
</td>
|
||||
<td class="px-2 py-2">
|
||||
<button type="button" class="btn-sm" style="background:#1a3a1a;color:#00ff88;font-size:0.6rem;white-space:nowrap" onclick="showDryRun('{{ s.hostname }}', this)">Dry Run</button>
|
||||
</td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
{% if not all_servers %}<tr><td colspan="11" class="px-2 py-8 text-center text-gray-500">Aucun serveur trouvé</td></tr>{% endif %}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
<!-- Aperçu de la commande QuickWin -->
|
||||
<div class="card p-4 mt-4">
|
||||
<h3 class="text-sm font-bold text-cyber-accent mb-2">Aperçu commande QuickWin générée</h3>
|
||||
<pre class="text-xs font-mono text-cyber-green bg-cyber-bg p-3 rounded overflow-x-auto" style="white-space:pre-wrap">yum update -y \
|
||||
{% for pkg in reboot_packages.split() %} --exclude={{ pkg }} \
|
||||
{% endfor %} <exclusions iTop du serveur></pre>
|
||||
<p class="text-xs text-gray-500 mt-2">Les <code><exclusions iTop du serveur></code> sont ajoutées depuis le champ <code>patch_excludes</code> de chaque serveur, gérable via <a href="/patching/config-exclusions" class="text-cyber-accent hover:underline">Config exclusions</a>.</p>
|
||||
</div>
|
||||
|
||||
<!-- Pagination -->
|
||||
<div class="flex justify-between items-center mt-4 text-sm text-gray-500">
|
||||
<span>Page {{ page }} / {{ total_pages }} — {{ total_count }} serveurs</span>
|
||||
<div class="flex gap-2">
|
||||
{% if page > 1 %}<a href="{{ qs(page - 1) }}" class="btn-sm bg-cyber-border text-gray-300">Précédent</a>{% endif %}
|
||||
{% if page < total_pages %}<a href="{{ qs(page + 1) }}" class="btn-sm bg-cyber-border text-gray-300">Suivant</a>{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Bulk modal -->
|
||||
<div id="bulkModal" style="display:none;position:fixed;top:0;left:0;right:0;bottom:0;background:rgba(0,0,0,.7);z-index:100;align-items:center;justify-content:center" onclick="if(event.target===this)this.style.display='none'">
|
||||
<div class="card" style="width:550px;max-width:90vw;padding:24px">
|
||||
<h3 style="color:#00d4ff;font-weight:bold;margin-bottom:12px">Modification groupée</h3>
|
||||
<p class="text-xs text-gray-500 mb-3">Cochez les serveurs dans le tableau, puis appliquez les exclusions.</p>
|
||||
<form method="post" action="/quickwin/config/bulk-add">
|
||||
<input type="hidden" name="server_ids" id="bulkIds">
|
||||
<div class="mb-3">
|
||||
<label class="text-xs text-gray-400 block mb-1">Exclusions générales</label>
|
||||
<textarea name="general_excludes" rows="3" style="width:100%;font-size:0.75rem">{{ default_excludes }}</textarea>
|
||||
</div>
|
||||
<div class="flex gap-2 justify-end">
|
||||
<button type="button" class="btn-sm" style="background:#333;color:#ccc;padding:6px 16px" onclick="document.getElementById('bulkModal').style.display='none'">Annuler</button>
|
||||
<button type="submit" class="btn-primary" style="padding:6px 20px" onclick="collectIds()">Appliquer</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Dry Run modal -->
|
||||
<div id="dryRunModal" style="display:none;position:fixed;top:0;left:0;right:0;bottom:0;background:rgba(0,0,0,.7);z-index:100;align-items:center;justify-content:center" onclick="if(event.target===this)this.style.display='none'">
|
||||
<div class="card" style="width:700px;max-width:90vw;padding:24px">
|
||||
<div class="flex items-center justify-between mb-3">
|
||||
<h3 style="color:#00ff88;font-weight:bold" id="dryRunTitle">Dry Run</h3>
|
||||
<button id="copyBtn" onclick="copyDryRun()" class="btn-sm" style="background:#1e3a5f;color:#00d4ff;font-size:0.75rem;padding:4px 12px">Copier</button>
|
||||
</div>
|
||||
<pre id="dryRunCmd" style="background:#0a0e17;border:1px solid #1e3a5f;border-radius:6px;padding:12px;font-size:0.75rem;color:#00ff88;white-space:pre-wrap;word-break:break-all;max-height:400px;overflow-y:auto"></pre>
|
||||
<div class="flex justify-end mt-3">
|
||||
<button type="button" class="btn-sm" style="background:#333;color:#ccc;padding:6px 16px" onclick="document.getElementById('dryRunModal').style.display='none'">Fermer</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
function showDetail(hostname, td) {
|
||||
const tr = td.closest('tr');
|
||||
const ge = tr.querySelector('input[name=general_excludes]').value.trim();
|
||||
const se = tr.querySelector('input[name=specific_excludes]').value.trim();
|
||||
document.getElementById('detailName').textContent = hostname;
|
||||
document.getElementById('detailGeneral').textContent = ge ? ge.split(/\s+/).join('\n') : '(aucune)';
|
||||
document.getElementById('detailSpecific').textContent = se ? se.split(/\s+/).join('\n') : '(aucune)';
|
||||
const panel = document.getElementById('srvDetail');
|
||||
panel.style.display = 'block';
|
||||
panel.scrollIntoView({behavior: 'smooth', block: 'nearest'});
|
||||
}
|
||||
function showDryRun(hostname, btn) {
|
||||
const tr = btn.closest('tr');
|
||||
const ge = tr.querySelector('input[name=general_excludes]').value.trim();
|
||||
const se = tr.querySelector('input[name=specific_excludes]').value.trim();
|
||||
const all = (ge + ' ' + se).trim().split(/\s+/).filter(x => x);
|
||||
const excludes = all.map(e => '--exclude=' + e).join(' \\\n ');
|
||||
const cmd = 'yum update -y \\\n ' + excludes;
|
||||
document.getElementById('dryRunTitle').textContent = 'Dry Run — ' + hostname;
|
||||
document.getElementById('dryRunCmd').textContent = cmd;
|
||||
document.getElementById('dryRunModal').style.display = 'flex';
|
||||
}
|
||||
function copyDryRun() {
|
||||
const text = document.getElementById('dryRunCmd').textContent;
|
||||
navigator.clipboard.writeText(text).then(() => {
|
||||
const btn = document.getElementById('copyBtn');
|
||||
btn.textContent = 'Copi\u00e9 !';
|
||||
setTimeout(() => btn.textContent = 'Copier', 1500);
|
||||
});
|
||||
}
|
||||
function toggleAll(cb) {
|
||||
document.querySelectorAll('.srv-check').forEach(c => c.checked = cb.checked);
|
||||
}
|
||||
function collectIds() {
|
||||
const ids = Array.from(document.querySelectorAll('.srv-check:checked')).map(c => c.value);
|
||||
document.getElementById('bulkIds').value = ids.join(',');
|
||||
}
|
||||
</script>
|
||||
{% endblock %}
|
||||
|
||||
@ -26,6 +26,7 @@
|
||||
</div>
|
||||
<div class="flex gap-2 items-center">
|
||||
<a href="/quickwin/{{ run.id }}/correspondance" class="btn-sm" style="background:#1e3a5f;color:#a78bfa;padding:4px 14px;text-decoration:none">Correspondance</a>
|
||||
<a href="/patching/validations?campaign_id={{ run.id }}" class="btn-sm" style="background:#1e3a5f;color:#00ff88;padding:4px 14px;text-decoration:none">Validations</a>
|
||||
<a href="/quickwin/{{ run.id }}/logs" class="btn-sm" style="background:#1e3a5f;color:#94a3b8;padding:4px 14px;text-decoration:none">Logs</a>
|
||||
<form method="post" action="/quickwin/{{ run.id }}/delete" onsubmit="return confirm('Supprimer cette campagne ?')">
|
||||
<button class="btn-sm btn-danger" style="padding:4px 12px">Supprimer</button>
|
||||
@ -232,6 +233,33 @@
|
||||
{% if run.status == 'patching' %}
|
||||
|
||||
{% if prod_ok and stats.prod_total > 0 %}
|
||||
{# Alerte validations hors-prod non validées avant de patcher prod #}
|
||||
{% if not validations_ok %}
|
||||
<div class="card mb-4" style="border-left:3px solid #ff3366;padding:16px;background:#5a1a1a22">
|
||||
<h3 style="color:#ff3366;font-weight:bold;margin-bottom:8px">⚠ Validations hors-prod requises avant la production</h3>
|
||||
<p class="text-xs text-gray-300 mb-2">Les non-prod suivants ne sont pas validés. Marquer leur validation dans <a href="/patching/validations?status=en_attente" class="text-cyber-accent hover:underline">/patching/validations</a> avant de patcher les prods correspondants.</p>
|
||||
<div class="text-xs" style="max-height:180px;overflow-y:auto">
|
||||
<table class="w-full table-cyber">
|
||||
<thead><tr><th class="text-left p-1">Prod</th><th class="text-left p-1">Hors-prod bloquant</th><th class="p-1">Statut</th></tr></thead>
|
||||
<tbody>
|
||||
{% for b in validations_blockers %}
|
||||
<tr class="border-t border-cyber-border/30">
|
||||
<td class="p-1 font-mono text-cyber-green">{{ b.prod_hostname }}</td>
|
||||
<td class="p-1 font-mono text-cyber-yellow">{{ b.nonprod_hostname }}</td>
|
||||
<td class="p-1 text-center">
|
||||
{% if b.status == 'en_attente' %}<span class="badge badge-yellow">En attente</span>
|
||||
{% elif b.status == 'validated_ko' %}<span class="badge badge-red">KO</span>
|
||||
{% elif b.status == 'aucun_patching' %}<span class="badge badge-gray">Pas de patching</span>
|
||||
{% else %}<span class="badge badge-gray">{{ b.status }}</span>{% endif %}
|
||||
</td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
<p class="text-xs text-gray-500 mt-2">{{ validations_blockers|length }} bloquant(s) — vous pouvez continuer mais il est recommandé d'obtenir les validations d'abord.</p>
|
||||
</div>
|
||||
{% endif %}
|
||||
<!-- Prereq + Snapshot Prod (si pas encore faits) -->
|
||||
<div class="card mb-4" style="border-left:3px solid #ff8800;padding:16px">
|
||||
<h3 style="color:#ff8800;font-weight:bold;margin-bottom:8px">Préparation Production</h3>
|
||||
|
||||
@ -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 %}
|
||||
@ -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 %}
|
||||
@ -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 = ' ';
|
||||
} 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 %}
|
||||
@ -36,7 +36,7 @@
|
||||
{% for t in ['tier0','tier1','tier2','tier3'] %}<option value="{{ t }}" {% if filters.tier == t %}selected{% endif %}>{{ t }}</option>{% endfor %}
|
||||
</select>
|
||||
<select name="etat" onchange="this.form.submit()"><option value="">Etat</option>
|
||||
{% for e in ['en_production','en_implementation','en_decommissionnement','decommissionne','eteint','eol'] %}<option value="{{ e }}" {% if filters.etat == e %}selected{% endif %}>{{ e.replace("en_","En ").replace("_"," ").title() }}</option>{% endfor %}
|
||||
{% for e,l in [('production','Production'),('implementation','Implementation'),('stock','Stock'),('obsolete','Obsolete')] %}<option value="{{ e }}" {% if filters.etat == e %}selected{% endif %}>{{ l }}</option>{% endfor %}
|
||||
</select>
|
||||
<select name="os" onchange="this.form.submit()"><option value="">OS</option>
|
||||
<option value="linux" {% if filters.os == 'linux' %}selected{% endif %}>Linux</option>
|
||||
@ -47,10 +47,14 @@
|
||||
<option value="ipop" {% if filters.owner == 'ipop' %}selected{% endif %}>ipop</option>
|
||||
<option value="na" {% if filters.owner == 'na' %}selected{% endif %}>na</option>
|
||||
</select>
|
||||
<select name="application" onchange="this.form.submit()" style="max-width:200px"><option value="">Solution app.</option>
|
||||
{% for a in applications_list %}<option value="{{ a.application_name }}" {% if filters.application == a.application_name %}selected{% endif %}>{{ a.application_name }} ({{ a.c }})</option>{% endfor %}
|
||||
</select>
|
||||
<button type="submit" class="btn-primary px-3 py-1 text-sm">Filtrer</button>
|
||||
<a href="/servers" class="text-xs text-gray-500 hover:text-gray-300">Reset</a>
|
||||
</form>
|
||||
|
||||
{% if can_edit_servers %}
|
||||
<!-- Actions groupées -->
|
||||
<div id="bulk-bar" class="card p-3 mb-2 flex gap-3 items-center flex-wrap" style="display:none">
|
||||
<span class="text-xs text-gray-400" id="bulk-count">0 sélectionné(s)</span>
|
||||
@ -71,15 +75,16 @@
|
||||
<button type="submit" class="btn-primary px-3 py-1 text-xs">Appliquer</button>
|
||||
</form>
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
<script>
|
||||
const bulkValues = {
|
||||
domain_code: [{% for d in domains_list %}{v:"{{ d.code }}", l:"{{ d.name }}"},{% endfor %}],
|
||||
env_code: [{% for e in envs_list %}{v:"{{ e.code }}", l:"{{ e.name }}"},{% endfor %}],
|
||||
tier: [{v:"tier0",l:"tier0"},{v:"tier1",l:"tier1"},{v:"tier2",l:"tier2"},{v:"tier3",l:"tier3"}],
|
||||
etat: [{v:"en_production",l:"En production"},{v:"en_implementation",l:"En implémentation"},{v:"en_decommissionnement",l:"En décommissionnement"},{v:"decommissionne",l:"Décommissionné"},{v:"eteint",l:"Éteint"},{v:"eol",l:"EOL"}],
|
||||
etat: [{v:"production",l:"Production"},{v:"implementation",l:"Implementation"},{v:"stock",l:"Stock"},{v:"obsolete",l:"Obsolete"}],
|
||||
patch_os_owner: [{v:"secops",l:"secops"},{v:"ipop",l:"ipop"},{v:"na",l:"na"}],
|
||||
licence_support: [{v:"active",l:"active"},{v:"eol",l:"eol"},{v:"els",l:"els"}],
|
||||
licence_support: [{v:"active",l:"active"},{v:"obsolete",l:"obsolete"},{v:"els",l:"els"}],
|
||||
};
|
||||
document.getElementById('bulk-field').addEventListener('change', function() {
|
||||
const sel = document.getElementById('bulk-value');
|
||||
@ -103,7 +108,7 @@ function updateBulk() {
|
||||
<div id="server-table" class="card overflow-x-auto">
|
||||
<table class="w-full table-cyber">
|
||||
<thead><tr>
|
||||
<th class="p-2 w-8"><input type="checkbox" id="check-all"></th>
|
||||
{% if can_edit_servers %}<th class="p-2 w-8"><input type="checkbox" id="check-all"></th>{% endif %}
|
||||
<th class="text-left p-2"><a href="{{ sort_url('hostname') }}" class="hover:text-cyber-accent">Hostname {{ sort_icon('hostname') }}</a></th>
|
||||
<th class="p-2"><a href="{{ sort_url('domaine') }}" class="hover:text-cyber-accent">Domaine {{ sort_icon('domaine') }}</a></th>
|
||||
<th class="p-2"><a href="{{ sort_url('env') }}" class="hover:text-cyber-accent">Env {{ sort_icon('env') }}</a></th>
|
||||
@ -114,24 +119,43 @@ function updateBulk() {
|
||||
<th class="p-2"><a href="{{ sort_url('tier') }}" class="hover:text-cyber-accent">Tier {{ sort_icon('tier') }}</a></th>
|
||||
<th class="p-2"><a href="{{ sort_url('etat') }}" class="hover:text-cyber-accent">Etat {{ sort_icon('etat') }}</a></th>
|
||||
<th class="p-2"><a href="{{ sort_url('owner') }}" class="hover:text-cyber-accent">Owner {{ sort_icon('owner') }}</a></th>
|
||||
<th class="p-2 text-left">Solution applicative</th>
|
||||
<th class="p-2 text-left">Équivalent(s)</th>
|
||||
<th class="p-2">Actions</th>
|
||||
</tr></thead>
|
||||
<tbody>
|
||||
{% for s in servers %}
|
||||
<tr id="row-{{ s.id }}" class="group" hx-get="/servers/{{ s.id }}/detail" hx-target="#detail-panel" hx-swap="innerHTML" onclick="openPanel()">
|
||||
<td class="p-2" onclick="event.stopPropagation()"><input type="checkbox" name="srv" value="{{ s.id }}" onchange="updateBulk()"></td>
|
||||
{% if can_edit_servers %}<td class="p-2" onclick="event.stopPropagation()"><input type="checkbox" name="srv" value="{{ s.id }}" onchange="updateBulk()"></td>{% endif %}
|
||||
<td class="p-2 font-mono text-sm text-cyber-accent">{{ s.hostname }}</td>
|
||||
<td class="p-2 text-center text-xs">{{ s.domaine or '-' }}</td>
|
||||
<td class="p-2 text-center"><span class="badge {% if s.environnement == 'Production' %}badge-green{% elif s.environnement == 'Recette' %}badge-yellow{% else %}badge-gray{% endif %}"title="{{ s.environnement or '' }}">{{ (s.environnement or '-')[:6] }}</span></td>
|
||||
<td class="p-2 text-center"><span class="badge {% if s.zone == 'DMZ' %}badge-red{% elif s.zone == 'EMV' %}badge-yellow{% else %}badge-blue{% endif %}">{{ s.zone or 'LAN' }}</span></td>
|
||||
<td class="p-2 text-center text-xs">{{ s.os_family or '-' }}</td>
|
||||
<td class="p-2 text-center text-xs text-gray-400" title="{{ s.os_version or '' }}">{{ s.os_short or '-' }}</td>
|
||||
<td class="p-2 text-center"><span class="badge {% if s.licence_support == 'active' %}badge-green{% elif s.licence_support == 'eol' %}badge-red{% elif s.licence_support == 'els' %}badge-yellow{% else %}badge-gray{% endif %}">{{ s.licence_support }}</span></td>
|
||||
<td class="p-2 text-center"><span class="badge {% if s.licence_support == 'active' %}badge-green{% elif s.licence_support == 'obsolete' %}badge-red{% elif s.licence_support == 'els' %}badge-yellow{% else %}badge-gray{% endif %}">{{ s.licence_support }}</span></td>
|
||||
<td class="p-2 text-center"><span class="badge {% if s.tier == 'tier0' %}badge-red{% elif s.tier == 'tier1' %}badge-yellow{% elif s.tier == 'tier2' %}badge-blue{% else %}badge-green{% endif %}">{{ s.tier }}</span></td>
|
||||
<td class="p-2 text-center"><span class="badge {% if s.etat == 'en_production' %}badge-green{% elif s.etat == 'decommissionne' %}badge-red{% else %}badge-yellow{% endif %}"title="{{ s.etat or '' }}">{{ (s.etat or '')[:8] }}</span></td>
|
||||
<td class="p-2 text-center"><span class="badge {% if s.etat == 'production' %}badge-green{% elif s.etat == 'obsolete' %}badge-red{% else %}badge-yellow{% endif %}"title="{{ s.etat or '' }}">{{ (s.etat or '')[:8] }}</span></td>
|
||||
<td class="p-2 text-center text-xs">{{ s.patch_os_owner or '-' }}</td>
|
||||
<td class="p-2 text-xs text-gray-300" title="{{ s.application_name or '' }}">{{ (s.application_name or '-')[:35] }}</td>
|
||||
<td class="p-2 text-xs" onclick="event.stopPropagation()" style="max-width:220px">
|
||||
{% set link = links.get(s.id, {}) %}
|
||||
{% if link.as_prod %}
|
||||
<span class="text-cyber-green" style="font-size:10px">→ non-prod :</span>
|
||||
{% for l in link.as_prod %}<span class="font-mono text-gray-300" title="{{ l.env_name or '' }}">{{ l.hostname }}{% if not loop.last %}, {% endif %}</span>{% endfor %}
|
||||
{% elif link.as_nonprod %}
|
||||
<span class="text-cyber-yellow" style="font-size:10px">→ prod :</span>
|
||||
{% for l in link.as_nonprod %}<span class="font-mono text-gray-300">{{ l.hostname }}{% if not loop.last %}, {% endif %}</span>{% endfor %}
|
||||
{% else %}
|
||||
<span class="text-gray-600">—</span>
|
||||
{% endif %}
|
||||
</td>
|
||||
<td class="p-2 text-center" onclick="event.stopPropagation()">
|
||||
{% if can_edit_servers %}
|
||||
<button class="btn-sm bg-cyber-border text-cyber-accent" hx-get="/servers/{{ s.id }}/edit" hx-target="#detail-panel" hx-swap="innerHTML" onclick="openPanel()">Edit</button>
|
||||
{% else %}
|
||||
<span class="text-xs text-gray-600">—</span>
|
||||
{% endif %}
|
||||
</td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
|
||||
@ -463,5 +463,79 @@
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
<!-- iTop Contacts (filtre des teams à synchroniser) -->
|
||||
{% if visible.itop_contacts %}
|
||||
<div class="card overflow-hidden">
|
||||
<button @click="open = open === 'itop_contacts' ? '' : 'itop_contacts'" class="w-full flex items-center justify-between p-4 hover:bg-cyber-border/20 transition-colors">
|
||||
<div class="flex items-center gap-3">
|
||||
<span class="text-gray-400 font-bold">iTop Contacts — Périmètre</span>
|
||||
<span class="badge badge-blue">Filtre des teams</span>
|
||||
</div>
|
||||
<span class="text-gray-500 text-lg" x-text="open === 'itop_contacts' ? '▼' : '▶'"></span>
|
||||
</button>
|
||||
<div x-show="open === 'itop_contacts'" class="border-t border-cyber-border p-4">
|
||||
<form method="POST" action="/settings/itop_contacts" class="space-y-3">
|
||||
{% for key, label, is_secret in sections.itop_contacts %}
|
||||
<div>
|
||||
<label class="text-xs text-gray-500">{{ label }}</label>
|
||||
<input type="text" name="{{ key }}" value="{{ vals[key] }}" placeholder="SecOps, iPOP, Externe, DSI, Admin DSI" class="w-full font-mono text-xs" {% if not editable.itop_contacts %}disabled{% endif %}>
|
||||
</div>
|
||||
{% endfor %}
|
||||
<div class="text-xs text-gray-600 mt-2">
|
||||
Seuls les contacts appartenant à ces teams iTop seront synchronisés dans PatchCenter. Si vide, défaut : SecOps, iPOP, Externe, DSI, Admin DSI.
|
||||
</div>
|
||||
{% if editable.itop_contacts %}<button type="submit" class="btn-primary px-4 py-2 text-sm">Sauvegarder</button>{% endif %}
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
<!-- LDAP/AD -->
|
||||
{% if visible.ldap %}
|
||||
<div class="card overflow-hidden">
|
||||
<button @click="open = open === 'ldap' ? '' : 'ldap'" class="w-full flex items-center justify-between p-4 hover:bg-cyber-border/20 transition-colors">
|
||||
<div class="flex items-center gap-3">
|
||||
<span class="text-gray-400 font-bold">LDAP / Active Directory</span>
|
||||
<span class="badge {% if vals.ldap_enabled == 'true' %}badge-green{% else %}badge-gray{% endif %}">{{ 'Activé' if vals.ldap_enabled == 'true' else 'Désactivé' }}</span>
|
||||
<span class="text-xs text-gray-500">{{ vals.ldap_server or '' }}</span>
|
||||
</div>
|
||||
<span class="text-gray-500 text-lg" x-text="open === 'ldap' ? '▼' : '▶'"></span>
|
||||
</button>
|
||||
<div x-show="open === 'ldap'" class="border-t border-cyber-border p-4">
|
||||
<form method="POST" action="/settings/ldap" class="space-y-3">
|
||||
{% for key, label, is_secret in sections.ldap %}
|
||||
<div>
|
||||
<label class="text-xs text-gray-500">{{ label }}</label>
|
||||
<input type="{{ 'password' if is_secret else 'text' }}" name="{{ key }}" value="{{ vals[key] }}" class="w-full" {% if not editable.ldap %}disabled{% endif %}>
|
||||
</div>
|
||||
{% endfor %}
|
||||
<div class="text-xs text-gray-600 mt-2">
|
||||
Une fois configuré et activé, le choix <b>Local / LDAP</b> apparaîtra sur la page de connexion. Les users peuvent aussi être forcés en LDAP via le champ "Auth" dans /users.
|
||||
</div>
|
||||
<div class="flex gap-2 items-center">
|
||||
{% if editable.ldap %}<button type="submit" class="btn-primary px-4 py-2 text-sm">Sauvegarder</button>{% endif %}
|
||||
<button type="button" onclick="testLdap()" class="btn-sm bg-cyber-border text-gray-300 px-4 py-2">Tester la connexion</button>
|
||||
<span id="ldap-test-result" class="text-xs ml-2"></span>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
</div>
|
||||
|
||||
<script>
|
||||
function testLdap() {
|
||||
var out = document.getElementById('ldap-test-result');
|
||||
out.textContent = 'Test en cours...';
|
||||
out.className = 'text-xs ml-2 text-gray-400';
|
||||
fetch('/settings/ldap/test', {method: 'POST', credentials: 'same-origin'})
|
||||
.then(function(r){ return r.json(); })
|
||||
.then(function(d){
|
||||
if (d.ok) { out.textContent = '✓ ' + (d.msg || 'OK'); out.className = 'text-xs ml-2 text-cyber-green'; }
|
||||
else { out.textContent = '✗ ' + (d.msg || 'Erreur'); out.className = 'text-xs ml-2 text-cyber-red'; }
|
||||
})
|
||||
.catch(function(e){ out.textContent = '✗ ' + e.message; out.className = 'text-xs ml-2 text-cyber-red'; });
|
||||
}
|
||||
</script>
|
||||
{% endblock %}
|
||||
|
||||
@ -1,136 +1,133 @@
|
||||
{% extends 'base.html' %}
|
||||
{% block title %}Utilisateurs{% endblock %}
|
||||
{% block content %}
|
||||
<h2 class="text-xl font-bold text-cyber-accent mb-6">Utilisateurs & Permissions</h2>
|
||||
<div class="flex justify-between items-center mb-4">
|
||||
<div>
|
||||
<h2 class="text-xl font-bold text-cyber-accent">Utilisateurs</h2>
|
||||
<p class="text-xs text-gray-500 mt-1">Gestion des comptes et profils — les utilisateurs proviennent des contacts iTop synchronisés</p>
|
||||
</div>
|
||||
{% if can_edit_users %}
|
||||
<a href="/users/add" class="btn-primary px-4 py-2 text-sm">+ Ajouter un utilisateur{% if available_count %} <span class="text-xs opacity-70">({{ available_count }} contact{{ 's' if available_count > 1 else '' }} disponible{{ 's' if available_count > 1 else '' }})</span>{% endif %}</a>
|
||||
{% endif %}
|
||||
</div>
|
||||
|
||||
{% if msg %}
|
||||
<div class="mb-4 p-3 rounded text-sm {% if msg in ('forbidden','exists','exists_inactive','cant_self') %}bg-red-900/30 text-cyber-red{% else %}bg-green-900/30 text-cyber-green{% endif %}">
|
||||
{% if msg == 'added' %}Utilisateur créé.{% elif msg == 'edited' %}Utilisateur modifié.{% elif msg == 'password_changed' %}Mot de passe modifié.{% elif msg == 'toggled' %}Statut modifié.{% elif msg == 'perms_saved' %}Permissions sauvegardées.{% elif msg == 'deleted' %}Utilisateur supprimé.{% elif msg == 'exists' %}Ce nom d'utilisateur existe déjà.{% elif msg == 'exists_inactive' %}Ce nom existe déjà (désactivé). Réactivez-le plutôt.{% elif msg == 'cant_self' %}Vous ne pouvez pas vous désactiver/supprimer vous-même.{% elif msg == 'forbidden' %}Action non autorisée.{% endif %}
|
||||
<div class="mb-3 p-2 rounded text-sm {% if 'forbidden' in msg or 'error' in msg or 'cant' in msg or 'invalid' in msg or 'required' in msg %}bg-red-900/30 text-cyber-red{% else %}bg-green-900/30 text-cyber-green{% endif %}">
|
||||
{% if msg == 'added' %}Utilisateur ajouté.
|
||||
{% elif msg == 'exists' %}Cet utilisateur existe déjà.
|
||||
{% elif msg == 'role_changed' %}Profil modifié.
|
||||
{% elif msg == 'toggled' %}Statut modifié.
|
||||
{% elif msg == 'password_changed' %}Mot de passe modifié.
|
||||
{% elif msg == 'auth_changed' %}Méthode d'auth modifiée.
|
||||
{% elif msg == 'deleted' %}Utilisateur supprimé.
|
||||
{% elif msg == 'cant_self' %}Impossible sur votre propre compte.
|
||||
{% elif msg == 'cant_demote_self' %}Vous ne pouvez pas vous rétrograder.
|
||||
{% elif msg == 'cant_delete_admin' %}Le compte admin local ne peut pas être supprimé.
|
||||
{% elif msg == 'forbidden' %}Permission refusée.
|
||||
{% elif msg == 'invalid_role' %}Profil invalide.
|
||||
{% elif msg == 'contact_required' %}Sélectionner un contact.
|
||||
{% else %}{{ msg }}{% endif %}
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
<!-- Liste utilisateurs -->
|
||||
<div x-data="{ editing: '', editUser: null }" class="space-y-3">
|
||||
{% for ud in users_data %}
|
||||
<div class="card overflow-hidden">
|
||||
<div class="flex items-center justify-between p-4 cursor-pointer hover:bg-cyber-border/20" @click="editing = editing === '{{ ud.user.id }}' ? '' : '{{ ud.user.id }}'">
|
||||
<div class="flex items-center gap-3">
|
||||
<span class="font-bold {% if ud.user.is_active %}text-cyber-accent{% else %}text-gray-600 line-through{% endif %}">{{ ud.user.username }}</span>
|
||||
<span class="text-sm text-gray-400">{{ ud.user.display_name }}</span>
|
||||
<span class="badge {% if ud.user.role == 'admin' %}badge-red{% elif ud.user.role == 'coordinator' %}badge-yellow{% elif ud.user.role == 'operator' %}badge-blue{% else %}badge-gray{% endif %}">{% if ud.user.role == "operator" %}intervenant{% else %}{{ ud.user.role }}{% endif %}</span>
|
||||
<span class="badge {% if ud.user.is_active %}badge-green{% else %}badge-red{% endif %}">{{ 'Actif' if ud.user.is_active else 'Inactif' }}</span>
|
||||
{% if ud.user.email %}<span class="text-xs text-gray-500">{{ ud.user.email }}</span>{% endif %}
|
||||
</div>
|
||||
<div class="flex items-center gap-2">
|
||||
{% for m in modules %}
|
||||
{% if ud.perms.get(m) %}
|
||||
<span class="text-xs px-1 rounded {% if ud.perms[m] == 'admin' %}bg-red-900/30 text-cyber-red{% elif ud.perms[m] == 'edit' %}bg-blue-900/30 text-cyber-accent{% else %}bg-gray-800 text-gray-500{% endif %}" title="{{ m }}:{{ ud.perms[m] }}">{{ m[:3] }}</span>
|
||||
{% endif %}
|
||||
{% endfor %}
|
||||
<span class="text-gray-500 text-lg" x-text="editing === '{{ ud.user.id }}' ? '▼' : '▶'"></span>
|
||||
</div>
|
||||
<!-- Legende des profils -->
|
||||
<div class="card p-4 mb-4">
|
||||
<h3 class="text-sm font-bold text-cyber-accent mb-2">Profils</h3>
|
||||
<div class="grid grid-cols-1 md:grid-cols-4 gap-3 text-xs">
|
||||
{% for r in roles %}
|
||||
<div>
|
||||
<div class="font-bold text-cyber-accent">{{ profile_labels[r] }}</div>
|
||||
<div class="text-gray-400 mt-1">{{ profile_descriptions[r] }}</div>
|
||||
</div>
|
||||
{% endfor %}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div x-show="editing === '{{ ud.user.id }}'" class="border-t border-cyber-border p-4 space-y-4">
|
||||
{% if can_edit_users %}
|
||||
<!-- Éditer infos user -->
|
||||
<form method="POST" action="/users/{{ ud.user.id }}/edit" class="flex gap-3 items-end">
|
||||
<div>
|
||||
<label class="text-xs text-gray-500">Nom complet</label>
|
||||
<input type="text" name="display_name" value="{{ ud.user.display_name }}" class="text-xs py-1 px-2 w-40">
|
||||
</div>
|
||||
<div>
|
||||
<label class="text-xs text-gray-500">Email</label>
|
||||
<input type="email" name="email" value="{{ ud.user.email or '' }}" class="text-xs py-1 px-2 w-44">
|
||||
</div>
|
||||
<div>
|
||||
<label class="text-xs text-gray-500">Role</label>
|
||||
<select name="role" class="text-xs py-1 px-2">
|
||||
{% for r in ['admin','coordinator','operator','viewer'] %}
|
||||
<option value="{{ r }}" {% if r == ud.user.role %}selected{% endif %}>{% if r == "operator" %}intervenant{% else %}{{ r }}{% endif %}</option>
|
||||
<!-- Tableau users -->
|
||||
<div class="card overflow-hidden">
|
||||
<table class="w-full table-cyber text-xs">
|
||||
<thead><tr>
|
||||
<th class="p-2 text-left">Utilisateur</th>
|
||||
<th class="p-2">Profil</th>
|
||||
<th class="p-2">Auth</th>
|
||||
<th class="p-2">Team iTop</th>
|
||||
<th class="p-2">Email</th>
|
||||
<th class="p-2">Statut</th>
|
||||
<th class="p-2">Dernier login</th>
|
||||
<th class="p-2">Actions</th>
|
||||
</tr></thead>
|
||||
<tbody>
|
||||
{% for u in users %}
|
||||
<tr class="border-t border-cyber-border/30 {% if not u.is_active %}opacity-50{% endif %}">
|
||||
<td class="p-2">
|
||||
<div class="font-mono font-bold">{{ u.username }}</div>
|
||||
<div class="text-gray-400">{{ u.display_name or '' }}</div>
|
||||
{% if u.force_password_change %}<div class="text-cyber-yellow" style="font-size:9px">Doit changer mdp</div>{% endif %}
|
||||
</td>
|
||||
<td class="p-2 text-center">
|
||||
{% if can_edit_users %}
|
||||
<form method="POST" action="/users/{{ u.id }}/role" style="display:inline">
|
||||
<select name="role" onchange="this.form.submit()" class="text-xs py-1 px-2">
|
||||
{% for r in roles %}
|
||||
<option value="{{ r }}" {% if u.role == r %}selected{% endif %}>{{ profile_labels[r] }}</option>
|
||||
{% endfor %}
|
||||
</select>
|
||||
</div>
|
||||
<button type="submit" class="btn-sm bg-cyber-accent text-black">Modifier</button>
|
||||
</form>
|
||||
|
||||
<!-- Permissions par module -->
|
||||
<form method="POST" action="/users/{{ ud.user.id }}/permissions">
|
||||
<h4 class="text-xs text-cyber-accent font-bold uppercase mb-2">Permissions par module</h4>
|
||||
<div class="grid grid-cols-8 gap-2">
|
||||
{% for m in modules %}
|
||||
<div>
|
||||
<label class="text-xs text-gray-500 block mb-1">{{ m }}</label>
|
||||
<select name="perm_{{ m }}" class="w-full text-xs py-1">
|
||||
<option value="">—</option>
|
||||
{% for l in levels %}
|
||||
<option value="{{ l }}" {% if ud.perms.get(m) == l %}selected{% endif %}>{{ l }}</option>
|
||||
{% endfor %}
|
||||
</select>
|
||||
</div>
|
||||
{% endfor %}
|
||||
</div>
|
||||
<button type="submit" class="btn-primary px-4 py-1 text-sm mt-2">Sauvegarder permissions</button>
|
||||
</form>
|
||||
|
||||
<!-- Actions -->
|
||||
<div class="flex gap-3 pt-2 border-t border-cyber-border items-center">
|
||||
<form method="POST" action="/users/{{ ud.user.id }}/password" class="flex gap-2 items-center">
|
||||
<input type="password" name="new_password" placeholder="Nouveau mot de passe" class="text-xs py-1 px-2 w-48">
|
||||
<button type="submit" class="btn-sm bg-cyber-border text-cyber-accent">Changer MDP</button>
|
||||
</form>
|
||||
<form method="POST" action="/users/{{ ud.user.id }}/toggle">
|
||||
<button type="submit" class="btn-sm {% if ud.user.is_active %}bg-red-900/30 text-cyber-red{% else %}bg-green-900/30 text-cyber-green{% endif %}">
|
||||
{{ 'Désactiver' if ud.user.is_active else 'Activer' }}
|
||||
</button>
|
||||
{% else %}
|
||||
<span class="badge badge-blue">{{ profile_labels[u.role] or u.role }}</span>
|
||||
{% endif %}
|
||||
</td>
|
||||
<td class="p-2 text-center">
|
||||
{% if can_edit_users %}
|
||||
<form method="POST" action="/users/{{ u.id }}/auth_type" style="display:inline">
|
||||
<select name="auth_type" onchange="this.form.submit()" class="text-xs py-1 px-2">
|
||||
<option value="local" {% if u.auth_type == 'local' %}selected{% endif %}>Local</option>
|
||||
<option value="ldap" {% if u.auth_type == 'ldap' %}selected{% endif %}>LDAP</option>
|
||||
</select>
|
||||
</form>
|
||||
<form method="POST" action="/users/{{ ud.user.id }}/delete">
|
||||
<button type="submit" class="btn-sm bg-red-900/50 text-cyber-red" onclick="return confirm('SUPPRIMER définitivement {{ ud.user.username }} ?')">Supprimer</button>
|
||||
{% else %}
|
||||
<span class="badge {% if u.auth_type == 'ldap' %}badge-blue{% else %}badge-gray{% endif %}">{{ u.auth_type }}</span>
|
||||
{% endif %}
|
||||
</td>
|
||||
<td class="p-2 text-center text-gray-400">
|
||||
{% if u.contact_team %}<span class="badge badge-gray">{{ u.contact_team }}</span>{% else %}<span class="text-gray-600">—</span>{% endif %}
|
||||
</td>
|
||||
<td class="p-2 text-gray-400">{{ u.email or '—' }}</td>
|
||||
<td class="p-2 text-center">
|
||||
<span class="badge {% if u.is_active %}badge-green{% else %}badge-red{% endif %}">{{ 'Actif' if u.is_active else 'Inactif' }}</span>
|
||||
</td>
|
||||
<td class="p-2 text-center text-gray-400" style="font-size:10px">{% if u.last_login %}{{ u.last_login.strftime('%Y-%m-%d %H:%M') }}{% else %}—{% endif %}</td>
|
||||
<td class="p-2 text-center">
|
||||
{% if can_edit_users %}
|
||||
<form method="POST" action="/users/{{ u.id }}/toggle" style="display:inline">
|
||||
<button type="submit" class="text-xs {% if u.is_active %}text-cyber-yellow{% else %}text-cyber-green{% endif %} hover:underline">{{ 'Désactiver' if u.is_active else 'Activer' }}</button>
|
||||
</form>
|
||||
</div>
|
||||
{% else %}
|
||||
<p class="text-xs text-gray-500">Permissions en lecture seule</p>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
{% endfor %}
|
||||
<button onclick="document.getElementById('pw-{{ u.id }}').style.display='table-row'" class="text-xs text-gray-400 hover:text-cyber-accent ml-2">Mdp</button>
|
||||
{% if can_admin_users and u.username != 'admin' %}
|
||||
<form method="POST" action="/users/{{ u.id }}/delete" style="display:inline" onsubmit="return confirm('Supprimer {{ u.username }} ?')">
|
||||
<button type="submit" class="text-xs text-cyber-red hover:underline ml-2">Supprimer</button>
|
||||
</form>
|
||||
{% endif %}
|
||||
{% endif %}
|
||||
</td>
|
||||
</tr>
|
||||
{% if can_edit_users %}
|
||||
<tr id="pw-{{ u.id }}" style="display:none" class="bg-cyber-border/20">
|
||||
<td colspan="8" class="p-3">
|
||||
<form method="POST" action="/users/{{ u.id }}/password" class="flex gap-2 items-center">
|
||||
<label class="text-xs text-gray-400">Nouveau mot de passe pour <b>{{ u.username }}</b> :</label>
|
||||
<input type="password" name="new_password" required minlength="6" class="text-xs py-1 px-2 flex-1">
|
||||
<label class="text-xs text-gray-400 flex items-center gap-1">
|
||||
<input type="checkbox" name="force_change" checked> Doit changer au 1er login
|
||||
</label>
|
||||
<button type="submit" class="btn-primary px-3 py-1 text-xs">Enregistrer</button>
|
||||
<button type="button" onclick="document.getElementById('pw-{{ u.id }}').style.display='none'" class="text-xs text-gray-500">Annuler</button>
|
||||
</form>
|
||||
</td>
|
||||
</tr>
|
||||
{% endif %}
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
<!-- Ajouter un utilisateur -->
|
||||
{% if can_edit_users %}
|
||||
<div class="card p-5 mt-6">
|
||||
<h3 class="text-sm font-bold text-cyber-accent mb-3">Ajouter un utilisateur</h3>
|
||||
<form method="POST" action="/users/add" class="space-y-3">
|
||||
<div class="grid grid-cols-4 gap-3">
|
||||
<div>
|
||||
<label class="text-xs text-gray-500">Username</label>
|
||||
<input type="text" name="new_username" required class="w-full">
|
||||
</div>
|
||||
<div>
|
||||
<label class="text-xs text-gray-500">Nom complet</label>
|
||||
<input type="text" name="new_display_name" required class="w-full">
|
||||
</div>
|
||||
<div>
|
||||
<label class="text-xs text-gray-500">Email</label>
|
||||
<input type="email" name="new_email" class="w-full">
|
||||
</div>
|
||||
<div>
|
||||
<label class="text-xs text-gray-500">Role</label>
|
||||
<select name="new_role" class="w-full">
|
||||
<option value="operator">intervenant</option>
|
||||
<option value="coordinator">coordinator</option>
|
||||
<option value="admin">admin</option>
|
||||
<option value="viewer">viewer</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
<div class="w-64">
|
||||
<label class="text-xs text-gray-500">Mot de passe</label>
|
||||
<input type="password" name="new_password" required class="w-full">
|
||||
</div>
|
||||
<p class="text-xs text-gray-600">Permissions pre-remplies selon le role. Modifiables ensuite.</p>
|
||||
<button type="submit" class="btn-primary px-4 py-2 text-sm">Créer</button>
|
||||
</form>
|
||||
</div>
|
||||
{% endif %}
|
||||
{% endblock %}
|
||||
|
||||
122
app/templates/users_add.html
Normal file
122
app/templates/users_add.html
Normal file
@ -0,0 +1,122 @@
|
||||
{% extends 'base.html' %}
|
||||
{% block title %}Ajouter un utilisateur{% endblock %}
|
||||
{% block content %}
|
||||
<div class="flex justify-between items-center mb-4">
|
||||
<div>
|
||||
<a href="/users" class="text-xs text-gray-500 hover:text-gray-300">← Retour utilisateurs</a>
|
||||
<h2 class="text-xl font-bold text-cyber-accent">Ajouter un utilisateur</h2>
|
||||
<p class="text-xs text-gray-500 mt-1">Choisir un contact iTop puis définir son profil PatchCenter</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Info -->
|
||||
<div class="card p-3 mb-4 text-xs text-gray-400" style="background:#111827">
|
||||
<b class="text-cyber-accent">Périmètre :</b> seuls les contacts synchronisés depuis iTop (Teams SecOps, iPOP, Externe, DSI, Admin DSI) apparaissent ici.
|
||||
Si la personne n'est pas dans la liste, elle doit d'abord être créée dans iTop par un admin DSI.
|
||||
</div>
|
||||
|
||||
<!-- Filtres -->
|
||||
<div class="card p-3 mb-4">
|
||||
<form method="GET" class="flex gap-2 items-center flex-wrap">
|
||||
<input type="text" name="search" value="{{ search }}" placeholder="Rechercher nom ou email..." class="text-xs py-1 px-2" style="width:250px">
|
||||
<select name="team" class="text-xs py-1 px-2" style="width:150px">
|
||||
<option value="">Toutes teams</option>
|
||||
{% for t in teams %}
|
||||
<option value="{{ t.team }}" {% if team_filter == t.team %}selected{% endif %}>{{ t.team }}</option>
|
||||
{% endfor %}
|
||||
</select>
|
||||
<button type="submit" class="btn-primary px-3 py-1 text-xs">Filtrer</button>
|
||||
<a href="/users/add" class="text-xs text-gray-400 hover:text-cyber-accent">Reset</a>
|
||||
<span class="text-xs text-gray-500 ml-auto">{{ contacts|length }} contact{{ 's' if contacts|length > 1 else '' }}</span>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
{% if not contacts %}
|
||||
<div class="card p-6 text-center text-gray-500">
|
||||
<p>Aucun contact disponible pour créer un utilisateur.</p>
|
||||
<p class="text-xs mt-2">Lancer une synchro depuis iTop dans <a href="/referentiel" class="text-cyber-accent">Référentiel</a>.</p>
|
||||
</div>
|
||||
{% else %}
|
||||
<!-- Tableau contacts -->
|
||||
<form method="POST" action="/users/add">
|
||||
<div class="card overflow-hidden mb-4">
|
||||
<table class="w-full table-cyber text-xs">
|
||||
<thead><tr>
|
||||
<th class="p-2 w-8"></th>
|
||||
<th class="p-2 text-left">Nom</th>
|
||||
<th class="p-2 text-left">Email</th>
|
||||
<th class="p-2">Team iTop</th>
|
||||
<th class="p-2 text-left">Fonction</th>
|
||||
<th class="p-2 text-left">Téléphone</th>
|
||||
</tr></thead>
|
||||
<tbody>
|
||||
{% for c in contacts %}
|
||||
<tr class="border-t border-cyber-border/30 hover:bg-cyber-hover cursor-pointer" onclick="selectContact({{ c.id }}, '{{ c.name|e }}', '{{ c.team|default('') }}')">
|
||||
<td class="p-2 text-center">
|
||||
<input type="radio" name="contact_id" value="{{ c.id }}" id="c{{ c.id }}">
|
||||
</td>
|
||||
<td class="p-2"><label for="c{{ c.id }}" class="cursor-pointer font-mono">{{ c.name }}</label></td>
|
||||
<td class="p-2 text-gray-400">{{ c.email }}</td>
|
||||
<td class="p-2 text-center">{% if c.team %}<span class="badge badge-gray">{{ c.team }}</span>{% else %}—{% endif %}</td>
|
||||
<td class="p-2 text-gray-400">{{ c.function or '' }}</td>
|
||||
<td class="p-2 text-gray-400">{{ c.telephone or '' }}</td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
<!-- Formulaire de création -->
|
||||
<div class="card p-4">
|
||||
<h3 class="text-sm font-bold text-cyber-accent mb-3">Paramètres du compte <span id="selected-name" class="text-gray-400 font-normal ml-2"></span></h3>
|
||||
<div class="grid grid-cols-1 md:grid-cols-2 gap-3">
|
||||
<div>
|
||||
<label class="text-xs text-gray-500 block mb-1">Profil (rôle PatchCenter)</label>
|
||||
<select name="role" id="role-select" class="w-full text-sm">
|
||||
{% for r in roles %}
|
||||
<option value="{{ r }}">{{ profile_labels[r] }} — {{ profile_descriptions[r][:60] }}...</option>
|
||||
{% endfor %}
|
||||
</select>
|
||||
</div>
|
||||
<div>
|
||||
<label class="text-xs text-gray-500 block mb-1">Type d'authentification</label>
|
||||
<select name="auth_type" class="w-full text-sm">
|
||||
<option value="local">Local (mot de passe stocké)</option>
|
||||
<option value="ldap">LDAP/AD</option>
|
||||
</select>
|
||||
</div>
|
||||
<div>
|
||||
<label class="text-xs text-gray-500 block mb-1">Nom d'utilisateur (optionnel, dérivé de l'email sinon)</label>
|
||||
<input type="text" name="username" class="w-full text-sm" placeholder="ex: jean.dupont">
|
||||
</div>
|
||||
<div>
|
||||
<label class="text-xs text-gray-500 block mb-1">Mot de passe initial (optionnel)</label>
|
||||
<input type="password" name="password" class="w-full text-sm" placeholder="Laisser vide pour générer">
|
||||
</div>
|
||||
</div>
|
||||
<div class="mt-3">
|
||||
<label class="text-xs text-gray-400 flex items-center gap-2">
|
||||
<input type="checkbox" name="force_change" checked> Forcer le changement de mot de passe au 1er login
|
||||
</label>
|
||||
</div>
|
||||
<div class="mt-4 flex gap-2">
|
||||
<button type="submit" class="btn-primary px-4 py-2 text-sm" id="submit-btn" disabled>Créer l'utilisateur</button>
|
||||
<a href="/users" class="text-xs text-gray-500 hover:text-cyber-accent self-center ml-2">Annuler</a>
|
||||
</div>
|
||||
</div>
|
||||
</form>
|
||||
{% endif %}
|
||||
|
||||
<script>
|
||||
function selectContact(id, name, team) {
|
||||
document.getElementById('c' + id).checked = true;
|
||||
document.getElementById('selected-name').textContent = '— ' + name + (team ? ' (' + team + ')' : '');
|
||||
document.getElementById('submit-btn').disabled = false;
|
||||
// Auto-sélection du profil suggéré selon team
|
||||
var roleSel = document.getElementById('role-select');
|
||||
if (team === 'SecOps') roleSel.value = 'operator';
|
||||
else if (team === 'iPOP') roleSel.value = 'coordinator';
|
||||
else if (team === 'Externe') roleSel.value = 'viewer';
|
||||
}
|
||||
</script>
|
||||
{% endblock %}
|
||||
15
migrate_applications.sql
Normal file
15
migrate_applications.sql
Normal file
@ -0,0 +1,15 @@
|
||||
-- Ajoute itop_id à applications
|
||||
ALTER TABLE applications ADD COLUMN IF NOT EXISTS itop_id INTEGER;
|
||||
CREATE UNIQUE INDEX IF NOT EXISTS applications_itop_id_unique ON applications (itop_id) WHERE itop_id IS NOT NULL;
|
||||
|
||||
-- Ajoute criticite status si besoin
|
||||
ALTER TABLE applications ADD COLUMN IF NOT EXISTS status VARCHAR(30);
|
||||
|
||||
-- Vide les données applicatives actuelles côté serveurs
|
||||
UPDATE servers SET application_id = NULL, application_name = NULL WHERE application_id IS NOT NULL OR application_name IS NOT NULL;
|
||||
|
||||
-- Vide la table applications (on repart de zéro depuis iTop)
|
||||
DELETE FROM applications;
|
||||
|
||||
SELECT 'applications vidée' as msg;
|
||||
SELECT COUNT(*) as servers_cleared FROM servers WHERE application_id IS NULL AND application_name IS NULL;
|
||||
41
migrate_correspondance.sql
Normal file
41
migrate_correspondance.sql
Normal file
@ -0,0 +1,41 @@
|
||||
-- Table de correspondance prod ↔ hors-prod
|
||||
CREATE TABLE IF NOT EXISTS server_correspondance (
|
||||
id SERIAL PRIMARY KEY,
|
||||
prod_server_id INTEGER REFERENCES servers(id) ON DELETE CASCADE,
|
||||
nonprod_server_id INTEGER REFERENCES servers(id) ON DELETE CASCADE,
|
||||
environment_code VARCHAR(50),
|
||||
source VARCHAR(20) NOT NULL DEFAULT 'auto',
|
||||
note TEXT,
|
||||
created_by INTEGER REFERENCES users(id),
|
||||
created_at TIMESTAMP DEFAULT NOW(),
|
||||
updated_at TIMESTAMP DEFAULT NOW(),
|
||||
CONSTRAINT server_correspondance_source_check CHECK (source IN ('auto','manual','exception')),
|
||||
CONSTRAINT server_correspondance_uniq UNIQUE(prod_server_id, nonprod_server_id)
|
||||
);
|
||||
CREATE INDEX IF NOT EXISTS idx_corr_prod ON server_correspondance(prod_server_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_corr_nonprod ON server_correspondance(nonprod_server_id);
|
||||
|
||||
-- Table des validations post-patching
|
||||
CREATE TABLE IF NOT EXISTS patch_validation (
|
||||
id SERIAL PRIMARY KEY,
|
||||
server_id INTEGER REFERENCES servers(id) ON DELETE CASCADE,
|
||||
campaign_id INTEGER,
|
||||
campaign_type VARCHAR(30),
|
||||
patch_date TIMESTAMP DEFAULT NOW(),
|
||||
status VARCHAR(20) NOT NULL DEFAULT 'en_attente',
|
||||
validated_by_contact_id INTEGER REFERENCES contacts(id),
|
||||
validated_by_name VARCHAR(200),
|
||||
validated_at TIMESTAMP,
|
||||
marked_by_user_id INTEGER REFERENCES users(id),
|
||||
forced_reason TEXT,
|
||||
notes TEXT,
|
||||
created_at TIMESTAMP DEFAULT NOW(),
|
||||
updated_at TIMESTAMP DEFAULT NOW(),
|
||||
CONSTRAINT patch_validation_status_check CHECK (status IN ('en_attente','validated_ok','validated_ko','forced'))
|
||||
);
|
||||
CREATE INDEX IF NOT EXISTS idx_pv_server ON patch_validation(server_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_pv_campaign ON patch_validation(campaign_id, campaign_type);
|
||||
CREATE INDEX IF NOT EXISTS idx_pv_status ON patch_validation(status);
|
||||
CREATE INDEX IF NOT EXISTS idx_pv_patch_date ON patch_validation(patch_date DESC);
|
||||
|
||||
SELECT 'schema créé' as msg;
|
||||
7
migrate_etat.sql
Normal file
7
migrate_etat.sql
Normal file
@ -0,0 +1,7 @@
|
||||
ALTER TABLE servers DROP CONSTRAINT IF EXISTS servers_etat_check;
|
||||
UPDATE servers SET etat = 'production' WHERE etat = 'en_production';
|
||||
UPDATE servers SET etat = 'implementation' WHERE etat IN ('en_implementation', 'en_cours');
|
||||
UPDATE servers SET etat = 'obsolete' WHERE etat IN ('decommissionne', 'en_decommissionnement', 'eteint', 'eol');
|
||||
ALTER TABLE servers ADD CONSTRAINT servers_etat_check CHECK (etat IN ('production', 'implementation', 'stock', 'obsolete'));
|
||||
ALTER TABLE servers ALTER COLUMN etat SET DEFAULT 'production';
|
||||
SELECT etat, COUNT(*) FROM servers GROUP BY etat ORDER BY 2 DESC;
|
||||
23
migrate_users.sql
Normal file
23
migrate_users.sql
Normal file
@ -0,0 +1,23 @@
|
||||
-- Link users to iTop Person
|
||||
ALTER TABLE users ADD COLUMN IF NOT EXISTS itop_person_id INTEGER;
|
||||
ALTER TABLE users ADD COLUMN IF NOT EXISTS last_itop_sync TIMESTAMP;
|
||||
ALTER TABLE users ADD COLUMN IF NOT EXISTS force_password_change BOOLEAN DEFAULT false;
|
||||
|
||||
-- source already implied by auth_type, no change
|
||||
|
||||
-- Link contacts to iTop Person
|
||||
ALTER TABLE contacts ADD COLUMN IF NOT EXISTS itop_id INTEGER;
|
||||
ALTER TABLE contacts ADD COLUMN IF NOT EXISTS telephone VARCHAR(50);
|
||||
ALTER TABLE contacts ADD COLUMN IF NOT EXISTS team VARCHAR(100);
|
||||
ALTER TABLE contacts ADD COLUMN IF NOT EXISTS function VARCHAR(200);
|
||||
|
||||
-- Unique constraint on email for linking users
|
||||
CREATE UNIQUE INDEX IF NOT EXISTS users_email_unique_active ON users (LOWER(email)) WHERE email IS NOT NULL AND email != '';
|
||||
|
||||
-- Match existing users to contacts by email
|
||||
UPDATE users u SET itop_person_id = c.itop_id
|
||||
FROM contacts c
|
||||
WHERE u.email IS NOT NULL AND u.email != '' AND LOWER(u.email) = LOWER(c.email) AND c.itop_id IS NOT NULL;
|
||||
|
||||
SELECT COUNT(*) as users_linked FROM users WHERE itop_person_id IS NOT NULL;
|
||||
SELECT COUNT(*) as users_total FROM users;
|
||||
52
replace_etat.py
Normal file
52
replace_etat.py
Normal file
@ -0,0 +1,52 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Remplace les anciennes valeurs d'etat par les nouvelles (iTop) dans tous les fichiers Python et templates."""
|
||||
import os
|
||||
import re
|
||||
|
||||
# Ordre important: plus specifiques d'abord
|
||||
REPLACEMENTS = [
|
||||
("en_decommissionnement", "obsolete"),
|
||||
("en_implementation", "implementation"),
|
||||
("en_production", "production"),
|
||||
("decommissionne", "obsolete"),
|
||||
("en_cours", "implementation"), # prudent: seulement si etat context
|
||||
("'eteint'", "'obsolete'"),
|
||||
('"eteint"', '"obsolete"'),
|
||||
("'eol'", "'obsolete'"),
|
||||
('"eol"', '"obsolete"'),
|
||||
]
|
||||
|
||||
# Ne PAS toucher:
|
||||
# - itop_service.py (mappings deja corrects, on les laisse pour rétro-compat)
|
||||
# - migrate_etat.sql (script de migration)
|
||||
# - les tests
|
||||
SKIP_FILES = {
|
||||
"itop_service.py", # contient le mapping historique
|
||||
}
|
||||
|
||||
ROOT = "app"
|
||||
count = 0
|
||||
for root, dirs, files in os.walk(ROOT):
|
||||
for f in files:
|
||||
if not f.endswith((".py", ".html")):
|
||||
continue
|
||||
if f in SKIP_FILES:
|
||||
continue
|
||||
path = os.path.join(root, f)
|
||||
with open(path, encoding="utf-8") as fh:
|
||||
content = fh.read()
|
||||
orig = content
|
||||
for old, new in REPLACEMENTS:
|
||||
# en_cours est ambigu : on ne remplace que dans contexte etat
|
||||
if old == "en_cours":
|
||||
content = re.sub(r"(etat\s*[=:]\s*['\"])en_cours(['\"])", r"\1implementation\2", content)
|
||||
content = re.sub(r"\betat\s*==\s*['\"]en_cours['\"]", "etat == 'implementation'", content)
|
||||
continue
|
||||
content = content.replace(old, new)
|
||||
if content != orig:
|
||||
with open(path, "w", encoding="utf-8") as fh:
|
||||
fh.write(content)
|
||||
print(f" modified: {path}")
|
||||
count += 1
|
||||
|
||||
print(f"\nTotal: {count} files modified")
|
||||
Loading…
Reference in New Issue
Block a user