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.responses import RedirectResponse
from fastapi.staticfiles import StaticFiles from fastapi.staticfiles import StaticFiles
from .config import APP_NAME, APP_VERSION 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 = FastAPI(title=APP_NAME, version=APP_VERSION)
app.mount("/static", StaticFiles(directory="app/static"), name="static") 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(campaigns.router)
app.include_router(planning.router) app.include_router(planning.router)
app.include_router(specifics.router) app.include_router(specifics.router)
app.include_router(audit.router)
@app.get("/") @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 datetime import datetime
from fastapi import APIRouter, Request, Depends, Query, Form from fastapi import APIRouter, Request, Depends, Query, Form
from fastapi.responses import HTMLResponse, RedirectResponse from fastapi.responses import HTMLResponse, RedirectResponse
@ -10,9 +10,12 @@ from ..services.campaign_service import (
create_campaign_from_planning, get_servers_for_planning, create_campaign_from_planning, get_servers_for_planning,
update_campaign_status, exclude_session, restore_session, update_campaign_status, exclude_session, restore_session,
validate_prereq, get_prereq_stats, can_plan_campaign, 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.prereq_service import check_prereqs_campaign, check_single_prereq
from ..services.secrets_service import get_secret
from ..config import APP_NAME from ..config import APP_NAME
router = APIRouter() 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) @router.get("/campaigns", response_class=HTMLResponse)
async def campaigns_list(request: Request, db=Depends(get_db), async def campaigns_list(request: Request, db=Depends(get_db),
year: int = Query(None), status: str = Query(None)): 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 year = datetime.now().year
campaigns = list_campaigns(db, year=year, status=status) campaigns = list_campaigns(db, year=year, status=status)
# Semaines planifiees pour cette annee (pour le formulaire de creation)
now = datetime.now() now = datetime.now()
current_week = now.isocalendar()[1] current_week = now.isocalendar()[1]
planned_weeks = db.execute(text(""" 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) @router.get("/campaigns/preview", response_class=HTMLResponse)
async def campaign_preview(request: Request, db=Depends(get_db), async def campaign_preview(request: Request, db=Depends(get_db),
year: int = Query(...), week: int = Query(...)): year: int = Query(...), week: int = Query(...)):
"""HTMX: preview des serveurs pour une semaine du planning"""
user = get_current_user(request) user = get_current_user(request)
if not user: if not user:
return HTMLResponse("<p>Non autorise</p>") 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) user = get_current_user(request)
if not user: if not user:
return RedirectResponse(url="/login") return RedirectResponse(url="/login")
form = await request.form() form = await request.form()
year = int(form.get("year", datetime.now().year)) year = int(form.get("year", datetime.now().year))
week = int(form.get("week_number", 0)) week = int(form.get("week_number", 0))
label = form.get("label", f"Patch S{week:02d} {year}") label = form.get("label", f"Patch S{week:02d} {year}")
# Serveurs exclus (checkboxes non cochees)
excluded = [] excluded = []
for key in form.keys(): for key in form.keys():
if key.startswith("exclude_"): if key.startswith("exclude_"):
sid = int(key.replace("exclude_", "")) excluded.append(int(key.replace("exclude_", "")))
excluded.append(sid)
cid = create_campaign_from_planning(db, year, week, label, user.get("uid"), excluded) cid = create_campaign_from_planning(db, year, week, label, user.get("uid"), excluded)
if not cid: if not cid:
return RedirectResponse(url=f"/campaigns?year={year}&msg=no_servers", status_code=303) return RedirectResponse(url=f"/campaigns?year={year}&msg=no_servers", status_code=303)
return RedirectResponse(url=f"/campaigns/{cid}", 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) user = get_current_user(request)
if not user: if not user:
return RedirectResponse(url="/login") return RedirectResponse(url="/login")
campaign = get_campaign(db, campaign_id) campaign = get_campaign(db, campaign_id)
if not campaign: if not campaign:
return RedirectResponse(url="/campaigns") return RedirectResponse(url="/campaigns")
sessions = get_campaign_sessions(db, campaign_id) sessions = get_campaign_sessions(db, campaign_id)
stats = get_campaign_stats(db, campaign_id) stats = get_campaign_stats(db, campaign_id)
prereq = get_prereq_stats(db, campaign_id) prereq = get_prereq_stats(db, campaign_id)
can_plan = can_plan_campaign(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", { return templates.TemplateResponse("campaign_detail.html", {
"request": request, "user": user, "app_name": APP_NAME, "request": request, "user": user, "app_name": APP_NAME,
"c": campaign, "sessions": sessions, "stats": stats, "c": campaign, "sessions": sessions, "stats": stats,
"prereq": prereq, "can_plan": can_plan, "prereq": prereq, "can_plan": can_plan,
"exclusion_reasons": EXCLUSION_REASONS, "exclusion_reasons": EXCLUSION_REASONS,
"is_coordinator": is_coordinator, "intervenants": intervenants,
"op_limits": op_limits,
"msg": request.query_params.get("msg"), "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) user = get_current_user(request)
if not user: if not user:
return RedirectResponse(url="/login") return RedirectResponse(url="/login")
# Bloquer planned si prereqs non valides
if new_status == "planned" and not can_plan_campaign(db, campaign_id): 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) return RedirectResponse(url=f"/campaigns/{campaign_id}?msg=prereq_needed", status_code=303)
update_campaign_status(db, campaign_id, new_status) 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")) rollback_method or None, rollback_justif, user.get("sub"))
row = db.execute(text("SELECT campaign_id FROM patch_sessions WHERE id = :id"), row = db.execute(text("SELECT campaign_id FROM patch_sessions WHERE id = :id"),
{"id": session_id}).fetchone() {"id": session_id}).fetchone()
cid = row.campaign_id if row else 0 return RedirectResponse(url=f"/campaigns/{row.campaign_id}?msg=prereq_saved#row-{session_id}", status_code=303)
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)
@router.post("/campaigns/{campaign_id}/check-prereqs") @router.post("/campaigns/{campaign_id}/check-prereqs")
async def campaign_check_prereqs(request: Request, campaign_id: int, db=Depends(get_db)): 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) user = get_current_user(request)
if not user: if not user:
return RedirectResponse(url="/login") return RedirectResponse(url="/login")
checked, auto_excluded = check_prereqs_campaign(db, campaign_id) checked, auto_excluded = check_prereqs_campaign(db, campaign_id)
return RedirectResponse( return RedirectResponse(url=f"/campaigns/{campaign_id}?msg=checked_{checked}_{auto_excluded}", status_code=303)
url=f"/campaigns/{campaign_id}?msg=checked_{checked}_{auto_excluded}",
status_code=303
)
@router.post("/campaigns/session/{session_id}/check-prereq") @router.post("/campaigns/session/{session_id}/check-prereq")
async def session_check_prereq(request: Request, session_id: int, db=Depends(get_db)): 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) user = get_current_user(request)
if not user: if not user:
return RedirectResponse(url="/login") return RedirectResponse(url="/login")
check_single_prereq(db, session_id) check_single_prereq(db, session_id)
row = db.execute(text("SELECT campaign_id FROM patch_sessions WHERE id = :id"), row = db.execute(text("SELECT campaign_id FROM patch_sessions WHERE id = :id"),
{"id": session_id}).fetchone() {"id": session_id}).fetchone()
cid = row.campaign_id if row else 0 return RedirectResponse(url=f"/campaigns/{row.campaign_id}?msg=prereq_checked#row-{session_id}", status_code=303)
return RedirectResponse(url=f"/campaigns/{cid}?msg=prereq_checked#row-{session_id}", status_code=303)
@router.post("/campaigns/session/{session_id}/exclude") @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: if not user:
return RedirectResponse(url="/login") return RedirectResponse(url="/login")
exclude_session(db, session_id, reason, detail, user.get("sub")) 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"), row = db.execute(text("SELECT campaign_id FROM patch_sessions WHERE id = :id"),
{"id": session_id}).fetchone() {"id": session_id}).fetchone()
cid = row.campaign_id if row else 0 return RedirectResponse(url=f"/campaigns/{row.campaign_id}?msg=excluded#row-{session_id}", status_code=303)
return RedirectResponse(url=f"/campaigns/{cid}?msg=excluded#row-{session_id}", status_code=303)
@router.post("/campaigns/session/{session_id}/restore") @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) restore_session(db, session_id)
row = db.execute(text("SELECT campaign_id FROM patch_sessions WHERE id = :id"), row = db.execute(text("SELECT campaign_id FROM patch_sessions WHERE id = :id"),
{"id": session_id}).fetchone() {"id": session_id}).fetchone()
cid = row.campaign_id if row else 0 return RedirectResponse(url=f"/campaigns/{row.campaign_id}?msg=restored#row-{session_id}", status_code=303)
return RedirectResponse(url=f"/campaigns/{cid}?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""" """Service campagnes — logique metier patching"""
from datetime import datetime from datetime import datetime, date, timedelta
from sqlalchemy import text from sqlalchemy import text
@ -36,6 +36,7 @@ def get_campaign_sessions(db, campaign_id):
return db.execute(text(""" return db.execute(text("""
SELECT ps.*, s.hostname, s.fqdn, s.os_family, s.os_version, s.tier, 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.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, d.name as domaine, e.name as environnement,
u.display_name as intervenant_name u.display_name as intervenant_name
FROM patch_sessions ps 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 LEFT JOIN users u ON ps.intervenant_id = u.id
WHERE ps.campaign_id = :cid WHERE ps.campaign_id = :cid
ORDER BY CASE ps.status ORDER BY CASE ps.status
WHEN 'in_progress' THEN 1 WHEN 'in_progress' THEN 1 WHEN 'pending' THEN 2 WHEN 'prereq_ok' THEN 3
WHEN 'pending' THEN 2 WHEN 'patched' THEN 4 WHEN 'failed' THEN 5 WHEN 'reported' THEN 6
WHEN 'prereq_ok' THEN 3 WHEN 'excluded' THEN 7 WHEN 'cancelled' THEN 8 ELSE 9 END,
WHEN 'patched' THEN 4 ps.date_prevue, s.hostname
WHEN 'failed' THEN 5
WHEN 'reported' THEN 6
WHEN 'excluded' THEN 7
WHEN 'cancelled' THEN 8
ELSE 9 END, s.hostname
"""), {"cid": campaign_id}).fetchall() """), {"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 = 'skipped') as skipped,
COUNT(*) FILTER (WHERE status = 'excluded') as excluded, COUNT(*) FILTER (WHERE status = 'excluded') as excluded,
COUNT(*) FILTER (WHERE status = 'reported') as reported, 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 FROM patch_sessions WHERE campaign_id = :cid
"""), {"cid": campaign_id}).fetchone() """), {"cid": campaign_id}).fetchone()
def get_planning_for_week(db, year, week_number): def get_planning_for_week(db, year, week_number):
"""Retourne les entrees planning pour une semaine donnee"""
return db.execute(text(""" return db.execute(text("""
SELECT pp.*, d.name as domain_name SELECT pp.*, d.name as domain_name
FROM patch_planning pp FROM patch_planning pp
@ -85,91 +82,105 @@ def get_planning_for_week(db, year, week_number):
"""), {"y": year, "wn": week_number}).fetchall() """), {"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): 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) planning = get_planning_for_week(db, year, week_number)
if not planning: if not planning:
return [], [] return [], []
# Construire les filtres domaine/env depuis le planning
domain_envs = [] domain_envs = []
for p in planning: for p in planning:
if p.domain_code == 'DMZ': if p.domain_code == 'DMZ':
continue # DMZ traite separement continue
if p.env_scope == 'prod': if p.env_scope == 'prod':
domain_envs.append(("d.code = :dc_{0} AND e.name = 'Production'".format(len(domain_envs)), p.domain_code)) domain_envs.append(("d.code = :dc_{0} AND e.name = 'Production'".format(len(domain_envs)), p.domain_code))
elif p.env_scope == 'hprod': elif p.env_scope == 'hprod':
domain_envs.append(("d.code = :dc_{0} AND e.name != 'Production'".format(len(domain_envs)), p.domain_code)) domain_envs.append(("d.code = :dc_{0} AND e.name != 'Production'".format(len(domain_envs)), p.domain_code))
elif p.env_scope == 'prod_pilot': elif p.env_scope == 'prod_pilot':
domain_envs.append(("d.code = :dc_{0}".format(len(domain_envs)), p.domain_code)) 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)) domain_envs.append(("d.code = :dc_{0}".format(len(domain_envs)), p.domain_code))
if not domain_envs: if not domain_envs:
return [], planning return [], planning
# Construire la clause OR
or_clauses = [] or_clauses = []
params = {} params = {}
for i, (clause, dc) in enumerate(domain_envs): for i, (clause, dc) in enumerate(domain_envs):
or_clauses.append(clause) or_clauses.append(clause)
params[f"dc_{i}"] = dc params[f"dc_{i}"] = dc
# Toujours inclure DMZ
or_clauses.append("d.code = 'DMZ'") or_clauses.append("d.code = 'DMZ'")
where = f""" where = f"""
s.etat = 'en_production' s.etat = 'en_production' AND s.patch_os_owner = 'secops'
AND s.patch_os_owner = 'secops' AND s.licence_support IN ('active', 'els') AND s.os_family = 'linux'
AND s.licence_support IN ('active', 'els')
AND ({' OR '.join(or_clauses)}) AND ({' OR '.join(or_clauses)})
""" """
servers = db.execute(text(f""" servers = db.execute(text(f"""
SELECT s.id, s.hostname, s.fqdn, s.os_family, s.os_version, s.tier, 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.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 d.name as domaine, d.code as domain_code, e.name as environnement
FROM servers s FROM servers s
LEFT JOIN domain_environments de ON s.domain_env_id = de.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 domains d ON de.domain_id = d.id
LEFT JOIN environments e ON de.environment_id = e.id LEFT JOIN environments e ON de.environment_id = e.id
WHERE {where} WHERE {where}
ORDER BY d.name, e.name, s.hostname ORDER BY e.name, d.name, s.hostname
"""), params).fetchall() """), params).fetchall()
return servers, planning return servers, planning
def create_campaign_from_planning(db, year, week_number, label, user_id, excluded_ids=None): 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) servers, planning = get_servers_for_planning(db, year, week_number)
if not servers: if not servers:
return None return None
wc = f"S{week_number:02d}" 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 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(""" row = db.execute(text("""
INSERT INTO campaigns (week_code, year, label, status, date_start, date_end, created_by) INSERT INTO campaigns (week_code, year, label, status, date_start, date_end, created_by)
VALUES (:wc, :y, :label, 'draft', :ds, :de, :uid) VALUES (:wc, :y, :label, 'draft', :ds, :de, :uid)
RETURNING id 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 cid = row.id
excluded = set(excluded_ids or []) excluded = set(excluded_ids or [])
for s in servers: for s in servers:
status = 'excluded' if s.id in excluded else 'pending' status = 'excluded' if s.id in excluded else 'pending'
db.execute(text(""" # Date par defaut : hors-prod = lun/mar, prod = mer/jeu
INSERT INTO patch_sessions (campaign_id, server_id, status) is_prod = (s.environnement == 'Production')
VALUES (:cid, :sid, :st) if s.pref_patch_jour and s.pref_patch_jour != 'indifferent':
ON CONFLICT (campaign_id, server_id) DO NOTHING jour_map = {"lundi": lun, "mardi": mar, "mercredi": mer, "jeudi": jeu}
"""), {"cid": cid, "sid": s.id, "st": status}) 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( count = db.execute(text(
"SELECT COUNT(*) FROM patch_sessions WHERE campaign_id = :cid AND status != 'excluded'" "SELECT COUNT(*) FROM patch_sessions WHERE campaign_id = :cid AND status != 'excluded'"
), {"cid": cid}).scalar() ), {"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): def exclude_session(db, session_id, reason, detail, username):
"""Exclut un serveur d'une campagne avec motif"""
db.execute(text(""" db.execute(text("""
UPDATE patch_sessions SET UPDATE patch_sessions SET
status = 'excluded', exclusion_reason = :reason, status = 'excluded', exclusion_reason = :reason,
exclusion_detail = :detail, excluded_by = :by, exclusion_detail = :detail, excluded_by = :by, excluded_at = now()
excluded_at = now()
WHERE id = :id WHERE id = :id
"""), {"id": session_id, "reason": reason, "detail": detail, "by": username}) """), {"id": session_id, "reason": reason, "detail": detail, "by": username})
# Recalculer total _recalc_total(db, session_id)
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})
db.commit() db.commit()
def restore_session(db, session_id): def restore_session(db, session_id):
"""Restaure un serveur exclu"""
db.execute(text(""" db.execute(text("""
UPDATE patch_sessions SET UPDATE patch_sessions SET
status = 'pending', exclusion_reason = NULL, status = 'pending', exclusion_reason = NULL,
exclusion_detail = NULL, excluded_by = NULL, excluded_at = NULL exclusion_detail = NULL, excluded_by = NULL, excluded_at = NULL
WHERE id = :id WHERE id = :id
"""), {"id": session_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"), row = db.execute(text("SELECT campaign_id FROM patch_sessions WHERE id = :id"),
{"id": session_id}).fetchone() {"id": session_id}).fetchone()
if row: if row:
@ -217,11 +222,81 @@ def restore_session(db, session_id):
), {"cid": row.campaign_id}).scalar() ), {"cid": row.campaign_id}).scalar()
db.execute(text("UPDATE campaigns SET total_servers = :c WHERE id = :cid"), db.execute(text("UPDATE campaigns SET total_servers = :c WHERE id = :cid"),
{"c": count, "cid": row.campaign_id}) {"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() db.commit()
def validate_prereq(db, session_id, ssh, satellite, rollback, rollback_justif, username): def validate_prereq(db, session_id, ssh, satellite, rollback, rollback_justif, username):
"""Valide les prereqs d'un serveur dans une campagne"""
db.execute(text(""" db.execute(text("""
UPDATE patch_sessions SET UPDATE patch_sessions SET
prereq_ssh = :ssh, prereq_satellite = :sat, prereq_ssh = :ssh, prereq_satellite = :sat,
@ -234,24 +309,7 @@ def validate_prereq(db, session_id, ssh, satellite, rollback, rollback_justif, u
db.commit() 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): def get_prereq_stats(db, campaign_id):
"""Stats prereqs d'une campagne"""
return db.execute(text(""" return db.execute(text("""
SELECT SELECT
COUNT(*) FILTER (WHERE status = 'pending') as total_pending, 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): 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(""" pending_not_validated = db.execute(text("""
SELECT COUNT(*) FROM patch_sessions SELECT COUNT(*) FROM patch_sessions
WHERE campaign_id = :cid AND status = 'pending' AND prereq_validated = false 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"), db.execute(text("UPDATE campaigns SET status = :s WHERE id = :id"),
{"s": new_status, "id": campaign_id}) {"s": new_status, "id": campaign_id})
db.commit() 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="/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="/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">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="/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> <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> </nav>

View File

@ -8,97 +8,78 @@
<a href="/campaigns" class="text-xs text-gray-500 hover:text-gray-300">← Campagnes</a> <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> <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"> <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> <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 %} {% 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> <span class="text-xs text-gray-600">par {{ c.created_by_name or '-' }}</span>
</div> </div>
</div> </div>
<div class="flex gap-2"> <div class="flex gap-2">
{% if c.status == 'draft' %} {% if is_coordinator %}
{% if can_plan %} {% 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"> <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> <button class="btn-primary px-4 py-2 text-sm">Planifier (post-COMEP)</button></form>
{% else %} <form method="POST" action="/campaigns/{{ c.id }}/status"><input type="hidden" name="new_status" value="draft">
<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-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 %} {% 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 %} {% endif %}
</div> </div>
</div> </div>
{% if msg %} {% 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 %}"> <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 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 %} {% 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> </div>
{% endif %} {% endif %}
<!-- KPIs --> <!-- KPIs -->
<div class="grid grid-cols-7 gap-3 mb-4"> <div class="grid grid-cols-8 gap-2 mb-4">
<div class="card p-3 text-center"> <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="text-2xl font-bold text-cyber-accent">{{ stats.total }}</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="text-xs text-gray-500">Total</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> <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-3 text-center"> <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="text-2xl font-bold text-cyber-green">{{ stats.patched }}</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="text-xs text-gray-500">Patches</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> <div class="card p-2 text-center">
<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">
{% set patchable = stats.total - stats.excluded - stats.cancelled %} {% set patchable = stats.total - stats.excluded - stats.cancelled %}
{% if patchable > 0 %} <div class="text-xl font-bold text-cyber-accent">{% if patchable > 0 %}{{ (stats.patched / patchable * 100)|int }}%{% else %}-{% endif %}</div>
<div class="text-2xl font-bold text-cyber-accent">{{ (stats.patched / patchable * 100)|int }}%</div> <div class="text-[10px] text-gray-500">Progression</div>
{% else %}<div class="text-2xl font-bold text-gray-600">-</div>{% endif %}
<div class="text-xs text-gray-500">Progression</div>
</div> </div>
</div> </div>
<!-- Prereqs stats (draft only) --> <!-- Prereqs (draft) -->
{% if c.status == 'draft' and prereq %} {% if c.status == 'draft' and prereq and is_coordinator %}
<div class="card p-4 mb-4"> <div class="card p-4 mb-4">
<div class="flex justify-between items-center mb-2"> <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> <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">
<form method="POST" action="/campaigns/{{ c.id }}/check-prereqs"> <button class="btn-primary px-3 py-1 text-sm">Verifier prereqs</button>
<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>
</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>
</div> </div>
<div class="grid grid-cols-5 gap-3 text-sm"> <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">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">SSH</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">Satellite</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">Rollback</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">Disque</span><span class="text-cyber-green">{{ prereq.disk_ok }}</span></div>
</div> </div>
{% if prereq.total_pending > 0 %} {% if prereq.total_pending > 0 %}
<div class="w-full h-2 bg-gray-800 rounded-full overflow-hidden mt-2"> <div class="w-full h-2 bg-gray-800 rounded-full overflow-hidden mt-2">
@ -109,20 +90,20 @@
{% endif %} {% endif %}
<!-- Table serveurs --> <!-- Table serveurs -->
<div x-data="{ excluding: null, prereqing: null }" class="card overflow-x-auto"> <div x-data="{ action: null, target: null }" class="card overflow-x-auto">
<table class="w-full table-cyber"> <table class="w-full table-cyber text-xs">
<thead><tr> <thead><tr>
<th class="text-left p-2">Hostname</th> <th class="text-left p-2">Hostname</th>
<th class="p-2">Domaine</th> <th class="p-2">Domaine</th>
<th class="p-2">Env</th> <th class="p-2">Env</th>
<th class="p-2">OS</th> <th class="p-2">Tier</th>
<th class="p-2">Licence</th> <th class="p-2">Jour prevu</th>
<th class="p-2">Heure</th>
<th class="p-2">Operateur</th>
{% if c.status == 'draft' %} {% if c.status == 'draft' %}
<th class="p-2">SSH</th> <th class="p-2">SSH</th>
<th class="p-2">Satellite</th> <th class="p-2">Sat</th>
<th class="p-2">Rollback</th>
<th class="p-2">Disque</th> <th class="p-2">Disque</th>
<th class="p-2">Prereq</th>
{% endif %} {% endif %}
<th class="p-2">Statut</th> <th class="p-2">Statut</th>
<th class="p-2">Actions</th> <th class="p-2">Actions</th>
@ -130,115 +111,92 @@
<tbody> <tbody>
{% for s in sessions %} {% 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 %}"> <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 font-mono text-cyber-accent">{{ s.hostname }}</td>
<td class="p-2 text-center text-xs">{{ s.domaine or '-' }}</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"><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.tier == 'tier0' %}badge-red{% elif s.tier == 'tier1' %}badge-yellow{% else %}badge-blue{% endif %}">{{ s.tier }}</span></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">{% 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' %} {% if c.status == 'draft' %}
<td class="p-2 text-center"> <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>
{% if s.prereq_ssh == 'ok' %}<span class="text-cyber-green text-xs">OK</span> <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>
{% elif s.prereq_ssh == 'ko' %}<span class="text-cyber-red text-xs">KO</span> <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>
{% 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>
{% endif %} {% endif %}
<td class="p-2 text-center"> <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> <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 %} {% if s.exclusion_reason %}
<div class="text-[10px] text-gray-500 mt-0.5" title="{{ s.exclusion_detail or '' }}"> <div class="text-[9px] text-gray-500" title="{{ s.exclusion_detail or '' }}">
{% if s.exclusion_reason == 'eol' %}EOL {% 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 %}
{% 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 %}
{% if s.excluded_by %}<span class="text-gray-600">({{ s.excluded_by }})</span>{% endif %} {% if s.excluded_by %}<span class="text-gray-600">({{ s.excluded_by }})</span>{% endif %}
</div> </div>
{% if s.exclusion_detail %}<div class="text-[9px] text-gray-600 italic">{{ s.exclusion_detail[:60] }}</div>{% endif %}
{% endif %} {% endif %}
</td> </td>
<td class="p-2 text-center text-xs"> <td class="p-2 text-center">
{% if s.status == 'excluded' %} {% if s.status == 'excluded' and is_coordinator %}
<form method="POST" action="/campaigns/session/{{ s.id }}/restore" style="display:inline"> <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>
<button class="btn-sm bg-green-900/30 text-cyber-green">Restaurer</button>
</form> {% elif s.status == 'pending' %}
{% elif s.status == 'pending' and c.status == 'draft' %} {% if c.status == 'planned' %}
<div class="flex gap-1 justify-center"> {# Operateur: prendre/liberer #}
<form method="POST" action="/campaigns/session/{{ s.id }}/check-prereq" style="display:inline"> {% if not s.intervenant_id %}
<button class="btn-sm bg-cyber-border text-cyber-accent" title="Re-verifier ce serveur">Check</button> <form method="POST" action="/campaigns/session/{{ s.id }}/take" style="display:inline"><button class="btn-sm bg-cyber-accent text-black">Prendre</button></form>
</form> {% elif s.intervenant_id == user.uid and not s.forced_assignment %}
<button @click="prereqing = prereqing === {{ s.id }} ? null : {{ s.id }}" class="btn-sm bg-cyber-border text-cyber-accent">Edit</button> <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>
<button @click="excluding = excluding === {{ s.id }} ? null : {{ s.id }}" class="btn-sm bg-cyber-border text-gray-400">Exclure</button> {% endif %}
</div> {# 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 %} {% endif %}
</td> </td>
</tr> </tr>
<!-- Formulaire prereq inline -->
{% if s.status == 'pending' and c.status == 'draft' %} {# Formulaires inline #}
<tr x-show="prereqing === {{ s.id }}"> {% if s.status == 'pending' %}
<td colspan="12" class="p-2 bg-cyber-bg"> <tr x-show="target === {{ s.id }} && action === 'exclude'" class="bg-cyber-bg">
<form method="POST" action="/campaigns/session/{{ s.id }}/prereq" class="flex gap-2 items-center flex-wrap"> <td colspan="12" class="p-2">
<span class="text-xs text-gray-500">SSH:</span> <form method="POST" action="/campaigns/session/{{ s.id }}/exclude" class="flex gap-2 items-center flex-wrap">
<select name="prereq_ssh" class="text-xs py-1 px-2"> <select name="reason" required class="text-xs py-1 px-2">{% for code, label in exclusion_reasons %}<option value="{{ code }}">{{ label }}</option>{% endfor %}</select>
<option value="ok" {% if s.prereq_ssh == 'ok' %}selected{% endif %}>OK</option> <input type="text" name="detail" placeholder="Justification" class="text-xs py-1 px-2 flex-1">
<option value="ko" {% if s.prereq_ssh == 'ko' %}selected{% endif %}>KO</option> <button type="submit" class="btn-sm bg-red-900/30 text-cyber-red">Confirmer</button>
<option value="pending" {% if s.prereq_ssh == 'pending' %}selected{% endif %}>Pending</option> <button type="button" @click="target = null" class="btn-sm bg-cyber-border text-gray-400">X</button>
</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>
</form> </form>
</td> </td>
</tr> </tr>
<!-- Formulaire exclusion inline --> <tr x-show="target === {{ s.id }} && action === 'assign'" class="bg-cyber-bg">
<tr x-show="excluding === {{ s.id }}"> <td colspan="12" class="p-2">
<td colspan="12" class="p-2 bg-cyber-bg"> <form method="POST" action="/campaigns/session/{{ s.id }}/assign" class="flex gap-2 items-center">
<form method="POST" action="/campaigns/session/{{ s.id }}/exclude" class="flex gap-2 items-center flex-wrap"> <select name="operator_id" class="text-xs py-1 px-2">
<span class="text-xs text-gray-500">Motif:</span> <option value="">— Desassigner —</option>
<select name="reason" required class="text-xs py-1 px-2"> {% for u in intervenants %}<option value="{{ u.id }}" {% if s.intervenant_id == u.id %}selected{% endif %}>{{ u.display_name }}</option>{% endfor %}
{% for code, label in exclusion_reasons %}
<option value="{{ code }}">{{ label }}</option>
{% endfor %}
</select> </select>
<input type="text" name="detail" placeholder="Detail / justification" class="text-xs py-1 px-2 flex-1"> <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-red-900/30 text-cyber-red">Confirmer</button> <button type="submit" class="btn-sm bg-cyber-accent text-black">OK</button>
<button type="button" @click="excluding = null" class="btn-sm bg-cyber-border text-gray-400">X</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> </form>
</td> </td>
</tr> </tr>
@ -247,4 +205,38 @@
</tbody> </tbody>
</table> </table>
</div> </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 %} {% 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>