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:
parent
7adb9e553c
commit
ba8a969366
@ -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
84
app/routers/audit.py
Normal 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,
|
||||
})
|
||||
@ -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)
|
||||
|
||||
@ -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
89
app/templates/audit.html
Normal 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 %}
|
||||
@ -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>
|
||||
|
||||
@ -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">🔒</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 %}
|
||||
|
||||
128
app/templates/partials/audit_detail.html
Normal file
128
app/templates/partials/audit_detail.html
Normal 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">×</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>
|
||||
Loading…
Reference in New Issue
Block a user