Campagnes: workflow complet, audit serveurs, assignation operateurs

- Workflow: draft → pending_validation (COMEP) → planned → in_progress → completed
- Prereqs auto: SSH, disque (1.2Go /, 800Mo /var), satellite
- Assignation: operateurs prennent/liberent, coordinateur assigne/force
- Limites par operateur par campagne (max_servers + raison)
- Default intervenant permanent par serveur (auto-assigne)
- Planning jours: lun+mar hors-prod, mer+jeu prod, jamais vendredi
- Preferences serveur: pref_patch_jour, pref_patch_heure (permanents)
- Audit serveurs: import Excel, 29 colonnes, KPIs, detail HTMX
- Jours en francais (Lun, Mar, Mer, Jeu)

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
Khalid MOUTAOUAKIL 2026-04-04 13:06:08 +02:00
parent 7adb9e553c
commit ba8a969366
8 changed files with 694 additions and 276 deletions

View File

@ -3,7 +3,7 @@ from fastapi import FastAPI
from fastapi.responses import RedirectResponse
from fastapi.staticfiles import StaticFiles
from .config import APP_NAME, APP_VERSION
from .routers import auth, dashboard, servers, settings, users, campaigns, planning, specifics
from .routers import auth, dashboard, servers, settings, users, campaigns, planning, specifics, audit
app = FastAPI(title=APP_NAME, version=APP_VERSION)
app.mount("/static", StaticFiles(directory="app/static"), name="static")
@ -16,6 +16,7 @@ app.include_router(users.router)
app.include_router(campaigns.router)
app.include_router(planning.router)
app.include_router(specifics.router)
app.include_router(audit.router)
@app.get("/")

84
app/routers/audit.py Normal file
View File

@ -0,0 +1,84 @@
"""Router audit serveurs — resultats des scans d'audit"""
from fastapi import APIRouter, Request, Depends, Query
from fastapi.responses import HTMLResponse, RedirectResponse
from fastapi.templating import Jinja2Templates
from sqlalchemy import text
from ..dependencies import get_db, get_current_user
from ..config import APP_NAME
router = APIRouter()
templates = Jinja2Templates(directory="app/templates")
@router.get("/audit", response_class=HTMLResponse)
async def audit_page(request: Request, db=Depends(get_db),
filter: str = Query(None), search: str = Query(None)):
user = get_current_user(request)
if not user:
return RedirectResponse(url="/login")
where = ["1=1"]
params = {}
if filter == "failed":
where.append("sa.status = 'CONNECTION_FAILED'")
elif filter == "disk":
where.append("sa.disk_alert = true")
elif filter == "no_qualys":
where.append("sa.qualys_active = false AND sa.status = 'OK'")
elif filter == "no_s1":
where.append("sa.sentinelone_active = false AND sa.status = 'OK'")
elif filter == "no_autostart":
where.append("sa.running_not_enabled IS NOT NULL AND sa.running_not_enabled != '' AND sa.status = 'OK'")
elif filter == "failed_svc":
where.append("sa.failed_services IS NOT NULL AND sa.failed_services != '' AND sa.status = 'OK'")
if search:
where.append("sa.hostname ILIKE :q")
params["q"] = f"%{search}%"
wc = " AND ".join(where)
entries = db.execute(text(f"""
SELECT sa.*, d.name as domaine, e.name as environnement
FROM server_audit sa
LEFT JOIN servers s ON sa.server_id = s.id
LEFT JOIN domain_environments de ON s.domain_env_id = de.id
LEFT JOIN domains d ON de.domain_id = d.id
LEFT JOIN environments e ON de.environment_id = e.id
WHERE {wc}
ORDER BY sa.disk_alert DESC, sa.status, sa.hostname
LIMIT 500
"""), params).fetchall()
# Stats
stats = db.execute(text("""
SELECT
COUNT(*) as total,
COUNT(*) FILTER (WHERE status = 'OK') as ok,
COUNT(*) FILTER (WHERE status = 'CONNECTION_FAILED') as failed,
COUNT(*) FILTER (WHERE disk_alert = true) as disk_alerts,
COUNT(*) FILTER (WHERE qualys_active = true) as qualys_ok,
COUNT(*) FILTER (WHERE sentinelone_active = true) as s1_ok,
COUNT(*) FILTER (WHERE running_not_enabled IS NOT NULL AND running_not_enabled != '') as no_autostart,
COUNT(*) FILTER (WHERE failed_services IS NOT NULL AND failed_services != '') as failed_svc
FROM server_audit
""")).fetchone()
return templates.TemplateResponse("audit.html", {
"request": request, "user": user, "app_name": APP_NAME,
"entries": entries, "stats": stats, "filter": filter,
"search": search,
})
@router.get("/audit/{audit_id}", response_class=HTMLResponse)
async def audit_detail(request: Request, audit_id: int, db=Depends(get_db)):
user = get_current_user(request)
if not user:
return HTMLResponse("<p>Non autorise</p>")
entry = db.execute(text("SELECT * FROM server_audit WHERE id = :id"),
{"id": audit_id}).fetchone()
if not entry:
return HTMLResponse("<p>Non trouve</p>")
return templates.TemplateResponse("partials/audit_detail.html", {
"request": request, "e": entry,
})

View File

@ -1,4 +1,4 @@
"""Router campagnes — creation depuis planning + gestion exclusions"""
"""Router campagnes — creation, prereqs, assignation, workflow"""
from datetime import datetime
from fastapi import APIRouter, Request, Depends, Query, Form
from fastapi.responses import HTMLResponse, RedirectResponse
@ -10,9 +10,12 @@ from ..services.campaign_service import (
create_campaign_from_planning, get_servers_for_planning,
update_campaign_status, exclude_session, restore_session,
validate_prereq, get_prereq_stats, can_plan_campaign,
bulk_auto_exclude_failed_prereqs,
assign_operator, unassign_operator, get_operator_count, is_forced,
update_session_schedule, get_operator_limit, set_operator_limit,
get_campaign_operator_limits,
)
from ..services.prereq_service import check_prereqs_campaign, check_single_prereq
from ..services.secrets_service import get_secret
from ..config import APP_NAME
router = APIRouter()
@ -28,6 +31,14 @@ EXCLUSION_REASONS = [
]
def _get_max_servers(db):
v = get_secret(db, "max_servers_per_operator")
try:
return int(v) if v else 0
except (ValueError, TypeError):
return 0
@router.get("/campaigns", response_class=HTMLResponse)
async def campaigns_list(request: Request, db=Depends(get_db),
year: int = Query(None), status: str = Query(None)):
@ -38,7 +49,6 @@ async def campaigns_list(request: Request, db=Depends(get_db),
year = datetime.now().year
campaigns = list_campaigns(db, year=year, status=status)
# Semaines planifiees pour cette annee (pour le formulaire de creation)
now = datetime.now()
current_week = now.isocalendar()[1]
planned_weeks = db.execute(text("""
@ -62,7 +72,6 @@ async def campaigns_list(request: Request, db=Depends(get_db),
@router.get("/campaigns/preview", response_class=HTMLResponse)
async def campaign_preview(request: Request, db=Depends(get_db),
year: int = Query(...), week: int = Query(...)):
"""HTMX: preview des serveurs pour une semaine du planning"""
user = get_current_user(request)
if not user:
return HTMLResponse("<p>Non autorise</p>")
@ -79,23 +88,17 @@ async def campaign_create(request: Request, db=Depends(get_db)):
user = get_current_user(request)
if not user:
return RedirectResponse(url="/login")
form = await request.form()
year = int(form.get("year", datetime.now().year))
week = int(form.get("week_number", 0))
label = form.get("label", f"Patch S{week:02d} {year}")
# Serveurs exclus (checkboxes non cochees)
excluded = []
for key in form.keys():
if key.startswith("exclude_"):
sid = int(key.replace("exclude_", ""))
excluded.append(sid)
excluded.append(int(key.replace("exclude_", "")))
cid = create_campaign_from_planning(db, year, week, label, user.get("uid"), excluded)
if not cid:
return RedirectResponse(url=f"/campaigns?year={year}&msg=no_servers", status_code=303)
return RedirectResponse(url=f"/campaigns/{cid}", status_code=303)
@ -104,21 +107,29 @@ async def campaign_detail(request: Request, campaign_id: int, db=Depends(get_db)
user = get_current_user(request)
if not user:
return RedirectResponse(url="/login")
campaign = get_campaign(db, campaign_id)
if not campaign:
return RedirectResponse(url="/campaigns")
sessions = get_campaign_sessions(db, campaign_id)
stats = get_campaign_stats(db, campaign_id)
prereq = get_prereq_stats(db, campaign_id)
can_plan = can_plan_campaign(db, campaign_id)
role = user.get("role", "viewer")
is_coordinator = role in ("admin", "coordinator")
max_srv = _get_max_servers(db)
intervenants = db.execute(text(
"SELECT id, display_name FROM users WHERE is_active = true ORDER BY display_name"
)).fetchall()
op_limits = get_campaign_operator_limits(db, campaign_id)
return templates.TemplateResponse("campaign_detail.html", {
"request": request, "user": user, "app_name": APP_NAME,
"c": campaign, "sessions": sessions, "stats": stats,
"prereq": prereq, "can_plan": can_plan,
"exclusion_reasons": EXCLUSION_REASONS,
"is_coordinator": is_coordinator, "intervenants": intervenants,
"op_limits": op_limits,
"msg": request.query_params.get("msg"),
})
@ -129,7 +140,6 @@ async def campaign_status_change(request: Request, campaign_id: int,
user = get_current_user(request)
if not user:
return RedirectResponse(url="/login")
# Bloquer planned si prereqs non valides
if new_status == "planned" and not can_plan_campaign(db, campaign_id):
return RedirectResponse(url=f"/campaigns/{campaign_id}?msg=prereq_needed", status_code=303)
update_campaign_status(db, campaign_id, new_status)
@ -147,43 +157,27 @@ async def session_prereq(request: Request, session_id: int, db=Depends(get_db),
rollback_method or None, rollback_justif, user.get("sub"))
row = db.execute(text("SELECT campaign_id FROM patch_sessions WHERE id = :id"),
{"id": session_id}).fetchone()
cid = row.campaign_id if row else 0
return RedirectResponse(url=f"/campaigns/{cid}?msg=prereq_saved#row-{session_id}", status_code=303)
@router.post("/campaigns/{campaign_id}/auto-exclude-failed")
async def auto_exclude_failed(request: Request, campaign_id: int, db=Depends(get_db)):
user = get_current_user(request)
if not user:
return RedirectResponse(url="/login")
count = bulk_auto_exclude_failed_prereqs(db, campaign_id, user.get("sub"))
return RedirectResponse(url=f"/campaigns/{campaign_id}?msg=auto_excluded_{count}", status_code=303)
return RedirectResponse(url=f"/campaigns/{row.campaign_id}?msg=prereq_saved#row-{session_id}", status_code=303)
@router.post("/campaigns/{campaign_id}/check-prereqs")
async def campaign_check_prereqs(request: Request, campaign_id: int, db=Depends(get_db)):
"""Lance la verification automatique des prereqs pour toute la campagne"""
user = get_current_user(request)
if not user:
return RedirectResponse(url="/login")
checked, auto_excluded = check_prereqs_campaign(db, campaign_id)
return RedirectResponse(
url=f"/campaigns/{campaign_id}?msg=checked_{checked}_{auto_excluded}",
status_code=303
)
return RedirectResponse(url=f"/campaigns/{campaign_id}?msg=checked_{checked}_{auto_excluded}", status_code=303)
@router.post("/campaigns/session/{session_id}/check-prereq")
async def session_check_prereq(request: Request, session_id: int, db=Depends(get_db)):
"""Lance la verification prereq pour un seul serveur"""
user = get_current_user(request)
if not user:
return RedirectResponse(url="/login")
check_single_prereq(db, session_id)
row = db.execute(text("SELECT campaign_id FROM patch_sessions WHERE id = :id"),
{"id": session_id}).fetchone()
cid = row.campaign_id if row else 0
return RedirectResponse(url=f"/campaigns/{cid}?msg=prereq_checked#row-{session_id}", status_code=303)
return RedirectResponse(url=f"/campaigns/{row.campaign_id}?msg=prereq_checked#row-{session_id}", status_code=303)
@router.post("/campaigns/session/{session_id}/exclude")
@ -193,11 +187,9 @@ async def session_exclude(request: Request, session_id: int, db=Depends(get_db),
if not user:
return RedirectResponse(url="/login")
exclude_session(db, session_id, reason, detail, user.get("sub"))
# Retrouver campaign_id
row = db.execute(text("SELECT campaign_id FROM patch_sessions WHERE id = :id"),
{"id": session_id}).fetchone()
cid = row.campaign_id if row else 0
return RedirectResponse(url=f"/campaigns/{cid}?msg=excluded#row-{session_id}", status_code=303)
return RedirectResponse(url=f"/campaigns/{row.campaign_id}?msg=excluded#row-{session_id}", status_code=303)
@router.post("/campaigns/session/{session_id}/restore")
@ -208,5 +200,86 @@ async def session_restore(request: Request, session_id: int, db=Depends(get_db))
restore_session(db, session_id)
row = db.execute(text("SELECT campaign_id FROM patch_sessions WHERE id = :id"),
{"id": session_id}).fetchone()
cid = row.campaign_id if row else 0
return RedirectResponse(url=f"/campaigns/{cid}?msg=restored#row-{session_id}", status_code=303)
return RedirectResponse(url=f"/campaigns/{row.campaign_id}?msg=restored#row-{session_id}", status_code=303)
# --- Assignation operateurs ---
@router.post("/campaigns/session/{session_id}/take")
async def session_take(request: Request, session_id: int, db=Depends(get_db)):
"""Operateur prend un serveur"""
user = get_current_user(request)
if not user:
return RedirectResponse(url="/login")
row = db.execute(text("SELECT campaign_id, intervenant_id, forced_assignment FROM patch_sessions WHERE id = :id"),
{"id": session_id}).fetchone()
if row.intervenant_id:
return RedirectResponse(url=f"/campaigns/{row.campaign_id}?msg=already_taken", status_code=303)
# Verifier limite par campagne pour cet operateur
limit = get_operator_limit(db, row.campaign_id, user.get("uid"))
if limit > 0:
current = get_operator_count(db, row.campaign_id, user.get("uid"))
if current >= limit:
return RedirectResponse(url=f"/campaigns/{row.campaign_id}?msg=limit_reached", status_code=303)
assign_operator(db, session_id, user.get("uid"))
return RedirectResponse(url=f"/campaigns/{row.campaign_id}?msg=taken#row-{session_id}", status_code=303)
@router.post("/campaigns/session/{session_id}/release")
async def session_release(request: Request, session_id: int, db=Depends(get_db)):
"""Operateur se desassigne (sauf si forced)"""
user = get_current_user(request)
if not user:
return RedirectResponse(url="/login")
if is_forced(db, session_id):
row = db.execute(text("SELECT campaign_id FROM patch_sessions WHERE id = :id"),
{"id": session_id}).fetchone()
return RedirectResponse(url=f"/campaigns/{row.campaign_id}?msg=forced_cant_release", status_code=303)
unassign_operator(db, session_id)
row = db.execute(text("SELECT campaign_id FROM patch_sessions WHERE id = :id"),
{"id": session_id}).fetchone()
return RedirectResponse(url=f"/campaigns/{row.campaign_id}?msg=released#row-{session_id}", status_code=303)
@router.post("/campaigns/session/{session_id}/assign")
async def session_assign(request: Request, session_id: int, db=Depends(get_db),
operator_id: str = Form(""), forced: str = Form("")):
"""Coordinateur assigne un operateur (peut forcer)"""
user = get_current_user(request)
if not user:
return RedirectResponse(url="/login")
oid = int(operator_id) if operator_id else None
is_forced_flag = forced == "on"
if oid:
assign_operator(db, session_id, oid, forced=is_forced_flag)
else:
unassign_operator(db, session_id)
row = db.execute(text("SELECT campaign_id FROM patch_sessions WHERE id = :id"),
{"id": session_id}).fetchone()
return RedirectResponse(url=f"/campaigns/{row.campaign_id}?msg=assigned#row-{session_id}", status_code=303)
# --- Limites operateurs par campagne ---
@router.post("/campaigns/{campaign_id}/operator-limit")
async def set_op_limit(request: Request, campaign_id: int, db=Depends(get_db),
operator_id: int = Form(...), max_servers: int = Form(0),
note: str = Form("")):
user = get_current_user(request)
if not user:
return RedirectResponse(url="/login")
set_operator_limit(db, campaign_id, operator_id, max_servers, note or None)
return RedirectResponse(url=f"/campaigns/{campaign_id}?msg=limit_set", status_code=303)
@router.post("/campaigns/session/{session_id}/schedule")
async def session_schedule(request: Request, session_id: int, db=Depends(get_db),
date_prevue: str = Form(""), heure_prevue: str = Form("")):
"""Coordinateur ajuste date/heure"""
user = get_current_user(request)
if not user:
return RedirectResponse(url="/login")
update_session_schedule(db, session_id, date_prevue or None, heure_prevue or None)
row = db.execute(text("SELECT campaign_id FROM patch_sessions WHERE id = :id"),
{"id": session_id}).fetchone()
return RedirectResponse(url=f"/campaigns/{row.campaign_id}?msg=scheduled#row-{session_id}", status_code=303)

View File

@ -1,5 +1,5 @@
"""Service campagnes — logique metier patching"""
from datetime import datetime
from datetime import datetime, date, timedelta
from sqlalchemy import text
@ -36,6 +36,7 @@ def get_campaign_sessions(db, campaign_id):
return db.execute(text("""
SELECT ps.*, s.hostname, s.fqdn, s.os_family, s.os_version, s.tier,
s.etat, s.ssh_method, s.licence_support, s.machine_type,
s.pref_patch_jour, s.pref_patch_heure,
d.name as domaine, e.name as environnement,
u.display_name as intervenant_name
FROM patch_sessions ps
@ -46,15 +47,10 @@ def get_campaign_sessions(db, campaign_id):
LEFT JOIN users u ON ps.intervenant_id = u.id
WHERE ps.campaign_id = :cid
ORDER BY CASE ps.status
WHEN 'in_progress' THEN 1
WHEN 'pending' THEN 2
WHEN 'prereq_ok' THEN 3
WHEN 'patched' THEN 4
WHEN 'failed' THEN 5
WHEN 'reported' THEN 6
WHEN 'excluded' THEN 7
WHEN 'cancelled' THEN 8
ELSE 9 END, s.hostname
WHEN 'in_progress' THEN 1 WHEN 'pending' THEN 2 WHEN 'prereq_ok' THEN 3
WHEN 'patched' THEN 4 WHEN 'failed' THEN 5 WHEN 'reported' THEN 6
WHEN 'excluded' THEN 7 WHEN 'cancelled' THEN 8 ELSE 9 END,
ps.date_prevue, s.hostname
"""), {"cid": campaign_id}).fetchall()
@ -69,13 +65,14 @@ def get_campaign_stats(db, campaign_id):
COUNT(*) FILTER (WHERE status = 'skipped') as skipped,
COUNT(*) FILTER (WHERE status = 'excluded') as excluded,
COUNT(*) FILTER (WHERE status = 'reported') as reported,
COUNT(*) FILTER (WHERE status = 'cancelled') as cancelled
COUNT(*) FILTER (WHERE status = 'cancelled') as cancelled,
COUNT(*) FILTER (WHERE intervenant_id IS NOT NULL AND status NOT IN ('excluded','cancelled')) as assigned,
COUNT(*) FILTER (WHERE intervenant_id IS NULL AND status NOT IN ('excluded','cancelled')) as unassigned
FROM patch_sessions WHERE campaign_id = :cid
"""), {"cid": campaign_id}).fetchone()
def get_planning_for_week(db, year, week_number):
"""Retourne les entrees planning pour une semaine donnee"""
return db.execute(text("""
SELECT pp.*, d.name as domain_name
FROM patch_planning pp
@ -85,91 +82,105 @@ def get_planning_for_week(db, year, week_number):
"""), {"y": year, "wn": week_number}).fetchall()
def _week_dates(year, week_number):
"""Retourne lun, mar, mer, jeu de la semaine ISO"""
jan4 = date(year, 1, 4)
start_of_w1 = jan4 - timedelta(days=jan4.isoweekday() - 1)
monday = start_of_w1 + timedelta(weeks=week_number - 1)
return monday, monday + timedelta(1), monday + timedelta(2), monday + timedelta(3)
def get_servers_for_planning(db, year, week_number):
"""Retourne les serveurs a proposer pour une semaine du planning.
Inclut les domaines planifies + DMZ (toujours inclus)."""
planning = get_planning_for_week(db, year, week_number)
if not planning:
return [], []
# Construire les filtres domaine/env depuis le planning
domain_envs = []
for p in planning:
if p.domain_code == 'DMZ':
continue # DMZ traite separement
continue
if p.env_scope == 'prod':
domain_envs.append(("d.code = :dc_{0} AND e.name = 'Production'".format(len(domain_envs)), p.domain_code))
elif p.env_scope == 'hprod':
domain_envs.append(("d.code = :dc_{0} AND e.name != 'Production'".format(len(domain_envs)), p.domain_code))
elif p.env_scope == 'prod_pilot':
domain_envs.append(("d.code = :dc_{0}".format(len(domain_envs)), p.domain_code))
else: # all
else:
domain_envs.append(("d.code = :dc_{0}".format(len(domain_envs)), p.domain_code))
if not domain_envs:
return [], planning
# Construire la clause OR
or_clauses = []
params = {}
for i, (clause, dc) in enumerate(domain_envs):
or_clauses.append(clause)
params[f"dc_{i}"] = dc
# Toujours inclure DMZ
or_clauses.append("d.code = 'DMZ'")
where = f"""
s.etat = 'en_production'
AND s.patch_os_owner = 'secops'
AND s.licence_support IN ('active', 'els')
s.etat = 'en_production' AND s.patch_os_owner = 'secops'
AND s.licence_support IN ('active', 'els') AND s.os_family = 'linux'
AND ({' OR '.join(or_clauses)})
"""
servers = db.execute(text(f"""
SELECT s.id, s.hostname, s.fqdn, s.os_family, s.os_version, s.tier,
s.licence_support, s.ssh_method, s.machine_type,
s.pref_patch_jour, s.pref_patch_heure, s.default_intervenant_id,
d.name as domaine, d.code as domain_code, e.name as environnement
FROM servers s
LEFT JOIN domain_environments de ON s.domain_env_id = de.id
LEFT JOIN domains d ON de.domain_id = d.id
LEFT JOIN environments e ON de.environment_id = e.id
WHERE {where}
ORDER BY d.name, e.name, s.hostname
ORDER BY e.name, d.name, s.hostname
"""), params).fetchall()
return servers, planning
def create_campaign_from_planning(db, year, week_number, label, user_id, excluded_ids=None):
"""Cree une campagne depuis le planning avec exclusions"""
servers, planning = get_servers_for_planning(db, year, week_number)
if not servers:
return None
wc = f"S{week_number:02d}"
# Dates de la semaine
lun, mar, mer, jeu = _week_dates(year, week_number)
p = planning[0] if planning else None
ds = p.week_start if p else None
de = p.week_end if p else None
row = db.execute(text("""
INSERT INTO campaigns (week_code, year, label, status, date_start, date_end, created_by)
VALUES (:wc, :y, :label, 'draft', :ds, :de, :uid)
RETURNING id
"""), {"wc": wc, "y": year, "label": label, "ds": ds, "de": de, "uid": user_id}).fetchone()
"""), {"wc": wc, "y": year, "label": label, "ds": lun, "de": jeu, "uid": user_id}).fetchone()
cid = row.id
excluded = set(excluded_ids or [])
for s in servers:
status = 'excluded' if s.id in excluded else 'pending'
db.execute(text("""
INSERT INTO patch_sessions (campaign_id, server_id, status)
VALUES (:cid, :sid, :st)
ON CONFLICT (campaign_id, server_id) DO NOTHING
"""), {"cid": cid, "sid": s.id, "st": status})
# Date par defaut : hors-prod = lun/mar, prod = mer/jeu
is_prod = (s.environnement == 'Production')
if s.pref_patch_jour and s.pref_patch_jour != 'indifferent':
jour_map = {"lundi": lun, "mardi": mar, "mercredi": mer, "jeudi": jeu}
date_prevue = jour_map.get(s.pref_patch_jour, mer if is_prod else lun)
else:
date_prevue = mer if is_prod else lun
heure = s.pref_patch_heure if s.pref_patch_heure and s.pref_patch_heure != 'indifferent' else None
# Auto-assigner le default intervenant si defini
default_op = s.default_intervenant_id if hasattr(s, 'default_intervenant_id') else None
forced = True if default_op else False
db.execute(text("""
INSERT INTO patch_sessions (campaign_id, server_id, status, date_prevue, heure_prevue,
intervenant_id, forced_assignment, assigned_at)
VALUES (:cid, :sid, :st, :dp, :hp, :oid, :forced, CASE WHEN :oid IS NOT NULL THEN now() END)
ON CONFLICT (campaign_id, server_id) DO NOTHING
"""), {"cid": cid, "sid": s.id, "st": status, "dp": date_prevue, "hp": heure,
"oid": default_op, "forced": forced})
# Update total
count = db.execute(text(
"SELECT COUNT(*) FROM patch_sessions WHERE campaign_id = :cid AND status != 'excluded'"
), {"cid": cid}).scalar()
@ -181,34 +192,28 @@ def create_campaign_from_planning(db, year, week_number, label, user_id, exclude
def exclude_session(db, session_id, reason, detail, username):
"""Exclut un serveur d'une campagne avec motif"""
db.execute(text("""
UPDATE patch_sessions SET
status = 'excluded', exclusion_reason = :reason,
exclusion_detail = :detail, excluded_by = :by,
excluded_at = now()
exclusion_detail = :detail, excluded_by = :by, excluded_at = now()
WHERE id = :id
"""), {"id": session_id, "reason": reason, "detail": detail, "by": username})
# Recalculer total
row = db.execute(text("SELECT campaign_id FROM patch_sessions WHERE id = :id"),
{"id": session_id}).fetchone()
if row:
count = db.execute(text(
"SELECT COUNT(*) FROM patch_sessions WHERE campaign_id = :cid AND status NOT IN ('excluded','cancelled')"
), {"cid": row.campaign_id}).scalar()
db.execute(text("UPDATE campaigns SET total_servers = :c WHERE id = :cid"),
{"c": count, "cid": row.campaign_id})
_recalc_total(db, session_id)
db.commit()
def restore_session(db, session_id):
"""Restaure un serveur exclu"""
db.execute(text("""
UPDATE patch_sessions SET
status = 'pending', exclusion_reason = NULL,
exclusion_detail = NULL, excluded_by = NULL, excluded_at = NULL
WHERE id = :id
"""), {"id": session_id})
_recalc_total(db, session_id)
db.commit()
def _recalc_total(db, session_id):
row = db.execute(text("SELECT campaign_id FROM patch_sessions WHERE id = :id"),
{"id": session_id}).fetchone()
if row:
@ -217,11 +222,81 @@ def restore_session(db, session_id):
), {"cid": row.campaign_id}).scalar()
db.execute(text("UPDATE campaigns SET total_servers = :c WHERE id = :cid"),
{"c": count, "cid": row.campaign_id})
def assign_operator(db, session_id, operator_id, forced=False):
"""Assigne un operateur a un serveur"""
db.execute(text("""
UPDATE patch_sessions SET intervenant_id = :oid, assigned_at = now(),
forced_assignment = :forced
WHERE id = :id
"""), {"id": session_id, "oid": operator_id, "forced": forced})
db.commit()
def unassign_operator(db, session_id):
"""Desassigne un operateur"""
db.execute(text("""
UPDATE patch_sessions SET intervenant_id = NULL, assigned_at = NULL, forced_assignment = false
WHERE id = :id
"""), {"id": session_id})
db.commit()
def is_forced(db, session_id):
"""Verifie si l'assignation est forcee"""
row = db.execute(text("SELECT forced_assignment FROM patch_sessions WHERE id = :id"),
{"id": session_id}).fetchone()
return row.forced_assignment if row else False
def get_operator_count(db, campaign_id, operator_id):
"""Nombre de serveurs pris par un operateur dans cette campagne"""
return db.execute(text("""
SELECT COUNT(*) FROM patch_sessions
WHERE campaign_id = :cid AND intervenant_id = :oid AND status NOT IN ('excluded','cancelled')
"""), {"cid": campaign_id, "oid": operator_id}).scalar()
def get_operator_limit(db, campaign_id, operator_id):
"""Limite pour un operateur dans cette campagne (0=illimite)"""
row = db.execute(text("""
SELECT max_servers FROM campaign_operator_limits
WHERE campaign_id = :cid AND user_id = :uid
"""), {"cid": campaign_id, "uid": operator_id}).fetchone()
return row.max_servers if row else 0
def set_operator_limit(db, campaign_id, operator_id, max_servers, note=None):
"""Definit la limite pour un operateur dans cette campagne"""
db.execute(text("""
INSERT INTO campaign_operator_limits (campaign_id, user_id, max_servers, note)
VALUES (:cid, :uid, :max, :note)
ON CONFLICT (campaign_id, user_id) DO UPDATE SET max_servers = EXCLUDED.max_servers, note = EXCLUDED.note
"""), {"cid": campaign_id, "uid": operator_id, "max": max_servers, "note": note})
db.commit()
def get_campaign_operator_limits(db, campaign_id):
"""Retourne les limites de tous les operateurs pour une campagne"""
return db.execute(text("""
SELECT col.*, u.display_name
FROM campaign_operator_limits col
JOIN users u ON col.user_id = u.id
WHERE col.campaign_id = :cid ORDER BY u.display_name
"""), {"cid": campaign_id}).fetchall()
def update_session_schedule(db, session_id, date_prevue, heure_prevue):
"""Coordinateur ajuste la date/heure d'un serveur"""
db.execute(text("""
UPDATE patch_sessions SET date_prevue = :dp, heure_prevue = :hp
WHERE id = :id
"""), {"id": session_id, "dp": date_prevue or None, "hp": heure_prevue or None})
db.commit()
def validate_prereq(db, session_id, ssh, satellite, rollback, rollback_justif, username):
"""Valide les prereqs d'un serveur dans une campagne"""
db.execute(text("""
UPDATE patch_sessions SET
prereq_ssh = :ssh, prereq_satellite = :sat,
@ -234,24 +309,7 @@ def validate_prereq(db, session_id, ssh, satellite, rollback, rollback_justif, u
db.commit()
def bulk_auto_exclude_failed_prereqs(db, campaign_id, username):
"""Exclut automatiquement les serveurs qui n'ont pas passe les prereqs"""
failed = db.execute(text("""
SELECT id FROM patch_sessions
WHERE campaign_id = :cid AND status = 'pending'
AND prereq_validated = false
AND prereq_date IS NOT NULL
AND (prereq_ssh = 'ko' OR prereq_satellite = 'ko' OR rollback_method IS NULL)
"""), {"cid": campaign_id}).fetchall()
count = 0
for r in failed:
exclude_session(db, r.id, "creneau_inadequat", "Prereqs non valides — report auto", username)
count += 1
return count
def get_prereq_stats(db, campaign_id):
"""Stats prereqs d'une campagne"""
return db.execute(text("""
SELECT
COUNT(*) FILTER (WHERE status = 'pending') as total_pending,
@ -267,7 +325,6 @@ def get_prereq_stats(db, campaign_id):
def can_plan_campaign(db, campaign_id):
"""Verifie si la campagne peut passer en 'planned' (tous les prereqs pending valides)"""
pending_not_validated = db.execute(text("""
SELECT COUNT(*) FROM patch_sessions
WHERE campaign_id = :cid AND status = 'pending' AND prereq_validated = false
@ -279,9 +336,3 @@ def update_campaign_status(db, campaign_id, new_status):
db.execute(text("UPDATE campaigns SET status = :s WHERE id = :id"),
{"s": new_status, "id": campaign_id})
db.commit()
def get_reference_data(db):
domains = db.execute(text("SELECT code, name FROM domains ORDER BY display_order")).fetchall()
envs = db.execute(text("SELECT code, name FROM environments ORDER BY display_order")).fetchall()
return domains, envs

89
app/templates/audit.html Normal file
View File

@ -0,0 +1,89 @@
{% extends 'base.html' %}
{% block title %}Audit Serveurs{% endblock %}
{% block content %}
<h2 class="text-xl font-bold text-cyber-accent mb-4">Audit Serveurs <span class="text-sm text-gray-500">({{ stats.total }})</span></h2>
<!-- KPIs -->
<div class="grid grid-cols-8 gap-2 mb-4">
<a href="/audit" class="card p-2 text-center hover:border-cyber-accent/50 {% if not filter %}border-cyber-accent{% endif %}">
<div class="text-lg font-bold text-cyber-green">{{ stats.ok }}</div>
<div class="text-[10px] text-gray-500">Connectes</div>
</a>
<a href="/audit?filter=failed" class="card p-2 text-center hover:border-cyber-accent/50 {% if filter == 'failed' %}border-cyber-accent{% endif %}">
<div class="text-lg font-bold text-cyber-red">{{ stats.failed }}</div>
<div class="text-[10px] text-gray-500">Echoues</div>
</a>
<a href="/audit?filter=disk" class="card p-2 text-center hover:border-cyber-accent/50 {% if filter == 'disk' %}border-cyber-accent{% endif %}">
<div class="text-lg font-bold text-cyber-yellow">{{ stats.disk_alerts }}</div>
<div class="text-[10px] text-gray-500">Alerte disque</div>
</a>
<a href="/audit?filter=no_autostart" class="card p-2 text-center hover:border-cyber-accent/50 {% if filter == 'no_autostart' %}border-cyber-accent{% endif %}">
<div class="text-lg font-bold text-cyber-yellow">{{ stats.no_autostart }}</div>
<div class="text-[10px] text-gray-500">Sans auto-start</div>
</a>
<a href="/audit?filter=failed_svc" class="card p-2 text-center hover:border-cyber-accent/50 {% if filter == 'failed_svc' %}border-cyber-accent{% endif %}">
<div class="text-lg font-bold text-cyber-red">{{ stats.failed_svc }}</div>
<div class="text-[10px] text-gray-500">Svc en echec</div>
</a>
<div class="card p-2 text-center">
<div class="text-lg font-bold text-cyber-green">{{ stats.qualys_ok }}</div>
<div class="text-[10px] text-gray-500">Qualys OK</div>
</div>
<div class="card p-2 text-center">
<div class="text-lg font-bold text-cyber-green">{{ stats.s1_ok }}</div>
<div class="text-[10px] text-gray-500">SentinelOne OK</div>
</div>
<div class="card p-2 text-center">
<form method="GET" class="flex gap-1">
<input type="text" name="search" value="{{ search or '' }}" placeholder="Hostname" class="text-xs py-1 px-2 w-full">
</form>
</div>
</div>
<!-- Table -->
<div class="card overflow-x-auto">
<table class="w-full table-cyber">
<thead><tr>
<th class="text-left p-2">Hostname</th>
<th class="p-2">Statut</th>
<th class="p-2">Connexion</th>
<th class="p-2">Kernel</th>
<th class="p-2">Uptime</th>
<th class="p-2">Disque</th>
<th class="p-2">Qualys</th>
<th class="p-2">S1</th>
<th class="p-2">Sans auto</th>
<th class="p-2">Svc KO</th>
<th class="p-2">Detail</th>
</tr></thead>
<tbody>
{% for e in entries %}
<tr class="{% if e.status != 'OK' %}bg-red-900/10{% elif e.disk_alert %}bg-yellow-900/10{% endif %}">
<td class="p-2 font-mono text-sm text-cyber-accent">{{ e.hostname }}</td>
<td class="p-2 text-center"><span class="badge {% if e.status == 'OK' %}badge-green{% else %}badge-red{% endif %}">{{ e.status[:10] }}</span></td>
<td class="p-2 text-center text-[10px] text-gray-400">{% if e.resolved_fqdn %}{{ e.resolved_fqdn[:25] }}{% else %}-{% endif %}</td>
<td class="p-2 text-center text-[10px] text-gray-400">{{ (e.kernel or '-')[:20] }}</td>
<td class="p-2 text-center text-[10px] text-gray-400">{{ (e.uptime or '-')[:15] }}</td>
<td class="p-2 text-center">
{% if e.disk_alert %}<span class="badge badge-red" title="{{ e.disk_detail[:80] if e.disk_detail else '' }}">ALERTE</span>
{% elif e.status == 'OK' %}<span class="text-cyber-green text-xs">OK</span>
{% else %}-{% endif %}
</td>
<td class="p-2 text-center">{% if e.qualys_active %}<span class="text-cyber-green text-xs">OK</span>{% else %}<span class="text-cyber-red text-xs">KO</span>{% endif %}</td>
<td class="p-2 text-center">{% if e.sentinelone_active %}<span class="text-cyber-green text-xs">OK</span>{% else %}<span class="text-cyber-red text-xs">KO</span>{% endif %}</td>
<td class="p-2 text-center text-[10px]">{% if e.running_not_enabled %}<span class="text-cyber-yellow" title="{{ e.running_not_enabled[:100] }}">{{ e.running_not_enabled.split('\n')|length }}</span>{% else %}-{% endif %}</td>
<td class="p-2 text-center text-[10px]">{% if e.failed_services %}<span class="text-cyber-red">{{ e.failed_services[:20] }}</span>{% else %}-{% endif %}</td>
<td class="p-2 text-center">
<button class="btn-sm bg-cyber-border text-cyber-accent"
hx-get="/audit/{{ e.id }}" hx-target="#audit-detail" hx-swap="innerHTML"
onclick="document.getElementById('audit-detail').style.display='block'; window.scrollTo({top:0,behavior:'smooth'})">Voir</button>
</td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
<!-- Panel detail -->
<div id="audit-detail" class="card mt-4 p-5" style="display:none"></div>
{% endblock %}

View File

@ -59,7 +59,7 @@
<a href="/campaigns" class="block px-3 py-2 rounded-md text-sm hover:bg-cyber-border/30 {% if 'campaigns' in request.url.path %}bg-cyber-border/30 text-cyber-accent{% else %}text-gray-400{% endif %}">Campagnes</a>
<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>
<a href="#" class="block px-3 py-2 rounded-md text-sm text-gray-600">Tags Qualys</a>
<a href="#" class="block px-3 py-2 rounded-md text-sm text-gray-600">Audit</a>
<a href="/audit" class="block px-3 py-2 rounded-md text-sm hover:bg-cyber-border/30 {% if request.url.path == '/audit' or '/audit/' in request.url.path %}bg-cyber-border/30 text-cyber-accent{% else %}text-gray-400{% endif %}">Audit</a>
<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>
<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>
</nav>

View File

@ -8,97 +8,78 @@
<a href="/campaigns" class="text-xs text-gray-500 hover:text-gray-300">← Campagnes</a>
<h2 class="text-xl font-bold text-cyber-accent">{{ c.label or c.week_code }}</h2>
<div class="flex items-center gap-3 mt-1">
<span class="badge {% if c.status == 'draft' %}badge-gray{% elif c.status == 'planned' %}badge-blue{% elif c.status == 'in_progress' %}badge-yellow{% elif c.status == 'completed' %}badge-green{% else %}badge-red{% endif %}">{{ c.status }}</span>
<span class="badge {% if c.status == 'draft' %}badge-gray{% elif c.status == 'pending_validation' %}badge-yellow{% elif c.status == 'planned' %}badge-blue{% elif c.status == 'in_progress' %}badge-yellow{% elif c.status == 'completed' %}badge-green{% else %}badge-red{% endif %}">{{ c.status }}</span>
<span class="text-sm text-gray-500">{{ c.week_code }} {{ c.year }}</span>
{% if c.date_start %}<span class="text-sm text-gray-500">{{ c.date_start.strftime('%d/%m/%Y') }}{% if c.date_end %} → {{ c.date_end.strftime('%d/%m/%Y') }}{% endif %}</span>{% endif %}
<span class="text-xs text-gray-600">par {{ c.created_by_name or '-' }}</span>
</div>
</div>
<div class="flex gap-2">
{% if c.status == 'draft' %}
{% if can_plan %}
{% if is_coordinator %}
{% if c.status == 'draft' %}
{% if can_plan %}
<form method="POST" action="/campaigns/{{ c.id }}/status"><input type="hidden" name="new_status" value="pending_validation">
<button class="btn-primary px-4 py-2 text-sm">Soumettre au COMEP</button></form>
{% else %}
<button class="btn-sm bg-gray-700 text-gray-500 px-4 py-2 cursor-not-allowed">COMEP (prereqs requis)</button>
{% endif %}
{% elif c.status == 'pending_validation' %}
<form method="POST" action="/campaigns/{{ c.id }}/status"><input type="hidden" name="new_status" value="planned">
<button class="btn-primary px-4 py-2 text-sm">Planifier</button></form>
{% else %}
<button class="btn-sm bg-gray-700 text-gray-500 px-4 py-2 cursor-not-allowed" title="Tous les prereqs doivent etre valides">Planifier (prereqs requis)</button>
<button class="btn-primary px-4 py-2 text-sm">Planifier (post-COMEP)</button></form>
<form method="POST" action="/campaigns/{{ c.id }}/status"><input type="hidden" name="new_status" value="draft">
<button class="btn-sm bg-cyber-border text-gray-300 px-4 py-2">Retour draft</button></form>
{% elif c.status == 'planned' %}
<form method="POST" action="/campaigns/{{ c.id }}/status"><input type="hidden" name="new_status" value="in_progress">
<button class="btn-primary px-4 py-2 text-sm">Demarrer</button></form>
{% elif c.status == 'in_progress' %}
<form method="POST" action="/campaigns/{{ c.id }}/status"><input type="hidden" name="new_status" value="completed">
<button class="btn-sm bg-cyber-green text-black px-4 py-2">Terminer</button></form>
{% endif %}
{% if c.status in ('draft', 'pending_validation', 'planned') %}
<form method="POST" action="/campaigns/{{ c.id }}/status"><input type="hidden" name="new_status" value="cancelled">
<button class="btn-sm bg-red-900/30 text-cyber-red px-4 py-2" onclick="return confirm('Annuler ?')">Annuler</button></form>
{% endif %}
{% elif c.status == 'planned' %}
<form method="POST" action="/campaigns/{{ c.id }}/status"><input type="hidden" name="new_status" value="in_progress">
<button class="btn-primary px-4 py-2 text-sm">Demarrer</button></form>
{% elif c.status == 'in_progress' %}
<form method="POST" action="/campaigns/{{ c.id }}/status"><input type="hidden" name="new_status" value="completed">
<button class="btn-sm bg-cyber-green text-black px-4 py-2">Terminer</button></form>
{% endif %}
{% if c.status in ('draft', 'planned') %}
<form method="POST" action="/campaigns/{{ c.id }}/status"><input type="hidden" name="new_status" value="cancelled">
<button class="btn-sm bg-red-900/30 text-cyber-red px-4 py-2" onclick="return confirm('Annuler ?')">Annuler</button></form>
{% endif %}
</div>
</div>
{% if msg %}
<div class="mb-3 p-2 rounded text-sm {% if 'prereq_needed' in msg %}bg-red-900/30 text-cyber-red{% else %}bg-green-900/30 text-cyber-green{% endif %}">
{% if msg == 'excluded' %}Serveur exclu.{% elif msg == 'restored' %}Serveur restaure.{% elif msg == 'prereq_saved' %}Prereqs sauvegardes.{% elif msg == 'prereq_checked' %}Prereq re-verifie.{% elif msg == 'prereq_needed' %}Impossible de planifier : tous les serveurs pending doivent avoir leurs prereqs valides.{% elif msg.startswith('checked_') %}Verification terminee : {{ msg.split('_')[1] }} serveur(s) verifies, {{ msg.split('_')[2] }} auto-exclus.{% elif msg.startswith('auto_excluded_') %}{{ msg.split('_')[-1] }} serveur(s) exclus (prereqs KO).{% endif %}
<div class="mb-3 p-2 rounded text-sm {% if msg in ('prereq_needed','already_taken','limit_reached') %}bg-red-900/30 text-cyber-red{% else %}bg-green-900/30 text-cyber-green{% endif %}">
{% if msg == 'excluded' %}Serveur exclu.{% elif msg == 'restored' %}Serveur restaure.{% elif msg == 'prereq_saved' %}Prereqs sauvegardes.{% elif msg == 'prereq_checked' %}Prereq verifie.{% elif msg == 'prereq_needed' %}Prereqs requis avant soumission COMEP.{% elif msg == 'taken' %}Serveur pris.{% elif msg == 'released' %}Serveur libere.{% elif msg == 'assigned' %}Operateur assigne.{% elif msg == 'scheduled' %}Planning ajuste.{% elif msg == 'limit_set' %}Limite operateur definie.{% elif msg == 'already_taken' %}Ce serveur est deja pris.{% elif msg == 'limit_reached' %}Limite de serveurs atteinte pour cette campagne.{% elif msg == 'forced_cant_release' %}Assignation forcee — seul le coordinateur peut modifier.{% elif msg.startswith('checked_') %}Verification: {{ msg.split('_')[1] }} verifies, {{ msg.split('_')[2] }} auto-exclus.{% endif %}
</div>
{% endif %}
<!-- KPIs -->
<div class="grid grid-cols-7 gap-3 mb-4">
<div class="card p-3 text-center">
<div class="text-2xl font-bold text-cyber-accent">{{ stats.total }}</div>
<div class="text-xs text-gray-500">Total</div>
</div>
<div class="card p-3 text-center">
<div class="text-2xl font-bold text-cyber-green">{{ stats.patched }}</div>
<div class="text-xs text-gray-500">Patches</div>
</div>
<div class="card p-3 text-center">
<div class="text-2xl font-bold text-cyber-red">{{ stats.failed }}</div>
<div class="text-xs text-gray-500">Echoues</div>
</div>
<div class="card p-3 text-center">
<div class="text-2xl font-bold text-cyber-yellow">{{ stats.pending }}</div>
<div class="text-xs text-gray-500">En attente</div>
</div>
<div class="card p-3 text-center">
<div class="text-2xl font-bold text-gray-500">{{ stats.excluded }}</div>
<div class="text-xs text-gray-500">Exclus</div>
</div>
<div class="card p-3 text-center">
<div class="text-2xl font-bold text-blue-400">{{ stats.reported }}</div>
<div class="text-xs text-gray-500">Reportes</div>
</div>
<div class="card p-3 text-center">
<div class="grid grid-cols-8 gap-2 mb-4">
<div class="card p-2 text-center"><div class="text-xl font-bold text-cyber-accent">{{ stats.total }}</div><div class="text-[10px] text-gray-500">Total</div></div>
<div class="card p-2 text-center"><div class="text-xl font-bold text-cyber-green">{{ stats.patched }}</div><div class="text-[10px] text-gray-500">Patches</div></div>
<div class="card p-2 text-center"><div class="text-xl font-bold text-cyber-red">{{ stats.failed }}</div><div class="text-[10px] text-gray-500">Echoues</div></div>
<div class="card p-2 text-center"><div class="text-xl font-bold text-cyber-yellow">{{ stats.pending }}</div><div class="text-[10px] text-gray-500">En attente</div></div>
<div class="card p-2 text-center"><div class="text-xl font-bold text-gray-500">{{ stats.excluded }}</div><div class="text-[10px] text-gray-500">Exclus</div></div>
<div class="card p-2 text-center"><div class="text-xl font-bold text-cyber-accent">{{ stats.assigned }}</div><div class="text-[10px] text-gray-500">Assignes</div></div>
<div class="card p-2 text-center"><div class="text-xl font-bold text-gray-400">{{ stats.unassigned }}</div><div class="text-[10px] text-gray-500">Libres</div></div>
<div class="card p-2 text-center">
{% set patchable = stats.total - stats.excluded - stats.cancelled %}
{% if patchable > 0 %}
<div class="text-2xl font-bold text-cyber-accent">{{ (stats.patched / patchable * 100)|int }}%</div>
{% else %}<div class="text-2xl font-bold text-gray-600">-</div>{% endif %}
<div class="text-xs text-gray-500">Progression</div>
<div class="text-xl font-bold text-cyber-accent">{% if patchable > 0 %}{{ (stats.patched / patchable * 100)|int }}%{% else %}-{% endif %}</div>
<div class="text-[10px] text-gray-500">Progression</div>
</div>
</div>
<!-- Prereqs stats (draft only) -->
{% if c.status == 'draft' and prereq %}
<!-- Prereqs (draft) -->
{% if c.status == 'draft' and prereq and is_coordinator %}
<div class="card p-4 mb-4">
<div class="flex justify-between items-center mb-2">
<h3 class="text-sm font-bold text-cyber-accent">Prerequis ({{ prereq.prereq_ok }}/{{ prereq.total_pending }} valides)</h3>
<div class="flex gap-2">
<form method="POST" action="/campaigns/{{ c.id }}/check-prereqs">
<button class="btn-primary px-3 py-1 text-sm" onclick="this.textContent='Verification en cours...'; this.disabled=true; this.form.submit()">Verifier les prereqs</button>
</form>
{% if prereq.prereq_ko > 0 %}
<form method="POST" action="/campaigns/{{ c.id }}/auto-exclude-failed">
<button class="btn-sm bg-red-900/30 text-cyber-red" onclick="return confirm('Exclure les {{ prereq.prereq_ko }} serveurs en echec ?')">Exclure {{ prereq.prereq_ko }} KO</button>
</form>
{% endif %}
</div>
<form method="POST" action="/campaigns/{{ c.id }}/check-prereqs">
<button class="btn-primary px-3 py-1 text-sm">Verifier prereqs</button>
</form>
</div>
<div class="grid grid-cols-5 gap-3 text-sm">
<div class="flex justify-between"><span class="text-gray-500">A verifier</span><span class="text-cyber-yellow">{{ prereq.prereq_todo }}</span></div>
<div class="flex justify-between"><span class="text-gray-500">SSH OK</span><span class="text-cyber-green">{{ prereq.ssh_ok }}</span></div>
<div class="flex justify-between"><span class="text-gray-500">Satellite OK</span><span class="text-cyber-green">{{ prereq.sat_ok }}</span></div>
<div class="flex justify-between"><span class="text-gray-500">Rollback OK</span><span class="text-cyber-green">{{ prereq.rollback_ok }}</span></div>
<div class="flex justify-between"><span class="text-gray-500">Disque OK</span><span class="text-cyber-green">{{ prereq.disk_ok }}</span></div>
<div class="flex justify-between"><span class="text-gray-500">SSH</span><span class="text-cyber-green">{{ prereq.ssh_ok }}</span></div>
<div class="flex justify-between"><span class="text-gray-500">Satellite</span><span class="text-cyber-green">{{ prereq.sat_ok }}</span></div>
<div class="flex justify-between"><span class="text-gray-500">Rollback</span><span class="text-cyber-green">{{ prereq.rollback_ok }}</span></div>
<div class="flex justify-between"><span class="text-gray-500">Disque</span><span class="text-cyber-green">{{ prereq.disk_ok }}</span></div>
</div>
{% if prereq.total_pending > 0 %}
<div class="w-full h-2 bg-gray-800 rounded-full overflow-hidden mt-2">
@ -109,20 +90,20 @@
{% endif %}
<!-- Table serveurs -->
<div x-data="{ excluding: null, prereqing: null }" class="card overflow-x-auto">
<table class="w-full table-cyber">
<div x-data="{ action: null, target: null }" class="card overflow-x-auto">
<table class="w-full table-cyber text-xs">
<thead><tr>
<th class="text-left p-2">Hostname</th>
<th class="p-2">Domaine</th>
<th class="p-2">Env</th>
<th class="p-2">OS</th>
<th class="p-2">Licence</th>
<th class="p-2">Tier</th>
<th class="p-2">Jour prevu</th>
<th class="p-2">Heure</th>
<th class="p-2">Operateur</th>
{% if c.status == 'draft' %}
<th class="p-2">SSH</th>
<th class="p-2">Satellite</th>
<th class="p-2">Rollback</th>
<th class="p-2">Sat</th>
<th class="p-2">Disque</th>
<th class="p-2">Prereq</th>
{% endif %}
<th class="p-2">Statut</th>
<th class="p-2">Actions</th>
@ -130,115 +111,92 @@
<tbody>
{% for s in sessions %}
<tr id="row-{{ s.id }}" class="{% if s.status == 'excluded' %}opacity-40{% elif s.status == 'patched' %}opacity-60{% elif s.status == 'failed' %}bg-red-900/10{% 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 font-mono text-cyber-accent">{{ s.hostname }}</td>
<td class="p-2 text-center">{{ s.domaine or '-' }}</td>
<td class="p-2 text-center"><span class="badge {% if s.environnement == 'Production' %}badge-green{% else %}badge-yellow{% endif %}">{{ (s.environnement or '-')[:6] }}</span></td>
<td class="p-2 text-center text-xs">{{ s.os_family 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{% else %}badge-yellow{% 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{% else %}badge-blue{% endif %}">{{ s.tier }}</span></td>
<td class="p-2 text-center">{% if s.date_prevue %}{% set jours = {0:'Lun',1:'Mar',2:'Mer',3:'Jeu',4:'Ven',5:'Sam',6:'Dim'} %}{{ jours[s.date_prevue.weekday()] }} {{ s.date_prevue.strftime('%d/%m') }}{% else %}-{% endif %}</td>
<td class="p-2 text-center text-gray-400">{{ s.heure_prevue or s.pref_patch_heure or '-' }}</td>
<td class="p-2 text-center">
{% if s.intervenant_name %}
<span class="text-cyber-accent">{{ s.intervenant_name }}</span>
{% if s.forced_assignment %}<span class="text-cyber-yellow text-[9px] ml-0.5" title="Assignation forcee">&#128274;</span>{% endif %}
{% else %}<span class="text-gray-600"></span>{% endif %}
</td>
{% if c.status == 'draft' %}
<td class="p-2 text-center">
{% if s.prereq_ssh == 'ok' %}<span class="text-cyber-green text-xs">OK</span>
{% elif s.prereq_ssh == 'ko' %}<span class="text-cyber-red text-xs">KO</span>
{% else %}<span class="text-gray-600 text-xs">-</span>{% endif %}
</td>
<td class="p-2 text-center">
{% if s.prereq_satellite == 'ok' %}<span class="text-cyber-green text-xs">OK</span>
{% elif s.prereq_satellite == 'ko' %}<span class="text-cyber-red text-xs">KO</span>
{% elif s.prereq_satellite == 'na' %}<span class="text-gray-500 text-xs">N/A</span>
{% else %}<span class="text-gray-600 text-xs">-</span>{% endif %}
</td>
<td class="p-2 text-center">
{% if s.rollback_method %}<span class="badge {% if s.rollback_method == 'force' %}badge-red{% else %}badge-green{% endif %}">{{ s.rollback_method }}</span>
{% else %}<span class="text-gray-600 text-xs">-</span>{% endif %}
</td>
<td class="p-2 text-center text-xs">
{% if s.prereq_disk_ok is true %}<span class="text-cyber-green" title="/ {{ s.prereq_disk_root_mb or '?' }}Mo | /var {{ s.prereq_disk_var_mb or '?' }}Mo">OK</span>
{% elif s.prereq_disk_ok is false %}<span class="text-cyber-red" title="/ {{ s.prereq_disk_root_mb or '?' }}Mo | /var {{ s.prereq_disk_var_mb or '?' }}Mo">KO</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 text-xs">-</span>{% endif %}
</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 %}-{% 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>{% else %}-{% 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 %}-{% endif %}</td>
{% endif %}
<td class="p-2 text-center">
<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-[10px] text-gray-500 mt-0.5" title="{{ s.exclusion_detail or '' }}">
{% if s.exclusion_reason == 'eol' %}EOL
{% elif s.exclusion_reason == 'creneau_inadequat' %}Creneau/Prereq
{% elif s.exclusion_reason == 'intervention_non_secops' %}Non-SecOps
{% elif s.exclusion_reason == 'report_cycle' %}Reporte
{% elif s.exclusion_reason == 'non_patchable' %}Non patchable
{% else %}{{ s.exclusion_reason }}{% endif %}
<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.excluded_by %}<span class="text-gray-600">({{ s.excluded_by }})</span>{% endif %}
</div>
{% if s.exclusion_detail %}<div class="text-[9px] text-gray-600 italic">{{ s.exclusion_detail[:60] }}</div>{% endif %}
{% endif %}
</td>
<td class="p-2 text-center text-xs">
{% if s.status == 'excluded' %}
<form method="POST" action="/campaigns/session/{{ s.id }}/restore" style="display:inline">
<button class="btn-sm bg-green-900/30 text-cyber-green">Restaurer</button>
</form>
{% elif s.status == 'pending' and c.status == 'draft' %}
<div class="flex gap-1 justify-center">
<form method="POST" action="/campaigns/session/{{ s.id }}/check-prereq" style="display:inline">
<button class="btn-sm bg-cyber-border text-cyber-accent" title="Re-verifier ce serveur">Check</button>
</form>
<button @click="prereqing = prereqing === {{ s.id }} ? null : {{ s.id }}" class="btn-sm bg-cyber-border text-cyber-accent">Edit</button>
<button @click="excluding = excluding === {{ s.id }} ? null : {{ s.id }}" class="btn-sm bg-cyber-border text-gray-400">Exclure</button>
</div>
<td class="p-2 text-center">
{% if s.status == 'excluded' and is_coordinator %}
<form method="POST" action="/campaigns/session/{{ s.id }}/restore" style="display:inline"><button class="btn-sm bg-green-900/30 text-cyber-green">Restaurer</button></form>
{% elif s.status == 'pending' %}
{% if c.status == 'planned' %}
{# Operateur: prendre/liberer #}
{% if not s.intervenant_id %}
<form method="POST" action="/campaigns/session/{{ s.id }}/take" style="display:inline"><button class="btn-sm bg-cyber-accent text-black">Prendre</button></form>
{% elif s.intervenant_id == user.uid and not s.forced_assignment %}
<form method="POST" action="/campaigns/session/{{ s.id }}/release" style="display:inline"><button class="btn-sm bg-cyber-border text-gray-400">Liberer</button></form>
{% endif %}
{# Coordinateur: assigner + planifier #}
{% if is_coordinator %}
<button @click="action = 'assign'; target = {{ s.id }}" class="btn-sm bg-cyber-border text-cyber-accent">Assigner</button>
<button @click="action = 'schedule'; target = {{ s.id }}" class="btn-sm bg-cyber-border text-gray-400">Planifier</button>
{% endif %}
{% elif c.status == 'draft' and is_coordinator %}
<div class="flex gap-1 justify-center">
<form method="POST" action="/campaigns/session/{{ s.id }}/check-prereq" style="display:inline"><button class="btn-sm bg-cyber-border text-cyber-accent">Check</button></form>
<button @click="action = 'exclude'; target = {{ s.id }}" class="btn-sm bg-cyber-border text-gray-400">Exclure</button>
</div>
{% endif %}
{% endif %}
</td>
</tr>
<!-- Formulaire prereq inline -->
{% if s.status == 'pending' and c.status == 'draft' %}
<tr x-show="prereqing === {{ s.id }}">
<td colspan="12" class="p-2 bg-cyber-bg">
<form method="POST" action="/campaigns/session/{{ s.id }}/prereq" class="flex gap-2 items-center flex-wrap">
<span class="text-xs text-gray-500">SSH:</span>
<select name="prereq_ssh" class="text-xs py-1 px-2">
<option value="ok" {% if s.prereq_ssh == 'ok' %}selected{% endif %}>OK</option>
<option value="ko" {% if s.prereq_ssh == 'ko' %}selected{% endif %}>KO</option>
<option value="pending" {% if s.prereq_ssh == 'pending' %}selected{% endif %}>Pending</option>
</select>
<span class="text-xs text-gray-500">Satellite:</span>
<select name="prereq_satellite" class="text-xs py-1 px-2">
<option value="ok" {% if s.prereq_satellite == 'ok' %}selected{% endif %}>OK</option>
<option value="ko" {% if s.prereq_satellite == 'ko' %}selected{% endif %}>KO</option>
<option value="na" {% if s.prereq_satellite == 'na' %}selected{% endif %}>N/A</option>
<option value="pending" {% if s.prereq_satellite == 'pending' %}selected{% endif %}>Pending</option>
</select>
<span class="text-xs text-gray-500">Rollback:</span>
<select name="rollback_method" class="text-xs py-1 px-2">
<option value="">-</option>
<option value="snapshot" {% if s.rollback_method == 'snapshot' %}selected{% endif %}>Snapshot vSphere</option>
<option value="commvault" {% if s.rollback_method == 'commvault' %}selected{% endif %}>Commvault</option>
<option value="commcell" {% if s.rollback_method == 'commcell' %}selected{% endif %}>CommCell</option>
<option value="force" {% if s.rollback_method == 'force' %}selected{% endif %}>Force (justif.)</option>
<option value="na" {% if s.rollback_method == 'na' %}selected{% endif %}>N/A (physique)</option>
</select>
<input type="text" name="rollback_justif" value="{{ s.rollback_justif or '' }}" placeholder="Justification si force" class="text-xs py-1 px-2 flex-1">
<button type="submit" class="btn-sm bg-cyber-accent text-black">Valider</button>
<button type="button" @click="prereqing = null" class="btn-sm bg-cyber-border text-gray-400">X</button>
{# Formulaires inline #}
{% if s.status == 'pending' %}
<tr x-show="target === {{ s.id }} && action === 'exclude'" class="bg-cyber-bg">
<td colspan="12" class="p-2">
<form method="POST" action="/campaigns/session/{{ s.id }}/exclude" class="flex gap-2 items-center flex-wrap">
<select name="reason" required class="text-xs py-1 px-2">{% for code, label in exclusion_reasons %}<option value="{{ code }}">{{ label }}</option>{% endfor %}</select>
<input type="text" name="detail" placeholder="Justification" class="text-xs py-1 px-2 flex-1">
<button type="submit" class="btn-sm bg-red-900/30 text-cyber-red">Confirmer</button>
<button type="button" @click="target = null" class="btn-sm bg-cyber-border text-gray-400">X</button>
</form>
</td>
</tr>
<!-- Formulaire exclusion inline -->
<tr x-show="excluding === {{ s.id }}">
<td colspan="12" class="p-2 bg-cyber-bg">
<form method="POST" action="/campaigns/session/{{ s.id }}/exclude" class="flex gap-2 items-center flex-wrap">
<span class="text-xs text-gray-500">Motif:</span>
<select name="reason" required class="text-xs py-1 px-2">
{% for code, label in exclusion_reasons %}
<option value="{{ code }}">{{ label }}</option>
{% endfor %}
<tr x-show="target === {{ s.id }} && action === 'assign'" class="bg-cyber-bg">
<td colspan="12" class="p-2">
<form method="POST" action="/campaigns/session/{{ s.id }}/assign" class="flex gap-2 items-center">
<select name="operator_id" class="text-xs py-1 px-2">
<option value="">— Desassigner —</option>
{% for u in intervenants %}<option value="{{ u.id }}" {% if s.intervenant_id == u.id %}selected{% endif %}>{{ u.display_name }}</option>{% endfor %}
</select>
<input type="text" name="detail" placeholder="Detail / justification" class="text-xs py-1 px-2 flex-1">
<button type="submit" class="btn-sm bg-red-900/30 text-cyber-red">Confirmer</button>
<button type="button" @click="excluding = null" class="btn-sm bg-cyber-border text-gray-400">X</button>
<label class="flex items-center gap-1 text-xs text-gray-400"><input type="checkbox" name="forced" {% if s.forced_assignment %}checked{% endif %}> Forcer</label>
<button type="submit" class="btn-sm bg-cyber-accent text-black">OK</button>
<button type="button" @click="target = null" class="btn-sm bg-cyber-border text-gray-400">X</button>
</form>
</td>
</tr>
<tr x-show="target === {{ s.id }} && action === 'schedule'" class="bg-cyber-bg">
<td colspan="12" class="p-2">
<form method="POST" action="/campaigns/session/{{ s.id }}/schedule" class="flex gap-2 items-center">
<input type="date" name="date_prevue" value="{{ s.date_prevue.strftime('%Y-%m-%d') if s.date_prevue else '' }}" class="text-xs py-1 px-2">
<input type="text" name="heure_prevue" value="{{ s.heure_prevue or '' }}" placeholder="ex: 9h00, 14h00" class="text-xs py-1 px-2 w-24">
<button type="submit" class="btn-sm bg-cyber-accent text-black">OK</button>
<button type="button" @click="target = null" class="btn-sm bg-cyber-border text-gray-400">X</button>
</form>
</td>
</tr>
@ -247,4 +205,38 @@
</tbody>
</table>
</div>
<!-- Limites operateurs (coordinateur, planned) -->
{% if is_coordinator and c.status in ('planned', 'pending_validation') %}
<div class="card p-4 mt-4">
<h3 class="text-sm font-bold text-cyber-accent mb-3">Limites operateurs pour cette campagne</h3>
{% if op_limits %}
<div class="grid grid-cols-3 gap-2 text-xs mb-3">
{% for ol in op_limits %}
<div class="flex justify-between items-center bg-cyber-bg p-2 rounded">
<span>{{ ol.display_name }}</span>
<span class="badge badge-yellow">max {{ ol.max_servers }}{% if ol.note %} — {{ ol.note }}{% endif %}</span>
</div>
{% endfor %}
</div>
{% endif %}
<form method="POST" action="/campaigns/{{ c.id }}/operator-limit" class="flex gap-2 items-end">
<div>
<label class="text-xs text-gray-500">Operateur</label>
<select name="operator_id" class="text-xs py-1 px-2">
{% for u in intervenants %}<option value="{{ u.id }}">{{ u.display_name }}</option>{% endfor %}
</select>
</div>
<div>
<label class="text-xs text-gray-500">Max serveurs</label>
<input type="number" name="max_servers" min="0" value="5" class="text-xs py-1 px-2 w-16">
</div>
<div class="flex-1">
<label class="text-xs text-gray-500">Raison</label>
<input type="text" name="note" placeholder="ex: autre mission en parallele" class="text-xs py-1 px-2 w-full">
</div>
<button type="submit" class="btn-primary px-3 py-1 text-sm">Definir</button>
</form>
</div>
{% endif %}
{% endblock %}

View File

@ -0,0 +1,128 @@
<div>
<div class="flex justify-between items-center mb-4">
<h3 class="text-lg font-bold text-cyber-accent">{{ e.hostname }}</h3>
<button onclick="document.getElementById('audit-detail').style.display='none'" class="text-gray-500 hover:text-white text-xl">&times;</button>
</div>
<div class="grid grid-cols-2 gap-4">
<!-- Systeme -->
<div>
<h4 class="text-xs text-cyber-accent font-bold uppercase mb-2 border-b border-cyber-border pb-1">Systeme</h4>
<div class="space-y-1 text-xs">
<div><span class="text-gray-500">Connexion:</span> <span class="font-mono">{{ e.connection_method or '-' }}</span></div>
<div><span class="text-gray-500">FQDN resolu:</span> <span class="font-mono text-cyber-green">{{ e.resolved_fqdn or '-' }}</span></div>
<div><span class="text-gray-500">OS:</span> {{ e.os_release or '-' }}</div>
<div><span class="text-gray-500">Kernel:</span> <span class="font-mono">{{ e.kernel or '-' }}</span></div>
<div><span class="text-gray-500">Uptime:</span> {{ e.uptime or '-' }}</div>
<div><span class="text-gray-500">SELinux:</span> <span class="badge {% if e.selinux == 'Enforcing' %}badge-green{% elif e.selinux == 'Permissive' %}badge-yellow{% else %}badge-red{% endif %}">{{ e.selinux or '-' }}</span></div>
<div><span class="text-gray-500">Audit:</span> {{ e.audit_date.strftime('%d/%m/%Y %H:%M') if e.audit_date else '-' }}</div>
</div>
</div>
<!-- Agents -->
<div>
<h4 class="text-xs text-cyber-accent font-bold uppercase mb-2 border-b border-cyber-border pb-1">Agents & Securite</h4>
<div class="space-y-1 text-xs">
<div><span class="text-gray-500">Qualys:</span> <span class="badge {% if e.qualys_active %}badge-green{% else %}badge-red{% endif %}">{{ 'Actif' if e.qualys_active else 'Inactif' }}</span></div>
<div><span class="text-gray-500">SentinelOne:</span> <span class="badge {% if e.sentinelone_active %}badge-green{% else %}badge-red{% endif %}">{{ 'Actif' if e.sentinelone_active else 'Inactif' }}</span></div>
{% if e.agents %}<div class="mt-1 font-mono text-gray-400" style="white-space:pre-line">{{ e.agents }}</div>{% endif %}
</div>
</div>
</div>
<!-- Espace disque -->
<div class="mt-4">
<h4 class="text-xs text-cyber-accent font-bold uppercase mb-2 border-b border-cyber-border pb-1">Espace disque {% if e.disk_alert %}<span class="badge badge-red ml-2">ALERTE</span>{% endif %}</h4>
{% if e.disk_detail %}<pre class="text-xs text-gray-400 font-mono bg-cyber-bg p-2 rounded overflow-x-auto">{{ e.disk_detail }}</pre>{% endif %}
</div>
<!-- Apps installees -->
{% if e.apps_installed %}
<div class="mt-4">
<h4 class="text-xs text-cyber-accent font-bold uppercase mb-2 border-b border-cyber-border pb-1">Applications installees</h4>
<pre class="text-xs text-gray-400 font-mono bg-cyber-bg p-2 rounded overflow-x-auto" style="max-height:150px">{{ e.apps_installed }}</pre>
</div>
{% endif %}
<div class="grid grid-cols-2 gap-4 mt-4">
<!-- Services running -->
{% if e.services_running %}
<div>
<h4 class="text-xs text-cyber-accent font-bold uppercase mb-2 border-b border-cyber-border pb-1">Services running</h4>
<pre class="text-xs text-gray-400 font-mono bg-cyber-bg p-2 rounded overflow-y-auto" style="max-height:200px">{{ e.services_running }}</pre>
</div>
{% endif %}
<!-- Running sans auto-start -->
{% if e.running_not_enabled %}
<div>
<h4 class="text-xs font-bold uppercase mb-2 border-b border-cyber-border pb-1 text-cyber-yellow">Running SANS auto-start</h4>
<pre class="text-xs text-cyber-yellow font-mono bg-cyber-bg p-2 rounded overflow-y-auto" style="max-height:200px">{{ e.running_not_enabled }}</pre>
</div>
{% endif %}
</div>
<div class="grid grid-cols-2 gap-4 mt-4">
<!-- Containers -->
{% if e.containers %}
<div>
<h4 class="text-xs text-cyber-accent font-bold uppercase mb-2 border-b border-cyber-border pb-1">Containers</h4>
<pre class="text-xs text-gray-400 font-mono bg-cyber-bg p-2 rounded">{{ e.containers }}</pre>
</div>
{% endif %}
<!-- Ports -->
{% if e.listening_ports %}
<div>
<h4 class="text-xs text-cyber-accent font-bold uppercase mb-2 border-b border-cyber-border pb-1">Ports en ecoute</h4>
<pre class="text-xs text-gray-400 font-mono bg-cyber-bg p-2 rounded overflow-y-auto" style="max-height:150px">{{ e.listening_ports }}</pre>
</div>
{% endif %}
</div>
<div class="grid grid-cols-2 gap-4 mt-4">
<!-- Scripts -->
{% if e.applis_scripts %}
<div>
<h4 class="text-xs text-cyber-accent font-bold uppercase mb-2 border-b border-cyber-border pb-1">Scripts /applis</h4>
<pre class="text-xs text-gray-400 font-mono bg-cyber-bg p-2 rounded overflow-y-auto" style="max-height:150px">{{ e.applis_scripts }}</pre>
</div>
{% endif %}
<!-- Crontab -->
{% if e.crontab_root and e.crontab_root != 'empty' %}
<div>
<h4 class="text-xs text-cyber-accent font-bold uppercase mb-2 border-b border-cyber-border pb-1">Crontab root</h4>
<pre class="text-xs text-gray-400 font-mono bg-cyber-bg p-2 rounded overflow-y-auto" style="max-height:150px">{{ e.crontab_root }}</pre>
</div>
{% endif %}
</div>
{% if e.network_mounts and e.network_mounts != 'none' %}
<div class="mt-4">
<h4 class="text-xs text-cyber-accent font-bold uppercase mb-2 border-b border-cyber-border pb-1">Montages reseau</h4>
<pre class="text-xs text-gray-400 font-mono bg-cyber-bg p-2 rounded">{{ e.network_mounts }}</pre>
</div>
{% endif %}
{% if e.failed_services %}
<div class="mt-4">
<h4 class="text-xs text-cyber-red font-bold uppercase mb-2 border-b border-red-900 pb-1">Services en echec</h4>
<pre class="text-xs text-cyber-red font-mono bg-red-900/20 p-2 rounded">{{ e.failed_services }}</pre>
</div>
{% endif %}
{% if e.db_detected and e.db_detected != 'done' %}
<div class="mt-4">
<h4 class="text-xs text-cyber-accent font-bold uppercase mb-2 border-b border-cyber-border pb-1">Base de donnees</h4>
<pre class="text-xs text-gray-400 font-mono bg-cyber-bg p-2 rounded">{{ e.db_detected }}</pre>
</div>
{% endif %}
{% if e.cluster_detected and e.cluster_detected != 'no_cluster' %}
<div class="mt-4">
<h4 class="text-xs text-cyber-accent font-bold uppercase mb-2 border-b border-cyber-border pb-1">Cluster</h4>
<pre class="text-xs text-gray-400 font-mono bg-cyber-bg p-2 rounded">{{ e.cluster_detected }}</pre>
</div>
{% endif %}
</div>