Compare commits

...

10 Commits

Author SHA1 Message Date
Khalid MOUTAOUAKIL
e96d79aae3 QuickWin: prereq/snapshot services, referentiel, logs, correspondance
- Split quickwin services: prereq, snapshot, log services
- Add referentiel router and template
- QuickWin detail: prereq/snapshot terminal divs for production
- Server edit partial updates
- QuickWin correspondance and logs templates
- Base template updates

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-10 18:13:00 +02:00
Khalid MOUTAOUAKIL
13290c1ebb Phase 1 securite: permission checks sur tous les routers
- auth: verification is_active au login (compte desactive = bloque)
- settings: enforcement backend can_edit(settings) + role/section
- servers: can_view/can_edit(servers) sur toutes les routes
- planning: can_view/can_edit(planning) sur toutes les routes
- specifics: can_view/can_edit(specifics) sur toutes les routes
- contacts: rattache au module servers (can_view/can_edit)
- campaigns: can_view/can_edit(campaigns) sur toutes les routes manquantes
- audit/audit_full: can_view/can_edit(audit) sur toutes les routes
- qualys: can_view/can_edit(qualys) sur toutes les routes
- safe_patching: perm checks + authentification sur SSE stream
- quickwin: can_view/can_edit(campaigns|quickwin) sur toutes les routes

97 points d'injection securises, 0 route sans controle

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-08 16:46:05 +02:00
Khalid MOUTAOUAKIL
5cc10c5b6c Module QuickWin complet + filtres serveurs OS/owner
- QuickWin: campagnes patching rapide avec exclusions générales (OS/reboot) et spécifiques (applicatifs)
- Config serveurs: pagination, filtres (search, env, domain, zone, per_page), dry run, bulk edit
- Détail campagne: pagination hprod/prod séparée, filtres (search, status, domain), section prod masquée si hprod non terminé
- Auth: redirection qw_only vers /quickwin, profil lecture seule quickwin
- Serveurs: filtres OS (Linux/Windows) et Owner (secops/ipop/na), exclusion EOL
- Sidebar: lien QuickWin conditionné sur permission campaigns ou quickwin

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-08 16:27:45 +02:00
Khalid MOUTAOUAKIL
c550597a86 Export CSV serveurs avec filtres (domaine, env, tier, état, recherche)
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-07 01:49:23 +02:00
Khalid MOUTAOUAKIL
769e199735 Export CSV patching avec filtres (année, scope, domaine, recherche)
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-07 01:30:17 +02:00
Khalid MOUTAOUAKIL
7f5e5c83eb Export CSV: serveurs sans agent + agents inactifs
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-07 01:24:49 +02:00
Khalid MOUTAOUAKIL
5db47c497f Agents sans Qualys: filtres Alpine.js sur hostname, OS, domaine, env, état
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-07 01:20:59 +02:00
Khalid MOUTAOUAKIL
6a5bdefde5 Sans agent: lister tous les serveurs sans exclusion
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-07 01:13:49 +02:00
Khalid MOUTAOUAKIL
b159960522 Qualys agents: colonne État ajoutée, exclure décommissionnés de la liste sans agent
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-07 01:06:49 +02:00
Khalid MOUTAOUAKIL
c22ad75ee8 État édition: labels avec accents (Éteint, En implémentation, Décommissionné, EOL)
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-07 00:59:54 +02:00
30 changed files with 5370 additions and 34 deletions

View File

@ -6,7 +6,7 @@ from starlette.middleware.base import BaseHTTPMiddleware
from .config import APP_NAME, APP_VERSION
from .dependencies import get_current_user, get_user_perms
from .database import SessionLocal
from .routers import auth, dashboard, servers, settings, users, campaigns, planning, specifics, audit, contacts, qualys, safe_patching, audit_full
from .routers import auth, dashboard, servers, settings, users, campaigns, planning, specifics, audit, contacts, qualys, safe_patching, audit_full, quickwin, referentiel
class PermissionsMiddleware(BaseHTTPMiddleware):
@ -43,6 +43,8 @@ app.include_router(contacts.router)
app.include_router(qualys.router)
app.include_router(safe_patching.router)
app.include_router(audit_full.router)
app.include_router(quickwin.router)
app.include_router(referentiel.router)
@app.get("/")

View File

@ -18,6 +18,9 @@ async def audit_page(request: Request, db=Depends(get_db),
user = get_current_user(request)
if not user:
return RedirectResponse(url="/login")
perms = get_user_perms(db, user)
if not can_view(perms, "audit"):
return RedirectResponse(url="/dashboard")
where = ["1=1"]
params = {}
@ -223,6 +226,9 @@ async def audit_realtime_save(request: Request, db=Depends(get_db)):
user = get_current_user(request)
if not user:
return RedirectResponse(url="/login")
perms = get_user_perms(db, user)
if not can_edit(perms, "audit"):
return RedirectResponse(url="/audit")
results = getattr(request.app.state, "last_audit_results", None)
if not results:
@ -238,6 +244,9 @@ async def audit_export_csv(request: Request, db=Depends(get_db),
user = get_current_user(request)
if not user:
return RedirectResponse(url="/login")
perms = get_user_perms(db, user)
if not can_view(perms, "audit"):
return RedirectResponse(url="/audit")
where = ["1=1"]
params = {}

View File

@ -4,7 +4,7 @@ from fastapi import APIRouter, Request, Depends, UploadFile, File
from fastapi.responses import HTMLResponse, RedirectResponse, StreamingResponse
from fastapi.templating import Jinja2Templates
from sqlalchemy import text
from ..dependencies import get_db, get_current_user, get_user_perms, can_view, base_context
from ..dependencies import get_db, get_current_user, get_user_perms, can_view, can_edit, base_context
from ..services.server_audit_full_service import (
import_json_report, get_latest_audits, get_audit_detail,
get_flow_map, get_flow_map_for_server, get_app_map,
@ -208,6 +208,9 @@ async def audit_full_import(request: Request, db=Depends(get_db),
user = get_current_user(request)
if not user:
return RedirectResponse(url="/login")
perms = get_user_perms(db, user)
if not can_edit(perms, "audit"):
return RedirectResponse(url="/audit-full")
try:
content = await file.read()
@ -229,6 +232,9 @@ async def audit_full_patching(request: Request, db=Depends(get_db)):
user = get_current_user(request)
if not user:
return RedirectResponse(url="/login")
perms = get_user_perms(db, user)
if not can_view(perms, "audit"):
return RedirectResponse(url="/dashboard")
year = int(request.query_params.get("year", "2026"))
search = request.query_params.get("q", "").strip()
@ -408,11 +414,88 @@ async def audit_full_patching(request: Request, db=Depends(get_db)):
return templates.TemplateResponse("audit_full_patching.html", ctx)
@router.get("/audit-full/patching/export-csv")
async def patching_export_csv(request: Request, db=Depends(get_db)):
user = get_current_user(request)
if not user:
return RedirectResponse(url="/login")
perms = get_user_perms(db, user)
if not can_view(perms, "audit"):
return RedirectResponse(url="/audit-full")
import io, csv
year = int(request.query_params.get("year", "2026"))
search = request.query_params.get("q", "").strip()
domain = request.query_params.get("domain", "")
scope = request.query_params.get("scope", "")
yr_count = "patch_count_2026" if year == 2026 else "patch_count_2025"
yr_weeks = "patch_weeks_2026" if year == 2026 else "patch_weeks_2025"
servers = db.execute(text(
f"SELECT DISTINCT ON (saf.hostname) saf.hostname,"
f" saf.{yr_count} as patch_count, saf.{yr_weeks} as patch_weeks,"
f" saf.last_patch_date, saf.last_patch_week, saf.last_patch_year,"
f" saf.patch_status_2026,"
f" d.name as domain, e.name as env, z.name as zone, s.os_family, s.etat"
f" FROM server_audit_full saf"
f" LEFT JOIN servers s ON saf.server_id = s.id"
f" LEFT JOIN domain_environments de ON s.domain_env_id = de.id"
f" LEFT JOIN domains d ON de.domain_id = d.id"
f" LEFT JOIN environments e ON de.environment_id = e.id"
f" LEFT JOIN zones z ON s.zone_id = z.id"
f" WHERE saf.status IN ('ok','partial')"
f" ORDER BY saf.hostname, saf.audit_date DESC"
)).fetchall()
if scope == "secops":
secops = {r.hostname for r in db.execute(text("SELECT hostname FROM servers WHERE patch_os_owner = 'secops'")).fetchall()}
servers = [s for s in servers if s.hostname in secops]
elif scope == "other":
secops = {r.hostname for r in db.execute(text("SELECT hostname FROM servers WHERE patch_os_owner = 'secops'")).fetchall()}
servers = [s for s in servers if s.hostname not in secops]
if domain:
zone_hosts = {r.hostname for r in db.execute(text(
"SELECT s.hostname FROM servers s JOIN zones z ON s.zone_id = z.id WHERE z.name = :n"
), {"n": domain}).fetchall()}
if zone_hosts:
servers = [s for s in servers if s.hostname in zone_hosts]
else:
dom_hosts = {r.hostname for r in db.execute(text(
"SELECT s.hostname FROM servers s JOIN domain_environments de ON s.domain_env_id = de.id"
" JOIN domains d ON de.domain_id = d.id WHERE d.code = :dc"
), {"dc": domain}).fetchall()}
servers = [s for s in servers if s.hostname in dom_hosts]
if search:
servers = [s for s in servers if search.lower() in s.hostname.lower()]
output = io.StringIO()
w = csv.writer(output, delimiter=";")
w.writerow(["Hostname", "OS", "Domaine", "Environnement", "Zone", "Etat",
"Nb patches", "Semaines", "Dernier patch", "Statut"])
for s in servers:
w.writerow([
s.hostname, s.os_family or "", s.domain or "", s.env or "",
s.zone or "", s.etat or "", s.patch_count or 0,
s.patch_weeks or "", s.last_patch_date or s.last_patch_week or "",
s.patch_status_2026 or "",
])
output.seek(0)
return StreamingResponse(
iter(["\ufeff" + output.getvalue()]),
media_type="text/csv",
headers={"Content-Disposition": f"attachment; filename=patching_{year}.csv"})
@router.get("/audit-full/export-csv")
async def audit_full_export_csv(request: Request, db=Depends(get_db)):
user = get_current_user(request)
if not user:
return RedirectResponse(url="/login")
perms = get_user_perms(db, user)
if not can_view(perms, "audit"):
return RedirectResponse(url="/audit-full")
import io, csv
filtre = request.query_params.get("filter", "")
@ -487,6 +570,9 @@ async def audit_full_flow_map(request: Request, db=Depends(get_db)):
user = get_current_user(request)
if not user:
return RedirectResponse(url="/login")
perms = get_user_perms(db, user)
if not can_view(perms, "audit"):
return RedirectResponse(url="/audit-full")
domain_filter = request.query_params.get("domain", "")
server_filter = request.query_params.get("server", "").strip()
@ -577,6 +663,9 @@ async def audit_full_detail(request: Request, audit_id: int, db=Depends(get_db))
user = get_current_user(request)
if not user:
return RedirectResponse(url="/login")
perms = get_user_perms(db, user)
if not can_view(perms, "audit"):
return RedirectResponse(url="/audit-full")
audit = get_audit_detail(db, audit_id)
if not audit:

View File

@ -18,7 +18,7 @@ async def login_page(request: Request):
@router.post("/login")
async def login(request: Request, username: str = Form(...), password: str = Form(...), db=Depends(get_db)):
row = db.execute(text("SELECT id, username, password_hash, role FROM users WHERE LOWER(username) = LOWER(:u)"),
row = db.execute(text("SELECT id, username, password_hash, role, is_active FROM users WHERE LOWER(username) = LOWER(:u)"),
{"u": username}).fetchone()
if not row:
log_login_failed(db, request, username)
@ -26,6 +26,12 @@ async def login(request: Request, username: str = Form(...), password: str = For
return templates.TemplateResponse("login.html", {
"request": request, "app_name": APP_NAME, "version": APP_VERSION, "error": "Utilisateur inconnu"
})
if not row.is_active:
log_login_failed(db, request, username)
db.commit()
return templates.TemplateResponse("login.html", {
"request": request, "app_name": APP_NAME, "version": APP_VERSION, "error": "Compte desactive"
})
try:
ok = verify_password(password, row.password_hash)
except Exception:
@ -40,7 +46,14 @@ async def login(request: Request, username: str = Form(...), password: str = For
user = {"sub": row.username, "role": row.role, "uid": row.id}
log_login(db, request, user)
db.commit()
response = RedirectResponse(url="/dashboard", status_code=303)
# Redirect qw_only users to quickwin
perms = db.execute(text("SELECT module FROM user_permissions WHERE user_id = :uid"), {"uid": row.id}).fetchall()
modules = {r.module for r in perms}
if modules == {"quickwin"}:
redirect_url = "/quickwin"
else:
redirect_url = "/dashboard"
response = RedirectResponse(url=redirect_url, status_code=303)
response.set_cookie(key="access_token", value=token, httponly=True, samesite="lax", max_age=3600)
return response

View File

@ -85,7 +85,10 @@ async def campaign_preview(request: Request, db=Depends(get_db),
year: int = Query(...), week: int = Query(...)):
user = get_current_user(request)
if not user:
return HTMLResponse("<p>Non autorise</p>")
return HTMLResponse("<p>Non autorise</p>", status_code=401)
perms = get_user_perms(db, user)
if not can_view(perms, "campaigns"):
return HTMLResponse("<p>Acces interdit</p>", status_code=403)
servers, planning = get_servers_for_planning(db, year, week)
scope = ", ".join(set(f"{p.domain_name} ({p.env_scope})" for p in planning if p.domain_code))
return templates.TemplateResponse("partials/campaign_preview.html", {
@ -99,6 +102,9 @@ async def campaign_create(request: Request, db=Depends(get_db)):
user = get_current_user(request)
if not user:
return RedirectResponse(url="/login")
perms = get_user_perms(db, user)
if not can_edit(perms, "campaigns"):
return RedirectResponse(url="/campaigns")
form = await request.form()
year = int(form.get("year", datetime.now().year))
week = int(form.get("week_number", 0))
@ -128,6 +134,9 @@ 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")
perms = get_user_perms(db, user)
if not can_view(perms, "campaigns"):
return RedirectResponse(url="/dashboard")
campaign = get_campaign(db, campaign_id)
if not campaign:
return RedirectResponse(url="/campaigns")
@ -212,6 +221,9 @@ async def session_prereq(request: Request, session_id: int, db=Depends(get_db),
user = get_current_user(request)
if not user:
return RedirectResponse(url="/login")
perms = get_user_perms(db, user)
if not can_view(perms, "campaigns"):
return RedirectResponse(url="/campaigns")
validate_prereq(db, session_id, prereq_ssh, prereq_satellite,
rollback_method or None, rollback_justif, user.get("sub"))
row = db.execute(text("SELECT campaign_id FROM patch_sessions WHERE id = :id"),
@ -224,6 +236,9 @@ async def campaign_check_prereqs(request: Request, campaign_id: int, db=Depends(
user = get_current_user(request)
if not user:
return RedirectResponse(url="/login")
perms = get_user_perms(db, user)
if not can_edit(perms, "campaigns"):
return RedirectResponse(url=f"/campaigns/{campaign_id}")
checked, auto_excluded = check_prereqs_campaign(db, campaign_id)
log_prereq_check(db, request, user, campaign_id, checked, auto_excluded)
db.commit()
@ -235,6 +250,9 @@ async def session_check_prereq(request: Request, session_id: int, db=Depends(get
user = get_current_user(request)
if not user:
return RedirectResponse(url="/login")
perms = get_user_perms(db, user)
if not can_view(perms, "campaigns"):
return RedirectResponse(url="/campaigns")
check_single_prereq(db, session_id)
row = db.execute(text("SELECT campaign_id FROM patch_sessions WHERE id = :id"),
{"id": session_id}).fetchone()
@ -247,6 +265,9 @@ async def session_exclude(request: Request, session_id: int, db=Depends(get_db),
user = get_current_user(request)
if not user:
return RedirectResponse(url="/login")
perms = get_user_perms(db, user)
if not can_edit(perms, "campaigns"):
return RedirectResponse(url="/campaigns")
exclude_session(db, session_id, reason, detail, user.get("sub"))
row = db.execute(text("SELECT campaign_id FROM patch_sessions WHERE id = :id"),
{"id": session_id}).fetchone()
@ -258,6 +279,9 @@ async def session_restore(request: Request, session_id: int, db=Depends(get_db))
user = get_current_user(request)
if not user:
return RedirectResponse(url="/login")
perms = get_user_perms(db, user)
if not can_edit(perms, "campaigns"):
return RedirectResponse(url="/campaigns")
restore_session(db, session_id)
row = db.execute(text("SELECT campaign_id FROM patch_sessions WHERE id = :id"),
{"id": session_id}).fetchone()
@ -272,6 +296,9 @@ async def session_take(request: Request, session_id: int, db=Depends(get_db)):
user = get_current_user(request)
if not user:
return RedirectResponse(url="/login")
perms = get_user_perms(db, user)
if not can_view(perms, "campaigns"):
return RedirectResponse(url="/campaigns")
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:
@ -292,6 +319,9 @@ async def session_release(request: Request, session_id: int, db=Depends(get_db))
user = get_current_user(request)
if not user:
return RedirectResponse(url="/login")
perms = get_user_perms(db, user)
if not can_view(perms, "campaigns"):
return RedirectResponse(url="/campaigns")
if is_forced(db, session_id):
row = db.execute(text("SELECT campaign_id FROM patch_sessions WHERE id = :id"),
{"id": session_id}).fetchone()
@ -309,6 +339,9 @@ async def session_assign(request: Request, session_id: int, db=Depends(get_db),
user = get_current_user(request)
if not user:
return RedirectResponse(url="/login")
perms = get_user_perms(db, user)
if not can_edit(perms, "campaigns"):
return RedirectResponse(url="/campaigns")
oid = int(operator_id) if operator_id else None
is_forced_flag = forced == "on"
if oid:
@ -329,6 +362,9 @@ async def set_op_limit(request: Request, campaign_id: int, db=Depends(get_db),
user = get_current_user(request)
if not user:
return RedirectResponse(url="/login")
perms = get_user_perms(db, user)
if not can_edit(perms, "campaigns"):
return RedirectResponse(url=f"/campaigns/{campaign_id}")
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)
@ -375,6 +411,9 @@ async def assignment_add(request: Request, db=Depends(get_db),
user = get_current_user(request)
if not user:
return RedirectResponse(url="/login")
perms = get_user_perms(db, user)
if not can_edit(perms, "campaigns"):
return RedirectResponse(url="/assignments")
try:
db.execute(text("""
INSERT INTO default_assignments (rule_type, rule_value, user_id, priority, note)
@ -393,6 +432,9 @@ async def assignment_delete(request: Request, rule_id: int, db=Depends(get_db)):
user = get_current_user(request)
if not user:
return RedirectResponse(url="/login")
perms = get_user_perms(db, user)
if not can_edit(perms, "campaigns"):
return RedirectResponse(url="/assignments")
db.execute(text("DELETE FROM default_assignments WHERE id = :id"), {"id": rule_id})
db.commit()
return RedirectResponse(url="/assignments?msg=deleted", status_code=303)
@ -406,6 +448,9 @@ async def bulk_take(request: Request, campaign_id: int, db=Depends(get_db),
user = get_current_user(request)
if not user:
return RedirectResponse(url="/login")
perms = get_user_perms(db, user)
if not can_view(perms, "campaigns"):
return RedirectResponse(url="/campaigns")
ids = [int(x) for x in session_ids.split(",") if x.strip().isdigit()]
limit = get_operator_limit(db, campaign_id, user.get("uid"))
current = get_operator_count(db, campaign_id, user.get("uid"))
@ -461,6 +506,9 @@ async def session_schedule(request: Request, session_id: int, db=Depends(get_db)
user = get_current_user(request)
if not user:
return RedirectResponse(url="/login")
perms = get_user_perms(db, user)
if not can_edit(perms, "campaigns"):
return RedirectResponse(url="/campaigns")
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()

View File

@ -43,6 +43,9 @@ async def contacts_page(request: Request, db=Depends(get_db),
user = get_current_user(request)
if not user:
return RedirectResponse(url="/login")
perms = get_user_perms(db, user)
if not can_view(perms, "servers"):
return RedirectResponse(url="/dashboard")
where = ["1=1"]
params = {}
@ -170,6 +173,9 @@ async def contact_add(request: Request, db=Depends(get_db),
user = get_current_user(request)
if not user:
return RedirectResponse(url="/login")
perms = get_user_perms(db, user)
if not can_edit(perms, "servers"):
return RedirectResponse(url="/contacts")
try:
db.execute(text("""
INSERT INTO contacts (name, email, role, is_active)
@ -188,6 +194,9 @@ async def contact_edit(request: Request, contact_id: int, db=Depends(get_db),
user = get_current_user(request)
if not user:
return RedirectResponse(url="/login")
perms = get_user_perms(db, user)
if not can_edit(perms, "servers"):
return RedirectResponse(url="/contacts")
updates = []; params = {"id": contact_id}
if name: updates.append("name = :n"); params["n"] = name
if email: updates.append("email = :e"); params["e"] = email.lower()
@ -203,6 +212,9 @@ async def contact_toggle(request: Request, contact_id: int, db=Depends(get_db)):
user = get_current_user(request)
if not user:
return RedirectResponse(url="/login")
perms = get_user_perms(db, user)
if not can_edit(perms, "servers"):
return RedirectResponse(url="/contacts")
db.execute(text("UPDATE contacts SET is_active = NOT is_active WHERE id = :id"), {"id": contact_id})
db.commit()
return RedirectResponse(url="/contacts?msg=toggled", status_code=303)
@ -215,6 +227,9 @@ async def scope_add(request: Request, contact_id: int, db=Depends(get_db),
user = get_current_user(request)
if not user:
return RedirectResponse(url="/login")
perms = get_user_perms(db, user)
if not can_edit(perms, "servers"):
return RedirectResponse(url="/contacts")
try:
db.execute(text("""
INSERT INTO contact_scopes (contact_id, scope_type, scope_value, env_scope)
@ -231,6 +246,9 @@ async def scope_delete(request: Request, scope_id: int, db=Depends(get_db)):
user = get_current_user(request)
if not user:
return RedirectResponse(url="/login")
perms = get_user_perms(db, user)
if not can_edit(perms, "servers"):
return RedirectResponse(url="/contacts")
db.execute(text("DELETE FROM contact_scopes WHERE id = :id"), {"id": scope_id})
db.commit()
return RedirectResponse(url="/contacts?msg=scope_deleted", status_code=303)
@ -241,6 +259,9 @@ async def contact_delete(request: Request, contact_id: int, db=Depends(get_db)):
user = get_current_user(request)
if not user:
return RedirectResponse(url="/login")
perms = get_user_perms(db, user)
if not can_edit(perms, "servers"):
return RedirectResponse(url="/contacts")
db.execute(text("DELETE FROM contact_scopes WHERE contact_id = :cid"), {"cid": contact_id})
db.execute(text("DELETE FROM contacts WHERE id = :cid"), {"cid": contact_id})
db.commit()

View File

@ -84,6 +84,8 @@ async def planning_page(request: Request, db=Depends(get_db),
next_week = 1
perms = get_user_perms(db, user)
if not can_view(perms, "planning"):
return RedirectResponse(url="/dashboard")
return templates.TemplateResponse("planning.html", {
"request": request, "user": user, "perms": perms, "app_name": APP_NAME,
"year": year, "domains": domains, "grid": grid,
@ -104,6 +106,9 @@ async def planning_add(request: Request, db=Depends(get_db),
user = get_current_user(request)
if not user:
return RedirectResponse(url="/login")
perms = get_user_perms(db, user)
if not can_edit(perms, "planning"):
return RedirectResponse(url="/planning")
y = int(year)
wn = int(week_number) if week_number else 0
@ -146,6 +151,9 @@ async def planning_edit(request: Request, entry_id: int, db=Depends(get_db),
user = get_current_user(request)
if not user:
return RedirectResponse(url="/login")
perms = get_user_perms(db, user)
if not can_edit(perms, "planning"):
return RedirectResponse(url="/planning")
row = db.execute(text("SELECT year FROM patch_planning WHERE id = :id"), {"id": entry_id}).fetchone()
cyc = int(cycle) if cycle.strip() else None
db.execute(text("""
@ -163,6 +171,9 @@ async def planning_delete(request: Request, entry_id: int, db=Depends(get_db)):
user = get_current_user(request)
if not user:
return RedirectResponse(url="/login")
perms = get_user_perms(db, user)
if not can_edit(perms, "planning"):
return RedirectResponse(url="/planning")
row = db.execute(text("SELECT year FROM patch_planning WHERE id = :id"), {"id": entry_id}).fetchone()
db.execute(text("DELETE FROM patch_planning WHERE id = :id"), {"id": entry_id})
db.commit()
@ -177,6 +188,9 @@ async def planning_duplicate(request: Request, db=Depends(get_db),
user = get_current_user(request)
if not user:
return RedirectResponse(url="/login")
perms = get_user_perms(db, user)
if not can_edit(perms, "planning"):
return RedirectResponse(url="/planning")
# Verifier que l'annee cible est vide
existing = db.execute(text("SELECT COUNT(*) FROM patch_planning WHERE year = :y"),

View File

@ -168,6 +168,9 @@ async def qualys_tags_resync(request: Request, db=Depends(get_db)):
user = get_current_user(request)
if not user:
return RedirectResponse(url="/login")
perms = get_user_perms(db, user)
if not can_edit(perms, "qualys"):
return RedirectResponse(url="/qualys/tags")
result = resync_all_tags(db)
msg = "resync_ok" if result["ok"] else "resync_ko"
return RedirectResponse(url=f"/qualys/tags?msg={msg}", status_code=303)
@ -179,6 +182,9 @@ async def qualys_tag_create(request: Request, db=Depends(get_db),
user = get_current_user(request)
if not user:
return RedirectResponse(url="/login")
perms = get_user_perms(db, user)
if not can_edit(perms, "qualys"):
return RedirectResponse(url="/qualys/tags")
result = create_tag_api(db, tag_name.strip())
msg = "created" if result["ok"] else "create_error"
return RedirectResponse(url=f"/qualys/tags?msg={msg}", status_code=303)
@ -189,6 +195,9 @@ async def qualys_tag_delete(request: Request, tag_id: int, db=Depends(get_db)):
user = get_current_user(request)
if not user:
return RedirectResponse(url="/login")
perms = get_user_perms(db, user)
if not can_edit(perms, "qualys"):
return RedirectResponse(url="/qualys/tags")
result = delete_tag_api(db, tag_id)
msg = "deleted" if result["ok"] else "delete_error"
return RedirectResponse(url=f"/qualys/tags?msg={msg}", status_code=303)
@ -200,6 +209,9 @@ async def qualys_asset_tag_add(request: Request, asset_id: int, db=Depends(get_d
user = get_current_user(request)
if not user:
return RedirectResponse(url="/login")
perms = get_user_perms(db, user)
if not can_edit(perms, "qualys"):
return RedirectResponse(url="/qualys/tags")
result = add_tag_to_asset_api(db, asset_id, int(tag_id))
color = "text-cyber-green" if result["ok"] else "text-cyber-red"
return HTMLResponse(f'<span class="text-xs {color}">{result["msg"]}</span>')
@ -211,6 +223,9 @@ async def qualys_asset_tag_remove(request: Request, asset_id: int, db=Depends(ge
user = get_current_user(request)
if not user:
return RedirectResponse(url="/login")
perms = get_user_perms(db, user)
if not can_edit(perms, "qualys"):
return RedirectResponse(url="/qualys/tags")
result = remove_tag_from_asset_api(db, asset_id, int(tag_id))
color = "text-cyber-green" if result["ok"] else "text-cyber-red"
return HTMLResponse(f'<span class="text-xs {color}">{result["msg"]}</span>')
@ -228,6 +243,9 @@ async def qualys_bulk_add_tag(request: Request, db=Depends(get_db)):
user = get_current_user(request)
if not user:
return RedirectResponse(url="/login")
perms = get_user_perms(db, user)
if not can_edit(perms, "qualys"):
return RedirectResponse(url="/qualys/tags")
form = await request.form()
ids = [int(x) for x in form.get("asset_ids", "").split(",") if x.strip().isdigit()]
tid = int(form.get("tag_id", "0") or "0")
@ -244,6 +262,9 @@ async def qualys_bulk_remove_tag(request: Request, db=Depends(get_db)):
user = get_current_user(request)
if not user:
return RedirectResponse(url="/login")
perms = get_user_perms(db, user)
if not can_edit(perms, "qualys"):
return RedirectResponse(url="/qualys/tags")
form = await request.form()
ids = [int(x) for x in form.get("asset_ids", "").split(",") if x.strip().isdigit()]
tid = int(form.get("tag_id", "0") or "0")
@ -260,6 +281,9 @@ async def qualys_resync_assets(request: Request, db=Depends(get_db)):
user = get_current_user(request)
if not user:
return RedirectResponse(url="/login")
perms = get_user_perms(db, user)
if not can_edit(perms, "qualys"):
return RedirectResponse(url="/qualys/search")
form = await request.form()
ids = [int(x) for x in form.get("asset_ids", "").split(",") if x.strip().isdigit()]
ok = 0
@ -303,6 +327,9 @@ async def qualys_tags_export(request: Request, db=Depends(get_db)):
user = get_current_user(request)
if not user:
return RedirectResponse(url="/login")
perms = get_user_perms(db, user)
if not can_view(perms, "qualys"):
return RedirectResponse(url="/qualys/tags")
tags = db.execute(text("SELECT * FROM qualys_tags ORDER BY name")).fetchall()
output = io.StringIO()
writer = csv.writer(output, delimiter=";")
@ -452,14 +479,13 @@ async def qualys_agents_page(request: Request, db=Depends(get_db)):
# Serveurs en prod sans agent Qualys
no_agent = db.execute(text("""
SELECT s.hostname, s.os_family, d.name as domain, e.name as env, z.name as zone
SELECT s.hostname, s.os_family, s.etat, d.name as domain, e.name as env, z.name as zone
FROM servers s
LEFT JOIN domain_environments de ON s.domain_env_id = de.id
LEFT JOIN domains d ON de.domain_id = d.id
LEFT JOIN environments e ON de.environment_id = e.id
LEFT JOIN zones z ON s.zone_id = z.id
WHERE s.etat = 'en_production'
AND NOT EXISTS (SELECT 1 FROM qualys_assets qa WHERE LOWER(qa.hostname) = LOWER(s.hostname))
WHERE NOT EXISTS (SELECT 1 FROM qualys_assets qa WHERE LOWER(qa.hostname) = LOWER(s.hostname))
ORDER BY s.hostname
""")).fetchall()
@ -480,12 +506,73 @@ async def qualys_agents_page(request: Request, db=Depends(get_db)):
return templates.TemplateResponse("qualys_agents.html", ctx)
@router.get("/qualys/agents/export-no-agent")
async def export_no_agent_csv(request: Request, db=Depends(get_db)):
user = get_current_user(request)
if not user:
return RedirectResponse(url="/login")
perms = get_user_perms(db, user)
if not can_view(perms, "qualys"):
return RedirectResponse(url="/qualys/agents")
import io, csv as _csv
rows = db.execute(text("""
SELECT s.hostname, s.os_family, s.etat, d.name as domain, e.name as env, z.name as zone
FROM servers s
LEFT JOIN domain_environments de ON s.domain_env_id = de.id
LEFT JOIN domains d ON de.domain_id = d.id
LEFT JOIN environments e ON de.environment_id = e.id
LEFT JOIN zones z ON s.zone_id = z.id
WHERE NOT EXISTS (SELECT 1 FROM qualys_assets qa WHERE LOWER(qa.hostname) = LOWER(s.hostname))
ORDER BY s.hostname
""")).fetchall()
output = io.StringIO()
w = _csv.writer(output, delimiter=";")
w.writerow(["Hostname", "OS", "Domaine", "Environnement", "Zone", "Etat"])
for r in rows:
w.writerow([r.hostname, r.os_family or "", r.domain or "", r.env or "", r.zone or "", r.etat or ""])
output.seek(0)
return StreamingResponse(
iter(["\ufeff" + output.getvalue()]),
media_type="text/csv",
headers={"Content-Disposition": "attachment; filename=serveurs_sans_agent.csv"})
@router.get("/qualys/agents/export-inactive")
async def export_inactive_csv(request: Request, db=Depends(get_db)):
user = get_current_user(request)
if not user:
return RedirectResponse(url="/login")
perms = get_user_perms(db, user)
if not can_view(perms, "qualys"):
return RedirectResponse(url="/qualys/agents")
import io, csv as _csv
rows = db.execute(text("""
SELECT qa.hostname, qa.os, qa.agent_version, qa.last_checkin, s.etat
FROM qualys_assets qa LEFT JOIN servers s ON qa.server_id = s.id
WHERE qa.agent_status ILIKE '%inactive%' ORDER BY qa.hostname
""")).fetchall()
output = io.StringIO()
w = _csv.writer(output, delimiter=";")
w.writerow(["Hostname", "OS", "Version agent", "Dernier check-in", "Etat"])
for r in rows:
lc = str(r.last_checkin)[:10] if r.last_checkin else ""
w.writerow([r.hostname, r.os or "", r.agent_version or "", lc, r.etat or ""])
output.seek(0)
return StreamingResponse(
iter(["\ufeff" + output.getvalue()]),
media_type="text/csv",
headers={"Content-Disposition": "attachment; filename=agents_inactifs.csv"})
@router.get("/qualys/vulns/{ip}", response_class=HTMLResponse)
async def qualys_vulns_detail(request: Request, ip: str, db=Depends(get_db)):
"""Retourne le detail des vulns severity 3,4,5 pour une IP (fragment HTMX)"""
user = get_current_user(request)
if not user:
return HTMLResponse("<p>Non autorise</p>")
return HTMLResponse("<p>Non autorise</p>", status_code=401)
perms = get_user_perms(db, user)
if not can_view(perms, "qualys"):
return HTMLResponse("<p>Acces interdit</p>", status_code=403)
# Cache 10 min
from ..services import cache as _cache
@ -642,7 +729,10 @@ async def qualys_vulns_detail(request: Request, ip: str, db=Depends(get_db)):
async def qualys_asset_detail(request: Request, asset_id: int, db=Depends(get_db)):
user = get_current_user(request)
if not user:
return HTMLResponse("<p>Non autorisé</p>")
return HTMLResponse("<p>Non autorisé</p>", status_code=401)
perms = get_user_perms(db, user)
if not can_view(perms, "qualys"):
return HTMLResponse("<p>Acces interdit</p>", status_code=403)
asset = db.execute(text("SELECT * FROM qualys_assets WHERE qualys_asset_id = :aid"),
{"aid": asset_id}).fetchone()

1012
app/routers/quickwin.py Normal file

File diff suppressed because it is too large Load Diff

478
app/routers/referentiel.py Normal file
View File

@ -0,0 +1,478 @@
"""Router Referentiel — CRUD domaines, environnements, associations, zones"""
from fastapi import APIRouter, Request, Depends, Form, Query
from fastapi.responses import HTMLResponse, RedirectResponse, JSONResponse
from fastapi.templating import Jinja2Templates
from sqlalchemy import text
from ..dependencies import get_db, get_current_user, get_user_perms, can_view, can_edit
templates = Jinja2Templates(directory="app/templates")
router = APIRouter()
# =========================================================
# PAGE PRINCIPALE (onglets)
# =========================================================
@router.get("/referentiel", response_class=HTMLResponse)
def referentiel_page(request: Request, db=Depends(get_db),
tab: str = Query("domains")):
user = get_current_user(request)
if not user:
return RedirectResponse(url="/login")
perms = get_user_perms(db, user)
if not can_view(perms, "settings"):
return RedirectResponse(url="/dashboard")
can_modify = can_edit(perms, "settings")
# Domaines
domains = db.execute(text(
"SELECT id, name, code, description, default_excludes, default_patch_window, "
"default_patch_frequency, is_active, display_order FROM domains ORDER BY display_order, name"
)).fetchall()
# Environnements
envs = db.execute(text(
"SELECT id, name, code FROM environments ORDER BY id"
)).fetchall()
# Zones
zones = db.execute(text(
"SELECT id, name, description, is_dmz FROM zones ORDER BY id"
)).fetchall()
# Associations domain_environments
assocs = db.execute(text("""
SELECT de.id, d.name as domain_name, d.id as domain_id,
e.name as env_name, e.id as env_id,
de.responsable_nom, de.responsable_email,
de.referent_nom, de.referent_email,
de.patch_window, de.patch_excludes,
de.nb_servers, de.is_active
FROM domain_environments de
JOIN domains d ON de.domain_id = d.id
JOIN environments e ON de.environment_id = e.id
ORDER BY d.display_order, d.name, e.id
""")).fetchall()
# Domaines DNS (domain_ltd)
dns_domains = db.execute(text(
"SELECT id, name, description, is_active FROM domain_ltd_list ORDER BY name"
)).fetchall()
# Compteur serveurs par domain_ltd
dns_srv_counts = {}
rows = db.execute(text("""
SELECT dl.id, COUNT(s.id) as cnt FROM domain_ltd_list dl
LEFT JOIN servers s ON s.domain_ltd = dl.name
GROUP BY dl.id
""")).fetchall()
for r in rows:
dns_srv_counts[r.id] = r.cnt
# Compteur serveurs par domaine
dom_srv_counts = {}
rows = db.execute(text("""
SELECT d.id, COUNT(s.id) as cnt FROM domains d
LEFT JOIN domain_environments de ON de.domain_id = d.id
LEFT JOIN servers s ON s.domain_env_id = de.id
GROUP BY d.id
""")).fetchall()
for r in rows:
dom_srv_counts[r.id] = r.cnt
# Compteur serveurs par env
env_srv_counts = {}
rows = db.execute(text("""
SELECT e.id, COUNT(s.id) as cnt FROM environments e
LEFT JOIN domain_environments de ON de.environment_id = e.id
LEFT JOIN servers s ON s.domain_env_id = de.id
GROUP BY e.id
""")).fetchall()
for r in rows:
env_srv_counts[r.id] = r.cnt
# Compteur serveurs par zone
zone_srv_counts = {}
rows = db.execute(text("""
SELECT z.id, COUNT(s.id) as cnt FROM zones z
LEFT JOIN servers s ON s.zone_id = z.id
GROUP BY z.id
""")).fetchall()
for r in rows:
zone_srv_counts[r.id] = r.cnt
return templates.TemplateResponse("referentiel.html", {
"request": request, "user": user, "perms": perms,
"can_modify": can_modify, "tab": tab,
"domains": domains, "envs": envs, "zones": zones, "assocs": assocs,
"dns_domains": dns_domains, "dns_srv_counts": dns_srv_counts,
"dom_srv_counts": dom_srv_counts,
"env_srv_counts": env_srv_counts,
"zone_srv_counts": zone_srv_counts,
})
# =========================================================
# DOMAINES CRUD
# =========================================================
@router.post("/referentiel/domains/add")
def domain_add(request: Request, db=Depends(get_db),
name: str = Form(...), code: str = Form(...),
description: str = Form(""),
default_excludes: str = Form(""),
default_patch_window: str = Form(""),
display_order: int = Form(0)):
user = get_current_user(request)
if not user:
return RedirectResponse(url="/login")
perms = get_user_perms(db, user)
if not can_edit(perms, "settings"):
return RedirectResponse(url="/referentiel?tab=domains")
db.execute(text("""
INSERT INTO domains (name, code, description, default_excludes,
default_patch_window, display_order)
VALUES (:name, :code, :desc, :excl, :pw, :ord)
"""), {"name": name.strip(), "code": code.strip().upper(),
"desc": description.strip(), "excl": default_excludes.strip(),
"pw": default_patch_window.strip(), "ord": display_order})
db.commit()
return RedirectResponse(url="/referentiel?tab=domains&msg=added", status_code=303)
@router.post("/referentiel/domains/{domain_id}/edit")
def domain_edit(request: Request, domain_id: int, db=Depends(get_db),
name: str = Form(...), code: str = Form(...),
description: str = Form(""),
default_excludes: str = Form(""),
default_patch_window: str = Form(""),
display_order: int = Form(0),
is_active: str = Form("off")):
user = get_current_user(request)
if not user:
return RedirectResponse(url="/login")
perms = get_user_perms(db, user)
if not can_edit(perms, "settings"):
return RedirectResponse(url="/referentiel?tab=domains")
active = is_active == "on"
db.execute(text("""
UPDATE domains SET name=:name, code=:code, description=:desc,
default_excludes=:excl, default_patch_window=:pw,
display_order=:ord, is_active=:act, updated_at=now()
WHERE id=:id
"""), {"id": domain_id, "name": name.strip(), "code": code.strip().upper(),
"desc": description.strip(), "excl": default_excludes.strip(),
"pw": default_patch_window.strip(), "ord": display_order, "act": active})
db.commit()
return RedirectResponse(url="/referentiel?tab=domains&msg=updated", status_code=303)
@router.post("/referentiel/domains/{domain_id}/delete")
def domain_delete(request: Request, domain_id: int, db=Depends(get_db)):
user = get_current_user(request)
if not user:
return RedirectResponse(url="/login")
perms = get_user_perms(db, user)
if not can_edit(perms, "settings"):
return RedirectResponse(url="/referentiel?tab=domains")
# Verifier s'il y a des serveurs lies
cnt = db.execute(text("""
SELECT COUNT(*) as c FROM servers s
JOIN domain_environments de ON s.domain_env_id = de.id
WHERE de.domain_id = :id
"""), {"id": domain_id}).fetchone().c
if cnt > 0:
return RedirectResponse(
url=f"/referentiel?tab=domains&msg=nodelete&detail={cnt}",
status_code=303)
db.execute(text("DELETE FROM domain_environments WHERE domain_id = :id"), {"id": domain_id})
db.execute(text("DELETE FROM domains WHERE id = :id"), {"id": domain_id})
db.commit()
return RedirectResponse(url="/referentiel?tab=domains&msg=deleted", status_code=303)
# =========================================================
# ENVIRONNEMENTS CRUD
# =========================================================
@router.post("/referentiel/envs/add")
def env_add(request: Request, db=Depends(get_db),
name: str = Form(...), code: str = Form(...)):
user = get_current_user(request)
if not user:
return RedirectResponse(url="/login")
perms = get_user_perms(db, user)
if not can_edit(perms, "settings"):
return RedirectResponse(url="/referentiel?tab=envs")
db.execute(text("""
INSERT INTO environments (name, code) VALUES (:name, :code)
"""), {"name": name.strip(), "code": code.strip().upper()})
db.commit()
return RedirectResponse(url="/referentiel?tab=envs&msg=added", status_code=303)
@router.post("/referentiel/envs/{env_id}/edit")
def env_edit(request: Request, env_id: int, db=Depends(get_db),
name: str = Form(...), code: str = Form(...)):
user = get_current_user(request)
if not user:
return RedirectResponse(url="/login")
perms = get_user_perms(db, user)
if not can_edit(perms, "settings"):
return RedirectResponse(url="/referentiel?tab=envs")
db.execute(text("""
UPDATE environments SET name=:name, code=:code WHERE id=:id
"""), {"id": env_id, "name": name.strip(), "code": code.strip().upper()})
db.commit()
return RedirectResponse(url="/referentiel?tab=envs&msg=updated", status_code=303)
@router.post("/referentiel/envs/{env_id}/delete")
def env_delete(request: Request, env_id: int, db=Depends(get_db)):
user = get_current_user(request)
if not user:
return RedirectResponse(url="/login")
perms = get_user_perms(db, user)
if not can_edit(perms, "settings"):
return RedirectResponse(url="/referentiel?tab=envs")
cnt = db.execute(text("""
SELECT COUNT(*) as c FROM servers s
JOIN domain_environments de ON s.domain_env_id = de.id
WHERE de.environment_id = :id
"""), {"id": env_id}).fetchone().c
if cnt > 0:
return RedirectResponse(
url=f"/referentiel?tab=envs&msg=nodelete&detail={cnt}",
status_code=303)
db.execute(text("DELETE FROM domain_environments WHERE environment_id = :id"), {"id": env_id})
db.execute(text("DELETE FROM environments WHERE id = :id"), {"id": env_id})
db.commit()
return RedirectResponse(url="/referentiel?tab=envs&msg=deleted", status_code=303)
# =========================================================
# ZONES CRUD
# =========================================================
@router.post("/referentiel/zones/add")
def zone_add(request: Request, db=Depends(get_db),
name: str = Form(...), description: str = Form(""),
is_dmz: str = Form("off")):
user = get_current_user(request)
if not user:
return RedirectResponse(url="/login")
perms = get_user_perms(db, user)
if not can_edit(perms, "settings"):
return RedirectResponse(url="/referentiel?tab=zones")
db.execute(text("""
INSERT INTO zones (name, description, is_dmz) VALUES (:name, :desc, :dmz)
"""), {"name": name.strip(), "desc": description.strip(), "dmz": is_dmz == "on"})
db.commit()
return RedirectResponse(url="/referentiel?tab=zones&msg=added", status_code=303)
@router.post("/referentiel/zones/{zone_id}/edit")
def zone_edit(request: Request, zone_id: int, db=Depends(get_db),
name: str = Form(...), description: str = Form(""),
is_dmz: str = Form("off")):
user = get_current_user(request)
if not user:
return RedirectResponse(url="/login")
perms = get_user_perms(db, user)
if not can_edit(perms, "settings"):
return RedirectResponse(url="/referentiel?tab=zones")
db.execute(text("""
UPDATE zones SET name=:name, description=:desc, is_dmz=:dmz WHERE id=:id
"""), {"id": zone_id, "name": name.strip(), "desc": description.strip(),
"dmz": is_dmz == "on"})
db.commit()
return RedirectResponse(url="/referentiel?tab=zones&msg=updated", status_code=303)
@router.post("/referentiel/zones/{zone_id}/delete")
def zone_delete(request: Request, zone_id: int, db=Depends(get_db)):
user = get_current_user(request)
if not user:
return RedirectResponse(url="/login")
perms = get_user_perms(db, user)
if not can_edit(perms, "settings"):
return RedirectResponse(url="/referentiel?tab=zones")
cnt = db.execute(text(
"SELECT COUNT(*) as c FROM servers WHERE zone_id = :id"
), {"id": zone_id}).fetchone().c
if cnt > 0:
return RedirectResponse(
url=f"/referentiel?tab=zones&msg=nodelete&detail={cnt}",
status_code=303)
db.execute(text("DELETE FROM zones WHERE id = :id"), {"id": zone_id})
db.commit()
return RedirectResponse(url="/referentiel?tab=zones&msg=deleted", status_code=303)
# =========================================================
# ASSOCIATIONS DOMAIN x ENV
# =========================================================
@router.post("/referentiel/assocs/add")
def assoc_add(request: Request, db=Depends(get_db),
domain_id: int = Form(...), environment_id: int = Form(...),
responsable_nom: str = Form(""), responsable_email: str = Form(""),
referent_nom: str = Form(""), referent_email: str = Form(""),
patch_window: str = Form(""), patch_excludes: str = Form("")):
user = get_current_user(request)
if not user:
return RedirectResponse(url="/login")
perms = get_user_perms(db, user)
if not can_edit(perms, "settings"):
return RedirectResponse(url="/referentiel?tab=assocs")
existing = db.execute(text(
"SELECT id FROM domain_environments WHERE domain_id=:d AND environment_id=:e"
), {"d": domain_id, "e": environment_id}).fetchone()
if existing:
return RedirectResponse(url="/referentiel?tab=assocs&msg=exists", status_code=303)
db.execute(text("""
INSERT INTO domain_environments (domain_id, environment_id, responsable_nom,
responsable_email, referent_nom, referent_email, patch_window, patch_excludes)
VALUES (:d, :e, :rn, :re, :fn, :fe, :pw, :pe)
"""), {"d": domain_id, "e": environment_id,
"rn": responsable_nom.strip(), "re": responsable_email.strip(),
"fn": referent_nom.strip(), "fe": referent_email.strip(),
"pw": patch_window.strip(), "pe": patch_excludes.strip()})
db.commit()
return RedirectResponse(url="/referentiel?tab=assocs&msg=added", status_code=303)
@router.post("/referentiel/assocs/{assoc_id}/edit")
def assoc_edit(request: Request, assoc_id: int, db=Depends(get_db),
responsable_nom: str = Form(""), responsable_email: str = Form(""),
referent_nom: str = Form(""), referent_email: str = Form(""),
patch_window: str = Form(""), patch_excludes: str = Form(""),
is_active: str = Form("off")):
user = get_current_user(request)
if not user:
return RedirectResponse(url="/login")
perms = get_user_perms(db, user)
if not can_edit(perms, "settings"):
return RedirectResponse(url="/referentiel?tab=assocs")
db.execute(text("""
UPDATE domain_environments SET responsable_nom=:rn, responsable_email=:re,
referent_nom=:fn, referent_email=:fe, patch_window=:pw,
patch_excludes=:pe, is_active=:act
WHERE id=:id
"""), {"id": assoc_id,
"rn": responsable_nom.strip(), "re": responsable_email.strip(),
"fn": referent_nom.strip(), "fe": referent_email.strip(),
"pw": patch_window.strip(), "pe": patch_excludes.strip(),
"act": is_active == "on"})
db.commit()
return RedirectResponse(url="/referentiel?tab=assocs&msg=updated", status_code=303)
@router.post("/referentiel/assocs/{assoc_id}/delete")
def assoc_delete(request: Request, assoc_id: int, db=Depends(get_db)):
user = get_current_user(request)
if not user:
return RedirectResponse(url="/login")
perms = get_user_perms(db, user)
if not can_edit(perms, "settings"):
return RedirectResponse(url="/referentiel?tab=assocs")
cnt = db.execute(text(
"SELECT COUNT(*) as c FROM servers WHERE domain_env_id = :id"
), {"id": assoc_id}).fetchone().c
if cnt > 0:
return RedirectResponse(
url=f"/referentiel?tab=assocs&msg=nodelete&detail={cnt}",
status_code=303)
db.execute(text("DELETE FROM domain_environments WHERE id = :id"), {"id": assoc_id})
db.commit()
return RedirectResponse(url="/referentiel?tab=assocs&msg=deleted", status_code=303)
# =========================================================
# DOMAINES DNS (domain_ltd)
# =========================================================
@router.post("/referentiel/dns/add")
def dns_add(request: Request, db=Depends(get_db),
name: str = Form(...), description: str = Form("")):
user = get_current_user(request)
if not user:
return RedirectResponse(url="/login")
perms = get_user_perms(db, user)
if not can_edit(perms, "settings"):
return RedirectResponse(url="/referentiel?tab=dns")
db.execute(text("""
INSERT INTO domain_ltd_list (name, description)
VALUES (:name, :desc)
"""), {"name": name.strip().lower(), "desc": description.strip()})
db.commit()
return RedirectResponse(url="/referentiel?tab=dns&msg=added", status_code=303)
@router.post("/referentiel/dns/{dns_id}/edit")
def dns_edit(request: Request, dns_id: int, db=Depends(get_db),
name: str = Form(...), description: str = Form(""),
is_active: str = Form("off")):
user = get_current_user(request)
if not user:
return RedirectResponse(url="/login")
perms = get_user_perms(db, user)
if not can_edit(perms, "settings"):
return RedirectResponse(url="/referentiel?tab=dns")
old = db.execute(text("SELECT name FROM domain_ltd_list WHERE id=:id"), {"id": dns_id}).fetchone()
new_name = name.strip().lower()
db.execute(text("""
UPDATE domain_ltd_list SET name=:name, description=:desc, is_active=:act WHERE id=:id
"""), {"id": dns_id, "name": new_name, "desc": description.strip(),
"act": is_active == "on"})
# Propager le renommage sur les serveurs
if old and old.name != new_name:
db.execute(text("UPDATE servers SET domain_ltd=:new WHERE domain_ltd=:old"),
{"old": old.name, "new": new_name})
db.commit()
return RedirectResponse(url="/referentiel?tab=dns&msg=updated", status_code=303)
@router.post("/referentiel/dns/{dns_id}/delete")
def dns_delete(request: Request, dns_id: int, db=Depends(get_db)):
user = get_current_user(request)
if not user:
return RedirectResponse(url="/login")
perms = get_user_perms(db, user)
if not can_edit(perms, "settings"):
return RedirectResponse(url="/referentiel?tab=dns")
row = db.execute(text("SELECT name FROM domain_ltd_list WHERE id=:id"), {"id": dns_id}).fetchone()
if row:
cnt = db.execute(text(
"SELECT COUNT(*) as c FROM servers WHERE domain_ltd = :n"
), {"n": row.name}).fetchone().c
if cnt > 0:
return RedirectResponse(
url=f"/referentiel?tab=dns&msg=nodelete&detail={cnt}",
status_code=303)
db.execute(text("DELETE FROM domain_ltd_list WHERE id = :id"), {"id": dns_id})
db.commit()
return RedirectResponse(url="/referentiel?tab=dns&msg=deleted", status_code=303)

View File

@ -89,6 +89,9 @@ async def safe_patching_detail(request: Request, campaign_id: int, db=Depends(ge
user = get_current_user(request)
if not user:
return RedirectResponse(url="/login")
perms = get_user_perms(db, user)
if not can_view(perms, "campaigns"):
return RedirectResponse(url="/dashboard")
campaign = get_campaign(db, campaign_id)
if not campaign:
@ -148,6 +151,9 @@ async def safe_patching_check_prereqs(request: Request, campaign_id: int, db=Dep
user = get_current_user(request)
if not user:
return RedirectResponse(url="/login")
perms = get_user_perms(db, user)
if not can_edit(perms, "campaigns"):
return RedirectResponse(url=f"/safe-patching/{campaign_id}")
from ..services.prereq_service import check_prereqs_campaign
checked, auto_excluded = check_prereqs_campaign(db, campaign_id)
return RedirectResponse(url=f"/safe-patching/{campaign_id}?step=prereqs&msg=prereqs_done", status_code=303)
@ -159,6 +165,9 @@ async def safe_patching_bulk_exclude(request: Request, campaign_id: int, db=Depe
user = get_current_user(request)
if not user:
return RedirectResponse(url="/login")
perms = get_user_perms(db, user)
if not can_edit(perms, "campaigns"):
return RedirectResponse(url=f"/safe-patching/{campaign_id}")
from ..services.campaign_service import exclude_session
ids = [int(x) for x in session_ids.split(",") if x.strip().isdigit()]
for sid in ids:
@ -173,6 +182,9 @@ async def safe_patching_execute(request: Request, campaign_id: int, db=Depends(g
user = get_current_user(request)
if not user:
return RedirectResponse(url="/login")
perms = get_user_perms(db, user)
if not can_edit(perms, "campaigns"):
return RedirectResponse(url=f"/safe-patching/{campaign_id}")
# Récupérer les sessions pending de la branche
if branch == "hprod":
@ -215,6 +227,9 @@ async def safe_patching_terminal(request: Request, campaign_id: int, db=Depends(
user = get_current_user(request)
if not user:
return RedirectResponse(url="/login")
perms = get_user_perms(db, user)
if not can_view(perms, "campaigns"):
return RedirectResponse(url="/safe-patching")
campaign = get_campaign(db, campaign_id)
ctx = base_context(request, db, user)
ctx.update({"app_name": APP_NAME, "c": campaign, "branch": branch})
@ -222,8 +237,11 @@ async def safe_patching_terminal(request: Request, campaign_id: int, db=Depends(
@router.get("/safe-patching/{campaign_id}/stream")
async def safe_patching_stream(request: Request, campaign_id: int):
async def safe_patching_stream(request: Request, campaign_id: int, db=Depends(get_db)):
"""SSE endpoint — stream les logs en temps réel"""
user = get_current_user(request)
if not user:
return StreamingResponse(iter([]), media_type="text/event-stream")
async def event_generator():
q = get_stream(campaign_id)
while True:

View File

@ -1,6 +1,6 @@
"""Router serveurs — CRUD + detail + edit via HTMX"""
from fastapi import APIRouter, Request, Depends, Query, Form
from fastapi.responses import HTMLResponse, RedirectResponse
from fastapi.responses import HTMLResponse, RedirectResponse, StreamingResponse
from fastapi.templating import Jinja2Templates
from ..dependencies import get_db, get_current_user
from ..services.server_service import (
@ -18,13 +18,14 @@ templates = Jinja2Templates(directory="app/templates")
async def servers_list(request: Request, db=Depends(get_db),
domain: str = Query(None), env: str = Query(None),
tier: str = Query(None), etat: str = Query(None),
os: str = Query(None), owner: str = Query(None),
search: str = Query(None), page: int = Query(1),
sort: str = Query("hostname"), sort_dir: str = Query("asc")):
user = get_current_user(request)
if not user:
return RedirectResponse(url="/login")
filters = {"domain": domain, "env": env, "tier": tier, "etat": etat, "search": search}
filters = {"domain": domain, "env": env, "tier": tier, "etat": etat, "os": os, "owner": owner, "search": search}
servers, total = list_servers(db, filters, page, sort=sort, sort_dir=sort_dir)
domains_list, envs_list = get_reference_data(db)
@ -36,6 +37,37 @@ async def servers_list(request: Request, db=Depends(get_db),
})
@router.get("/servers/export-csv")
async def servers_export_csv(request: Request, db=Depends(get_db),
domain: str = Query(None), env: str = Query(None),
tier: str = Query(None), etat: str = Query(None),
os: str = Query(None), owner: str = Query(None),
search: str = Query(None)):
user = get_current_user(request)
if not user:
return RedirectResponse(url="/login")
import io, csv
filters = {"domain": domain, "env": env, "tier": tier, "etat": etat, "os": os, "owner": owner, "search": search}
servers, total = list_servers(db, filters, page=1, per_page=99999, sort="hostname", sort_dir="asc")
output = io.StringIO()
w = csv.writer(output, delimiter=";")
w.writerow(["Hostname", "FQDN", "OS", "Version OS", "Domaine", "Environnement",
"Zone", "Tier", "Etat", "Owner patching", "Application"])
for s in servers:
w.writerow([
s.hostname, getattr(s, "fqdn", "") or "", s.os_family or "",
getattr(s, "os_version", "") or "",
getattr(s, "domaine", "") or "", getattr(s, "environnement", "") or "",
getattr(s, "zone_name", "") or "", s.tier or "", s.etat or "",
s.patch_os_owner or "", getattr(s, "application_name", "") or "",
])
output.seek(0)
return StreamingResponse(
iter(["\ufeff" + output.getvalue()]),
media_type="text/csv",
headers={"Content-Disposition": "attachment; filename=serveurs.csv"})
@router.get("/servers/{server_id}/detail", response_class=HTMLResponse)
async def server_detail(request: Request, server_id: int, db=Depends(get_db)):
user = get_current_user(request)
@ -61,8 +93,17 @@ async def server_edit(request: Request, server_id: int, db=Depends(get_db)):
return HTMLResponse("<p>Serveur non trouve</p>")
domains, envs = get_reference_data(db)
ips = get_server_ips(db, server_id)
from sqlalchemy import text as sqlt
dns_list = db.execute(sqlt(
"SELECT name FROM domain_ltd_list WHERE is_active = true ORDER BY name"
)).fetchall()
zones_list = db.execute(sqlt(
"SELECT name FROM zones ORDER BY name"
)).fetchall()
return templates.TemplateResponse("partials/server_edit.html", {
"request": request, "s": s, "domains": domains, "envs": envs, "ips": ips
"request": request, "s": s, "domains": domains, "envs": envs, "ips": ips,
"dns_list": [r.name for r in dns_list],
"zones_list": [r.name for r in zones_list],
})
@ -74,7 +115,7 @@ async def server_update(request: Request, server_id: int, db=Depends(get_db),
referent_nom: str = Form(None), mode_operatoire: str = Form(None),
commentaire: str = Form(None),
ip_reelle: str = Form(None), ip_connexion: str = Form(None),
ssh_method: str = Form(None),
ssh_method: str = Form(None), domain_ltd: str = Form(None),
pref_patch_jour: str = Form(None), pref_patch_heure: str = Form(None)):
user = get_current_user(request)
@ -87,7 +128,7 @@ async def server_update(request: Request, server_id: int, db=Depends(get_db),
"responsable_nom": responsable_nom, "referent_nom": referent_nom,
"mode_operatoire": mode_operatoire, "commentaire": commentaire,
"ip_reelle": ip_reelle, "ip_connexion": ip_connexion,
"ssh_method": ssh_method,
"ssh_method": ssh_method, "domain_ltd": domain_ltd,
"pref_patch_jour": pref_patch_jour, "pref_patch_heure": pref_patch_heure,
}
update_server(db, server_id, data, user.get("sub"))

View File

@ -3,7 +3,7 @@ from fastapi import APIRouter, Request, Depends, Form
from fastapi.responses import HTMLResponse, RedirectResponse
from fastapi.templating import Jinja2Templates
from sqlalchemy import text
from ..dependencies import get_db, get_current_user
from ..dependencies import get_db, get_current_user, get_user_perms, can_view, can_edit
from ..services.secrets_service import get_secret, set_secret, list_secrets, init_secrets_from_config
from ..config import APP_NAME
@ -134,6 +134,9 @@ async def settings_page(request: Request, db=Depends(get_db)):
user = get_current_user(request)
if not user:
return RedirectResponse(url="/login")
perms = get_user_perms(db, user)
if not can_view(perms, "settings"):
return RedirectResponse(url="/dashboard")
ctx = _build_context(db, user)
ctx["request"] = request
return templates.TemplateResponse("settings.html", ctx)
@ -146,6 +149,12 @@ async def settings_save(request: Request, section: str, db=Depends(get_db)):
return RedirectResponse(url="/login")
if section not in SECTIONS:
return HTMLResponse("<p>Section inconnue</p>", status_code=400)
perms = get_user_perms(db, user)
if not can_edit(perms, "settings"):
return RedirectResponse(url="/settings")
role = user.get("role", "viewer")
if section in SECTION_ACCESS and role not in SECTION_ACCESS[section]["editable"]:
return RedirectResponse(url="/settings")
form = await request.form()
for key, label, is_secret in SECTIONS[section]:
@ -174,6 +183,9 @@ async def vcenter_add(request: Request, db=Depends(get_db),
user = get_current_user(request)
if not user:
return RedirectResponse(url="/login")
perms = get_user_perms(db, user)
if not can_edit(perms, "settings"):
return RedirectResponse(url="/settings")
db.execute(text(
"INSERT INTO vcenters (name, endpoint, datacenter, description, responsable) VALUES (:n, :e, :dc, :desc, :resp)"
), {"n": vc_name, "e": vc_endpoint, "dc": vc_datacenter or None, "desc": vc_description or None, "resp": vc_responsable or None})
@ -188,6 +200,9 @@ async def vcenter_delete(request: Request, vc_id: int, db=Depends(get_db)):
user = get_current_user(request)
if not user:
return RedirectResponse(url="/login")
perms = get_user_perms(db, user)
if not can_edit(perms, "settings"):
return RedirectResponse(url="/settings")
db.execute(text("UPDATE vcenters SET is_active = false WHERE id = :id"), {"id": vc_id})
db.commit()
ctx = _build_context(db, user, saved="vsphere")
@ -203,6 +218,9 @@ async def secret_update(request: Request, db=Depends(get_db),
user = get_current_user(request)
if not user:
return RedirectResponse(url="/login")
perms = get_user_perms(db, user)
if not can_edit(perms, "settings"):
return RedirectResponse(url="/settings")
if secret_value and secret_value != "********":
# Recuperer la description existante
existing = db.execute(text("SELECT description FROM app_secrets WHERE key = :k"),
@ -240,6 +258,9 @@ async def network_add(request: Request, db=Depends(get_db),
user = get_current_user(request)
if not user:
return RedirectResponse(url="/login")
perms = get_user_perms(db, user)
if not can_edit(perms, "settings"):
return RedirectResponse(url="/settings")
db.execute(text("INSERT INTO allowed_networks (cidr, description) VALUES (:c, :d)"),
{"c": cidr.strip(), "d": description or None})
db.commit()
@ -254,6 +275,9 @@ async def network_delete(request: Request, net_id: int, db=Depends(get_db)):
user = get_current_user(request)
if not user:
return RedirectResponse(url="/login")
perms = get_user_perms(db, user)
if not can_edit(perms, "settings"):
return RedirectResponse(url="/settings")
db.execute(text("DELETE FROM allowed_networks WHERE id = :id"), {"id": net_id})
db.commit()
_regen_nginx_acl(db)
@ -267,6 +291,9 @@ async def network_toggle(request: Request, net_id: int, db=Depends(get_db)):
user = get_current_user(request)
if not user:
return RedirectResponse(url="/login")
perms = get_user_perms(db, user)
if not can_edit(perms, "settings"):
return RedirectResponse(url="/settings")
db.execute(text("UPDATE allowed_networks SET is_active = NOT is_active WHERE id = :id"), {"id": net_id})
db.commit()
_regen_nginx_acl(db)

View File

@ -47,6 +47,9 @@ async def specifics_list(request: Request, db=Depends(get_db),
user = get_current_user(request)
if not user:
return RedirectResponse(url="/login")
perms = get_user_perms(db, user)
if not can_view(perms, "specifics"):
return RedirectResponse(url="/dashboard")
entries = _list_specifics(db, app_type, search)
# Types en base
types_in_db = db.execute(text(
@ -65,6 +68,9 @@ async def specific_edit(request: Request, spec_id: int, db=Depends(get_db)):
user = get_current_user(request)
if not user:
return HTMLResponse("<p>Non autorise</p>")
perms = get_user_perms(db, user)
if not can_edit(perms, "specifics"):
return HTMLResponse("<p>Acces interdit</p>", status_code=403)
row = db.execute(text("""
SELECT ss.*, s.hostname FROM server_specifics ss
JOIN servers s ON ss.server_id = s.id WHERE ss.id = :id
@ -81,6 +87,9 @@ async def specific_save(request: Request, spec_id: int, db=Depends(get_db)):
user = get_current_user(request)
if not user:
return RedirectResponse(url="/login")
perms = get_user_perms(db, user)
if not can_edit(perms, "specifics"):
return RedirectResponse(url="/specifics")
form = await request.form()
def val(k): v = form.get(k, ""); return v.strip() if v else None
@ -147,6 +156,9 @@ async def specific_add(request: Request, db=Depends(get_db),
user = get_current_user(request)
if not user:
return RedirectResponse(url="/login")
perms = get_user_perms(db, user)
if not can_edit(perms, "specifics"):
return RedirectResponse(url="/specifics")
row = db.execute(text("SELECT id FROM servers WHERE LOWER(hostname) = LOWER(:h)"),
{"h": hostname.strip()}).fetchone()
if not row:

View File

@ -0,0 +1,73 @@
"""Service logs QuickWin — journalisation des actions et erreurs par campagne"""
from sqlalchemy import text
def log_entry(db, run_id, step, level, message, detail=None,
entry_id=None, hostname=None, created_by=None):
"""Ajoute une ligne de log pour un run QuickWin.
level: info, warn, error, success"""
db.execute(text("""
INSERT INTO quickwin_logs (run_id, entry_id, hostname, step, level, message, detail, created_by)
VALUES (:rid, :eid, :host, :step, :lvl, :msg, :det, :by)
"""), {
"rid": run_id, "eid": entry_id, "host": hostname,
"step": step, "lvl": level, "msg": message,
"det": detail, "by": created_by,
})
def log_info(db, run_id, step, message, **kwargs):
log_entry(db, run_id, step, "info", message, **kwargs)
def log_warn(db, run_id, step, message, **kwargs):
log_entry(db, run_id, step, "warn", message, **kwargs)
def log_error(db, run_id, step, message, **kwargs):
log_entry(db, run_id, step, "error", message, **kwargs)
def log_success(db, run_id, step, message, **kwargs):
log_entry(db, run_id, step, "success", message, **kwargs)
def get_logs(db, run_id, level=None, step=None, hostname=None, limit=500):
"""Recupere les logs d'un run avec filtres optionnels."""
where = ["run_id = :rid"]
params = {"rid": run_id}
if level:
where.append("level = :lvl")
params["lvl"] = level
if step:
where.append("step = :step")
params["step"] = step
if hostname:
where.append("hostname ILIKE :host")
params["host"] = f"%{hostname}%"
params["lim"] = limit
return db.execute(text(f"""
SELECT * FROM quickwin_logs
WHERE {' AND '.join(where)}
ORDER BY created_at DESC
LIMIT :lim
"""), params).fetchall()
def get_log_stats(db, run_id):
"""Compteurs par level pour un run."""
return db.execute(text("""
SELECT level, COUNT(*) as cnt
FROM quickwin_logs WHERE run_id = :rid
GROUP BY level ORDER BY level
"""), {"rid": run_id}).fetchall()
def clear_logs(db, run_id, step=None):
"""Supprime les logs d'un run (optionnel: seulement une etape)."""
if step:
db.execute(text("DELETE FROM quickwin_logs WHERE run_id = :rid AND step = :step"),
{"rid": run_id, "step": step})
else:
db.execute(text("DELETE FROM quickwin_logs WHERE run_id = :rid"), {"rid": run_id})
db.commit()

View File

@ -0,0 +1,387 @@
"""Service prereq QuickWin — resolution DNS, SSH, disque, satellite
Adapte au contexte SANEF : PSMP CyberArk + SSH key selon methode serveur"""
import socket
import logging
import os
log = logging.getLogger("quickwin.prereq")
try:
import paramiko
PARAMIKO_OK = True
except ImportError:
PARAMIKO_OK = False
log.warning("paramiko non disponible — checks SSH impossibles")
# --- Constantes ---
DOMP = "sanef.groupe" # domaine prod/preprod/dev
DOMR = "sanef-rec.fr" # domaine recette/test
PSMP_HOST = "psmp.sanef.fr"
CYBR_USER = "CYBP01336"
TARGET_USER = "cybsecope"
SSH_KEY_FILE_DEFAULT = "/opt/patchcenter/keys/id_rsa_cybglobal.pem"
SSH_TIMEOUT = 15
# Seuils disque (% utilise)
DISK_MAX_PCT = 90 # >90% = KO
# Banniere CyberArk a filtrer
BANNER_FILTERS = [
"GROUPE SANEF", "propriete du Groupe", "accederait", "emprisonnement",
"Article 323", "code penal", "Authorized uses only", "CyberArk",
"This session", "session is being",
]
def _get_settings(db):
"""Charge les settings utiles depuis la table settings"""
from sqlalchemy import text
rows = db.execute(text(
"SELECT key, value FROM settings WHERE key IN "
"('psmp_host','default_ssh_timeout','disk_min_root_mb')"
)).fetchall()
return {r.key: r.value for r in rows}
def _get_secret(db, key):
"""Recupere un secret dechiffre depuis app_secrets"""
try:
from ..services.secrets_service import get_secret
return get_secret(db, key)
except Exception:
return None
# =========================================================
# 1. RESOLUTION DNS
# =========================================================
def _detect_env(hostname):
"""Detecte l'environnement par la 2e lettre du hostname (convention SANEF)
p=prod, i=preprod, r=recette, v/t=test, d=dev"""
if len(hostname) < 2:
return "unknown"
c = hostname[1].lower()
if c == "p":
return "prod"
elif c == "i":
return "preprod"
elif c == "r":
return "recette"
elif c in ("v", "t"):
return "test"
elif c == "d":
return "dev"
return "unknown"
def _resolve_fqdn(hostname, domain_ltd=None, env_code=None):
"""Resout le hostname en FQDN testable.
Retourne (fqdn, None) ou (None, error_msg).
Logique:
- Prod/Preprod/Dev: domp d'abord, puis domr
- Recette/Test: domr d'abord, puis domp
- Utilise domain_ltd si dispo, sinon detection par hostname
"""
if "." in hostname:
# Deja un FQDN
if _dns_resolves(hostname):
return hostname, None
return None, f"FQDN {hostname} non resolvable"
# Determiner l'ordre des domaines
env = _detect_env(hostname)
if env_code:
ec = env_code.upper()
if ec in ("PRD", "PPR", "DEV"):
env = "prod"
elif ec in ("REC",):
env = "recette"
elif ec in ("TES", "TS1", "TS2"):
env = "test"
if env in ("prod", "preprod", "dev"):
domains_order = [DOMP, DOMR]
elif env in ("recette", "test"):
domains_order = [DOMR, DOMP]
else:
# Fallback: utiliser domain_ltd si dispo
if domain_ltd and domain_ltd.strip():
alt = DOMR if domain_ltd.strip() == DOMP else DOMP
domains_order = [domain_ltd.strip(), alt]
else:
domains_order = [DOMP, DOMR]
# Tenter resolution dans l'ordre
for dom in domains_order:
fqdn = f"{hostname}.{dom}"
if _dns_resolves(fqdn):
return fqdn, None
return None, f"DNS KO: {hostname} non resolu ({'/'.join(domains_order)})"
def _dns_resolves(fqdn):
"""Verifie si un FQDN se resout en IP"""
try:
socket.getaddrinfo(fqdn, 22, socket.AF_INET, socket.SOCK_STREAM)
return True
except (socket.gaierror, socket.herror, OSError):
return False
# =========================================================
# 2. TEST SSH
# =========================================================
def _get_ssh_key_path(db=None):
"""Retourne le chemin de la cle SSH. Cherche d'abord dans app_secrets (ssh_key_file),
puis fallback sur le chemin par defaut."""
if db:
secret_path = _get_secret(db, "ssh_key_file")
if secret_path and secret_path.strip() and os.path.exists(secret_path.strip()):
return secret_path.strip()
if os.path.exists(SSH_KEY_FILE_DEFAULT):
return SSH_KEY_FILE_DEFAULT
return None
def _load_ssh_key(db=None):
"""Charge la cle SSH privee depuis le chemin configure en base ou par defaut"""
key_path = _get_ssh_key_path(db)
if not key_path:
return None
for cls in [paramiko.RSAKey, paramiko.Ed25519Key, paramiko.ECDSAKey]:
try:
return cls.from_private_key_file(key_path)
except Exception:
continue
return None
def _ssh_via_psmp(fqdn, password):
"""Connexion SSH via PSMP CyberArk (interactive auth).
Username format: CYBP01336@cybsecope@fqdn"""
if not password:
return None, "PSMP: pas de mot de passe configure"
try:
username = f"{CYBR_USER}@{TARGET_USER}@{fqdn}"
transport = paramiko.Transport((PSMP_HOST, 22))
transport.connect()
def handler(title, instructions, prompt_list):
return [password] * len(prompt_list)
transport.auth_interactive(username, handler)
client = paramiko.SSHClient()
client._transport = transport
return client, None
except Exception as e:
return None, f"PSMP: {str(e)[:120]}"
def _ssh_via_key(fqdn, ssh_user, key):
"""Connexion SSH directe par cle"""
if not key:
return None, "SSH key: cle non trouvee"
if not ssh_user:
return None, "SSH key: user non configure"
try:
client = paramiko.SSHClient()
client.set_missing_host_key_policy(paramiko.AutoAddPolicy())
client.connect(fqdn, port=22, username=ssh_user, pkey=key,
timeout=SSH_TIMEOUT, look_for_keys=False, allow_agent=False)
return client, None
except Exception as e:
return None, f"SSH key: {str(e)[:120]}"
def _ssh_connect(fqdn, ssh_method, db):
"""Connecte au serveur selon la methode (ssh_psmp ou ssh_key).
Retourne (client, error_msg)"""
if not PARAMIKO_OK:
return None, "paramiko non installe"
if ssh_method == "ssh_psmp":
# PSMP: password depuis app_secrets
password = _get_secret(db, "ssh_pwd_default_pass")
client, err = _ssh_via_psmp(fqdn, password)
if client:
return client, None
# Fallback: tenter par cle
key = _load_ssh_key(db)
ssh_user = _get_secret(db, "ssh_pwd_default_user") or TARGET_USER
client2, err2 = _ssh_via_key(fqdn, ssh_user, key)
if client2:
return client2, None
return None, err # retourner l'erreur PSMP originale
else:
# ssh_key: user depuis secrets, cle depuis fichier
ssh_user = _get_secret(db, "ssh_pwd_default_user") or TARGET_USER
key = _load_ssh_key(db)
client, err = _ssh_via_key(fqdn, ssh_user, key)
if client:
return client, None
# Fallback: tenter via PSMP
password = _get_secret(db, "ssh_pwd_default_pass")
if password:
client2, err2 = _ssh_via_psmp(fqdn, password)
if client2:
return client2, None
return None, err
def _ssh_exec(client, cmd, timeout=12):
"""Execute une commande via SSH et retourne (stdout, stderr, returncode).
Filtre les bannieres CyberArk."""
try:
chan = client._transport.open_session()
chan.settimeout(timeout)
chan.exec_command(cmd)
out = b""
err = b""
while True:
try:
chunk = chan.recv(8192)
if not chunk:
break
out += chunk
except Exception:
break
try:
err = chan.recv_stderr(8192)
except Exception:
pass
rc = chan.recv_exit_status()
chan.close()
# Filtrer bannieres
out_str = out.decode("utf-8", errors="replace")
lines = [l for l in out_str.splitlines() if not any(b in l for b in BANNER_FILTERS)]
return "\n".join(lines), err.decode("utf-8", errors="replace"), rc
except Exception as e:
return "", str(e), -1
# =========================================================
# 3. TEST ESPACE DISQUE
# =========================================================
def _check_disk(client):
"""Verifie l'espace disque / et /var via sudo df.
Retourne (ok, detail_msg)"""
out, err, rc = _ssh_exec(client, "sudo df / /var --output=target,pcent 2>/dev/null | tail -n +2")
if rc != 0 or not out.strip():
return True, "Disque: non verifie (df echoue)"
ok = True
parts = []
for line in out.strip().splitlines():
tokens = line.split()
if len(tokens) >= 2:
mount = tokens[0]
pct_str = tokens[-1].replace("%", "").strip()
if pct_str.isdigit():
pct = int(pct_str)
if pct >= DISK_MAX_PCT:
ok = False
parts.append(f"{mount}={pct}% KO")
else:
parts.append(f"{mount}={pct}%")
if not parts:
return True, "Disque: non verifie"
return ok, "Disque: " + ", ".join(parts)
# =========================================================
# 4. TEST SATELLITE / YUM REPOS
# =========================================================
def _check_satellite(client):
"""Verifie l'enregistrement Satellite et les repos YUM.
Retourne (ok, detail_msg)"""
# Tenter subscription-manager d'abord
out, err, rc = _ssh_exec(client, "sudo subscription-manager status 2>/dev/null | head -5")
if rc == 0 and "Current" in out:
return True, "Satellite: OK (subscription-manager)"
# Fallback: yum repolist
out2, err2, rc2 = _ssh_exec(client, "sudo yum repolist 2>/dev/null | tail -1")
if rc2 == 0 and out2.strip():
line = out2.strip()
# Si "repolist: 0" => pas de repos
if "repolist: 0" in line.lower():
return False, "Satellite: KO (0 repos)"
return True, f"Satellite: OK ({line[:60]})"
return False, "Satellite: KO (pas de repos ni subscription)"
# =========================================================
# ORCHESTRATEUR PRINCIPAL
# =========================================================
def check_server_prereqs(hostname, db, domain_ltd=None, env_code=None,
ssh_method="ssh_key"):
"""Verification complete des prerequis d'un serveur QuickWin.
Etapes:
1. Resolution DNS (domp/domr selon env)
2. Test SSH (PSMP ou key selon ssh_method)
3. Espace disque (sudo df)
4. Satellite/YUM
Retourne dict:
dns_ok, ssh_ok, disk_ok, satellite_ok,
fqdn, detail, skip (True si serveur a ignorer)
"""
result = {
"dns_ok": False, "ssh_ok": False, "disk_ok": False, "satellite_ok": False,
"fqdn": "", "detail": "", "skip": False,
}
detail_parts = []
# 1. Resolution DNS
fqdn, dns_err = _resolve_fqdn(hostname, domain_ltd, env_code)
if not fqdn:
result["detail"] = dns_err
result["skip"] = True
log.warning(f"[{hostname}] {dns_err}")
return result
result["dns_ok"] = True
result["fqdn"] = fqdn
detail_parts.append(f"DNS: OK ({fqdn})")
# 2. Test SSH
client, ssh_err = _ssh_connect(fqdn, ssh_method, db)
if not client:
detail_parts.append(f"SSH: KO ({ssh_err})")
result["detail"] = " | ".join(detail_parts)
result["skip"] = True
log.warning(f"[{hostname}] SSH KO: {ssh_err}")
return result
result["ssh_ok"] = True
method_label = "PSMP" if ssh_method == "ssh_psmp" else "key"
detail_parts.append(f"SSH: OK ({method_label})")
try:
# 3. Espace disque
disk_ok, disk_detail = _check_disk(client)
result["disk_ok"] = disk_ok
detail_parts.append(disk_detail)
# 4. Satellite
sat_ok, sat_detail = _check_satellite(client)
result["satellite_ok"] = sat_ok
detail_parts.append(sat_detail)
finally:
try:
client.close()
except Exception:
pass
result["detail"] = " | ".join(detail_parts)
return result

View File

@ -0,0 +1,788 @@
"""Service QuickWin — gestion des campagnes + exclusions par serveur"""
import json
from sqlalchemy import text
from .quickwin_log_service import log_info, log_warn, log_error, log_success
# Exclusions generales par defaut (reboot packages + middleware/apps)
DEFAULT_GENERAL_EXCLUDES = (
"dbus* dracut* glibc* grub2* kernel* kexec-tools* "
"libselinux* linux-firmware* microcode_ctl* mokutil* "
"net-snmp* NetworkManager* network-scripts* nss* openssl-libs* "
"polkit* selinux-policy* shim* systemd* tuned*"
)
def get_server_configs(db, server_ids=None):
"""Retourne les configs QuickWin pour les serveurs (ou tous)"""
if server_ids:
rows = db.execute(text("""
SELECT qc.*, s.hostname, s.os_family, s.tier,
d.name as domaine, e.name as environnement,
z.name as zone
FROM quickwin_server_config qc
JOIN servers s ON qc.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
LEFT JOIN zones z ON s.zone_id = z.id
WHERE qc.server_id = ANY(:ids)
ORDER BY s.hostname
"""), {"ids": server_ids}).fetchall()
else:
rows = db.execute(text("""
SELECT qc.*, s.hostname, s.os_family, s.tier,
d.name as domaine, e.name as environnement,
z.name as zone
FROM quickwin_server_config qc
JOIN servers s ON qc.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
LEFT JOIN zones z ON s.zone_id = z.id
ORDER BY s.hostname
""")).fetchall()
return rows
def upsert_server_config(db, server_id, general_excludes=None, specific_excludes="", notes=""):
"""Cree ou met a jour la config QuickWin d'un serveur.
Si general_excludes est vide lors de la creation, applique DEFAULT_GENERAL_EXCLUDES."""
existing = db.execute(text(
"SELECT id FROM quickwin_server_config WHERE server_id = :sid"
), {"sid": server_id}).fetchone()
if existing:
ge = general_excludes if general_excludes is not None else DEFAULT_GENERAL_EXCLUDES
db.execute(text("""
UPDATE quickwin_server_config
SET general_excludes = :ge, specific_excludes = :se, notes = :n, updated_at = now()
WHERE server_id = :sid
"""), {"sid": server_id, "ge": ge, "se": specific_excludes, "n": notes})
else:
ge = general_excludes if general_excludes else DEFAULT_GENERAL_EXCLUDES
db.execute(text("""
INSERT INTO quickwin_server_config (server_id, general_excludes, specific_excludes, notes)
VALUES (:sid, :ge, :se, :n)
"""), {"sid": server_id, "ge": ge, "se": specific_excludes, "n": notes})
db.commit()
def delete_server_config(db, config_id):
db.execute(text("DELETE FROM quickwin_server_config WHERE id = :id"), {"id": config_id})
db.commit()
def get_eligible_servers(db):
"""Serveurs Linux en_production, patch_os_owner=secops"""
return db.execute(text("""
SELECT s.id, s.hostname, s.os_family, s.os_version, s.machine_type,
s.tier, s.etat, s.patch_excludes, s.is_flux_libre, s.is_podman,
d.name as domaine, d.code as domain_code,
e.name as environnement, e.code as env_code,
COALESCE(qc.general_excludes, '') as qw_general_excludes,
COALESCE(qc.specific_excludes, '') as qw_specific_excludes
FROM servers s
LEFT JOIN domain_environments de ON s.domain_env_id = de.id
LEFT JOIN domains d ON de.domain_id = d.id
LEFT JOIN environments e ON de.environment_id = e.id
LEFT JOIN quickwin_server_config qc ON qc.server_id = s.id
WHERE s.os_family = 'linux'
AND s.etat = 'en_production'
AND s.patch_os_owner = 'secops'
ORDER BY e.display_order, d.display_order, s.hostname
""")).fetchall()
# -- Runs --
def list_runs(db):
return db.execute(text("""
SELECT r.*,
u.display_name as created_by_name,
(SELECT COUNT(*) FROM quickwin_entries e WHERE e.run_id = r.id) as total_entries,
(SELECT COUNT(*) FROM quickwin_entries e WHERE e.run_id = r.id AND e.status = 'patched') as patched_count,
(SELECT COUNT(*) FROM quickwin_entries e WHERE e.run_id = r.id AND e.status = 'failed') as failed_count,
(SELECT COUNT(*) FROM quickwin_entries e WHERE e.run_id = r.id AND e.branch = 'hprod') as hprod_count,
(SELECT COUNT(*) FROM quickwin_entries e WHERE e.run_id = r.id AND e.branch = 'prod') as prod_count
FROM quickwin_runs r
LEFT JOIN users u ON r.created_by = u.id
ORDER BY r.year DESC, r.week_number DESC, r.id DESC
""")).fetchall()
def get_run(db, run_id):
return db.execute(text("""
SELECT r.*, u.display_name as created_by_name
FROM quickwin_runs r LEFT JOIN users u ON r.created_by = u.id
WHERE r.id = :id
"""), {"id": run_id}).fetchone()
def get_run_entries(db, run_id):
return db.execute(text("""
SELECT qe.*, s.hostname, s.fqdn, s.os_family, s.machine_type,
d.name as domaine, e.name as environnement
FROM quickwin_entries qe
JOIN servers s ON qe.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 qe.run_id = :rid
ORDER BY qe.branch, s.hostname
"""), {"rid": run_id}).fetchall()
def create_run(db, year, week_number, label, user_id, server_ids, notes=""):
"""Cree un run QuickWin avec les serveurs selectionnes.
Classe auto en hprod/prod selon l'environnement du serveur."""
row = db.execute(text("""
INSERT INTO quickwin_runs (year, week_number, label, created_by, notes)
VALUES (:y, :w, :l, :uid, :n) RETURNING id
"""), {"y": year, "w": week_number, "l": label, "uid": user_id, "n": notes}).fetchone()
run_id = row.id
for sid in server_ids:
srv = db.execute(text("""
SELECT s.id, e.name as env_name,
COALESCE(qc.general_excludes, '') as ge,
COALESCE(qc.specific_excludes, '') as se
FROM servers s
LEFT JOIN domain_environments de ON s.domain_env_id = de.id
LEFT JOIN environments e ON de.environment_id = e.id
LEFT JOIN quickwin_server_config qc ON qc.server_id = s.id
WHERE s.id = :sid
"""), {"sid": sid}).fetchone()
if not srv:
continue
branch = "prod" if srv.env_name and "production" in srv.env_name.lower() else "hprod"
ge = srv.ge if srv.ge else DEFAULT_GENERAL_EXCLUDES
db.execute(text("""
INSERT INTO quickwin_entries (run_id, server_id, branch, general_excludes, specific_excludes)
VALUES (:rid, :sid, :br, :ge, :se)
"""), {"rid": run_id, "sid": sid, "br": branch, "ge": ge, "se": srv.se})
db.commit()
return run_id
def delete_run(db, run_id):
db.execute(text("DELETE FROM quickwin_entries WHERE run_id = :rid"), {"rid": run_id})
db.execute(text("DELETE FROM quickwin_runs WHERE id = :rid"), {"rid": run_id})
db.commit()
def get_available_servers(db, run_id, search="", domains=None, envs=None, zones=None):
"""Serveurs eligibles PAS encore dans ce run, avec multi-filtres.
domains/envs/zones: listes de noms (multi-select)."""
rows = db.execute(text("""
SELECT s.id, s.hostname, s.os_family, s.machine_type, s.domain_ltd,
d.name as domaine, e.name as environnement, e.code as env_code,
z.name as zone
FROM servers s
LEFT JOIN domain_environments de ON s.domain_env_id = de.id
LEFT JOIN domains d ON de.domain_id = d.id
LEFT JOIN environments e ON de.environment_id = e.id
LEFT JOIN zones z ON s.zone_id = z.id
WHERE s.os_family = 'linux'
AND s.etat = 'en_production'
AND s.patch_os_owner = 'secops'
AND s.id NOT IN (SELECT server_id FROM quickwin_entries WHERE run_id = :rid)
ORDER BY d.name, e.name, s.hostname
"""), {"rid": run_id}).fetchall()
filtered = rows
if search:
filtered = [r for r in filtered if search.lower() in r.hostname.lower()]
if domains:
filtered = [r for r in filtered if (r.domaine or '') in domains]
if envs:
filtered = [r for r in filtered if (r.environnement or '') in envs]
if zones:
filtered = [r for r in filtered if (r.zone or '') in zones]
return filtered
def get_available_filters(db, run_id):
"""Retourne les domaines, envs et zones disponibles (avec compteurs)."""
rows = db.execute(text("""
SELECT d.name as domaine, e.name as environnement, z.name as zone
FROM servers s
LEFT JOIN domain_environments de ON s.domain_env_id = de.id
LEFT JOIN domains d ON de.domain_id = d.id
LEFT JOIN environments e ON de.environment_id = e.id
LEFT JOIN zones z ON s.zone_id = z.id
WHERE s.os_family = 'linux'
AND s.etat = 'en_production'
AND s.patch_os_owner = 'secops'
AND s.id NOT IN (SELECT server_id FROM quickwin_entries WHERE run_id = :rid)
"""), {"rid": run_id}).fetchall()
domains = sorted(set(r.domaine for r in rows if r.domaine))
envs = sorted(set(r.environnement for r in rows if r.environnement))
zones = sorted(set(r.zone for r in rows if r.zone))
# Compteurs
dom_counts = {}
for r in rows:
k = r.domaine or '?'
dom_counts[k] = dom_counts.get(k, 0) + 1
env_counts = {}
for r in rows:
k = r.environnement or '?'
env_counts[k] = env_counts.get(k, 0) + 1
zone_counts = {}
for r in rows:
k = r.zone or '?'
zone_counts[k] = zone_counts.get(k, 0) + 1
return domains, envs, zones, dom_counts, env_counts, zone_counts
def add_entries_to_run(db, run_id, server_ids, user=None):
"""Ajoute des serveurs a un run existant. Determine auto hprod/prod.
Retourne le nombre de serveurs ajoutes."""
existing = set(r.server_id for r in db.execute(text(
"SELECT server_id FROM quickwin_entries WHERE run_id = :rid"
), {"rid": run_id}).fetchall())
by = user.get("display_name", user.get("username", "")) if user else ""
added = 0
hostnames = []
for sid in server_ids:
if sid in existing:
continue
srv = db.execute(text("""
SELECT s.id, s.hostname, e.name as env_name,
COALESCE(qc.general_excludes, '') as ge,
COALESCE(qc.specific_excludes, '') as se
FROM servers s
LEFT JOIN domain_environments de ON s.domain_env_id = de.id
LEFT JOIN environments e ON de.environment_id = e.id
LEFT JOIN quickwin_server_config qc ON qc.server_id = s.id
WHERE s.id = :sid
"""), {"sid": sid}).fetchone()
if not srv:
continue
branch = "prod" if srv.env_name and "production" in srv.env_name.lower() else "hprod"
ge = srv.ge if srv.ge else DEFAULT_GENERAL_EXCLUDES
db.execute(text("""
INSERT INTO quickwin_entries (run_id, server_id, branch, general_excludes, specific_excludes)
VALUES (:rid, :sid, :br, :ge, :se)
"""), {"rid": run_id, "sid": sid, "br": branch, "ge": ge, "se": srv.se})
added += 1
hostnames.append(srv.hostname)
if added:
log_info(db, run_id, "servers", f"{added} serveur(s) ajoute(s)",
detail=", ".join(hostnames[:20]) + ("..." if len(hostnames) > 20 else ""),
created_by=by)
db.commit()
return added
def remove_entries_from_run(db, run_id, entry_ids, user=None):
"""Supprime des entries d'un run. Retourne le nombre de suppressions."""
if not entry_ids:
return 0
by = user.get("display_name", user.get("username", "")) if user else ""
# Log hostnames avant suppression
rows = db.execute(text("""
SELECT s.hostname FROM quickwin_entries qe
JOIN servers s ON qe.server_id = s.id
WHERE qe.run_id = :rid AND qe.id = ANY(:ids)
"""), {"rid": run_id, "ids": entry_ids}).fetchall()
hostnames = [r.hostname for r in rows]
result = db.execute(text(
"DELETE FROM quickwin_entries WHERE run_id = :rid AND id = ANY(:ids)"
), {"rid": run_id, "ids": entry_ids})
removed = result.rowcount
if removed:
log_warn(db, run_id, "servers", f"{removed} serveur(s) supprime(s)",
detail=", ".join(hostnames[:20]) + ("..." if len(hostnames) > 20 else ""),
created_by=by)
db.commit()
return removed
def get_campaign_scope(db, run_id):
"""Retourne domaines, envs et zones presents dans le run avec compteurs."""
rows = db.execute(text("""
SELECT d.name as domaine, e.name as environnement, z.name as zone, qe.status
FROM quickwin_entries qe
JOIN servers s ON qe.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
LEFT JOIN zones z ON s.zone_id = z.id
WHERE qe.run_id = :rid
"""), {"rid": run_id}).fetchall()
dom_counts = {}
env_counts = {}
zone_counts = {}
dom_active = {} # non-excluded count
zone_active = {} # non-excluded count
for r in rows:
d = r.domaine or '?'
e = r.environnement or '?'
z = r.zone or '?'
dom_counts[d] = dom_counts.get(d, 0) + 1
env_counts[e] = env_counts.get(e, 0) + 1
zone_counts[z] = zone_counts.get(z, 0) + 1
if r.status != 'excluded':
dom_active[d] = dom_active.get(d, 0) + 1
zone_active[z] = zone_active.get(z, 0) + 1
domains = sorted(k for k in dom_counts if k != '?')
envs = sorted(k for k in env_counts if k != '?')
zones = sorted(k for k in zone_counts if k != '?')
return {
"domains": domains, "envs": envs, "zones": zones,
"dom_counts": dom_counts, "env_counts": env_counts, "zone_counts": zone_counts,
"dom_active": dom_active, "zone_active": zone_active,
}
def apply_scope(db, run_id, keep_domains=None, keep_zones=None, user=None):
"""Applique le perimetre: serveurs hors domaines/zones selectionnes -> excluded.
Serveurs dans le perimetre -> pending (si etaient excluded)."""
by = user.get("display_name", user.get("username", "")) if user else ""
# Recuperer toutes les entries avec leur domaine/zone
entries = db.execute(text("""
SELECT qe.id, qe.status, s.hostname, d.name as domaine, z.name as zone
FROM quickwin_entries qe
JOIN servers s ON qe.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 zones z ON s.zone_id = z.id
WHERE qe.run_id = :rid
"""), {"rid": run_id}).fetchall()
included = 0
excluded = 0
for e in entries:
in_scope = True
if keep_domains and (e.domaine or '') not in keep_domains:
in_scope = False
if keep_zones and (e.zone or '') not in keep_zones:
in_scope = False
if in_scope and e.status == 'excluded':
db.execute(text("UPDATE quickwin_entries SET status='pending', updated_at=now() WHERE id=:id"),
{"id": e.id})
included += 1
elif not in_scope and e.status != 'excluded':
db.execute(text("UPDATE quickwin_entries SET status='excluded', updated_at=now() WHERE id=:id"),
{"id": e.id})
excluded += 1
scope_desc = []
if keep_domains:
scope_desc.append(f"domaines: {', '.join(keep_domains)}")
if keep_zones:
scope_desc.append(f"zones: {', '.join(keep_zones)}")
log_info(db, run_id, "scope",
f"Perimetre applique: {included} inclus, {excluded} exclus",
detail=" | ".join(scope_desc) if scope_desc else "Tous",
created_by=by)
db.commit()
return included, excluded
def update_entry_status(db, entry_id, status, patch_output="", packages_count=0,
packages="", reboot_required=False, notes=""):
db.execute(text("""
UPDATE quickwin_entries SET
status = :st, patch_output = :po, patch_packages_count = :pc,
patch_packages = :pp, reboot_required = :rb, notes = :n,
patch_date = CASE WHEN :st IN ('patched','failed') THEN now() ELSE patch_date END,
updated_at = now()
WHERE id = :id
"""), {"id": entry_id, "st": status, "po": patch_output, "pc": packages_count,
"pp": packages, "rb": reboot_required, "n": notes})
db.commit()
def update_entry_field(db, entry_id, field, value):
"""Mise a jour d'un champ unique (pour inline edit)"""
allowed = ("general_excludes", "specific_excludes", "notes", "status",
"snap_done", "prereq_ok", "prereq_detail", "dryrun_output")
if field not in allowed:
return False
db.execute(text(f"UPDATE quickwin_entries SET {field} = :val, updated_at = now() WHERE id = :id"),
{"val": value, "id": entry_id})
db.commit()
return True
def can_start_prod(db, run_id):
"""Verifie que tous les hprod sont termines avant d'autoriser le prod"""
pending = db.execute(text("""
SELECT COUNT(*) as cnt FROM quickwin_entries
WHERE run_id = :rid AND branch = 'hprod' AND status IN ('pending', 'in_progress')
"""), {"rid": run_id}).fetchone()
return pending.cnt == 0
def get_run_stats(db, run_id):
return db.execute(text("""
SELECT
COUNT(*) FILTER (WHERE status != 'excluded') as total,
COUNT(*) FILTER (WHERE branch = 'hprod' AND status != 'excluded') as hprod_total,
COUNT(*) FILTER (WHERE branch = 'prod' AND status != 'excluded') as prod_total,
COUNT(*) FILTER (WHERE status = 'patched') as patched,
COUNT(*) FILTER (WHERE status = 'failed') as failed,
COUNT(*) FILTER (WHERE status = 'pending') as pending,
COUNT(*) FILTER (WHERE status = 'pending' AND branch = 'hprod') as hprod_pending,
COUNT(*) FILTER (WHERE status = 'excluded') as excluded,
COUNT(*) FILTER (WHERE status = 'skipped') as skipped,
COUNT(*) FILTER (WHERE branch = 'hprod' AND status = 'patched') as hprod_patched,
COUNT(*) FILTER (WHERE branch = 'prod' AND status = 'patched') as prod_patched,
COUNT(*) FILTER (WHERE reboot_required AND status != 'excluded') as reboot_count
FROM quickwin_entries WHERE run_id = :rid
"""), {"rid": run_id}).fetchone()
def advance_run_status(db, run_id, target_status, user=None):
"""Avance le statut du run vers l'etape suivante"""
VALID_TRANSITIONS = {
"draft": "prereq",
"prereq": "snapshot",
"snapshot": "patching",
"patching": "result",
"result": "completed",
}
run = db.execute(text("SELECT status FROM quickwin_runs WHERE id = :id"),
{"id": run_id}).fetchone()
if not run:
return False
by = user.get("display_name", user.get("username", "")) if user else ""
if target_status == "draft":
db.execute(text("UPDATE quickwin_runs SET status = :st, updated_at = now() WHERE id = :id"),
{"st": target_status, "id": run_id})
log_info(db, run_id, "workflow", f"Retour a l'etape brouillon", created_by=by)
db.commit()
return True
expected = VALID_TRANSITIONS.get(run.status)
if expected != target_status:
log_warn(db, run_id, "workflow",
f"Transition refusee: {run.status} -> {target_status}", created_by=by)
db.commit()
return False
db.execute(text("UPDATE quickwin_runs SET status = :st, updated_at = now() WHERE id = :id"),
{"st": target_status, "id": run_id})
log_info(db, run_id, "workflow", f"Passage a l'etape: {target_status}", created_by=by)
db.commit()
return True
def get_step_stats(db, run_id, branch=None):
"""Stats par etape pour un run (optionnel: filtre par branch)"""
where = "run_id = :rid"
params = {"rid": run_id}
if branch:
where += " AND branch = :br"
params["br"] = branch
return db.execute(text(f"""
SELECT
COUNT(*) as total,
COUNT(*) FILTER (WHERE status NOT IN ('excluded','skipped')) as active,
COUNT(*) FILTER (WHERE prereq_ok = true) as prereq_ok,
COUNT(*) FILTER (WHERE prereq_ok = false) as prereq_ko,
COUNT(*) FILTER (WHERE prereq_ok IS NULL AND status NOT IN ('excluded','skipped')) as prereq_pending,
COUNT(*) FILTER (WHERE snap_done = true) as snap_ok,
COUNT(*) FILTER (WHERE snap_done = false AND status NOT IN ('excluded','skipped')) as snap_pending,
COUNT(*) FILTER (WHERE status = 'patched') as patched,
COUNT(*) FILTER (WHERE status = 'failed') as failed,
COUNT(*) FILTER (WHERE status = 'pending') as pending,
COUNT(*) FILTER (WHERE reboot_required = true) as reboot_count
FROM quickwin_entries WHERE {where}
"""), params).fetchone()
def check_prereqs(db, run_id, branch, user=None):
"""Lance les verifications prerequis pour tous les serveurs d'une branche.
Etapes par serveur: DNS resolution, SSH, espace disque, satellite."""
from .quickwin_prereq_service import check_server_prereqs
entries = db.execute(text("""
SELECT qe.id, s.hostname, s.domain_ltd, s.ssh_method,
e.code as env_code
FROM quickwin_entries qe
JOIN servers s ON qe.server_id = s.id
LEFT JOIN domain_environments de ON s.domain_env_id = de.id
LEFT JOIN environments e ON de.environment_id = e.id
WHERE qe.run_id = :rid AND qe.branch = :br
AND qe.status NOT IN ('excluded','skipped')
ORDER BY s.hostname
"""), {"rid": run_id, "br": branch}).fetchall()
by = user.get("display_name", user.get("username", "")) if user else ""
log_info(db, run_id, "prereq",
f"Lancement check prerequis {branch} ({len(entries)} serveurs)", created_by=by)
results = []
ok_count = 0
for e in entries:
try:
r = check_server_prereqs(
hostname=e.hostname,
db=db,
domain_ltd=e.domain_ltd,
env_code=e.env_code,
ssh_method=e.ssh_method or "ssh_key",
)
except Exception as ex:
r = {"dns_ok": False, "ssh_ok": False, "satellite_ok": False,
"disk_ok": False, "detail": str(ex), "skip": True}
prereq_ok = (r.get("dns_ok", False) and r.get("ssh_ok", False)
and r.get("satellite_ok", False) and r.get("disk_ok", True))
db.execute(text("""
UPDATE quickwin_entries SET
prereq_ok = :ok, prereq_ssh = :ssh, prereq_satellite = :sat, prereq_disk = :disk,
prereq_detail = :detail, prereq_date = now(), updated_at = now()
WHERE id = :id
"""), {
"id": e.id, "ok": prereq_ok,
"ssh": r.get("ssh_ok", False), "sat": r.get("satellite_ok", False),
"disk": r.get("disk_ok", True), "detail": r.get("detail", ""),
})
# Log par serveur
detail_str = r.get("detail", "")
if prereq_ok:
ok_count += 1
log_success(db, run_id, "prereq", f"OK: {e.hostname}",
detail=detail_str, entry_id=e.id, hostname=e.hostname)
else:
log_error(db, run_id, "prereq", f"KO: {e.hostname}",
detail=detail_str, entry_id=e.id, hostname=e.hostname)
results.append({"hostname": e.hostname, "ok": prereq_ok, "detail": detail_str})
ko_count = len(results) - ok_count
log_info(db, run_id, "prereq",
f"Fin check {branch}: {ok_count} OK, {ko_count} KO sur {len(results)}", created_by=by)
db.commit()
return results
def mark_snapshot(db, entry_id, done=True):
"""Marque un serveur comme snapshot fait/pas fait"""
db.execute(text("""
UPDATE quickwin_entries SET snap_done = :done,
snap_date = CASE WHEN :done THEN now() ELSE NULL END,
updated_at = now() WHERE id = :id
"""), {"id": entry_id, "done": done})
db.commit()
def mark_all_snapshots(db, run_id, branch, done=True):
"""Marque tous les serveurs d'une branche comme snapshot fait"""
db.execute(text("""
UPDATE quickwin_entries SET snap_done = :done,
snap_date = CASE WHEN :done THEN now() ELSE NULL END,
updated_at = now()
WHERE run_id = :rid AND branch = :br AND status NOT IN ('excluded','skipped')
"""), {"rid": run_id, "br": branch, "done": done})
db.commit()
def build_yum_commands(db, run_id, branch):
"""Construit les commandes yum pour chaque serveur d'une branche.
Inclut tous les serveurs actifs (non excluded/skipped) avec snap fait."""
entries = db.execute(text("""
SELECT qe.id, s.hostname, qe.general_excludes, qe.specific_excludes
FROM quickwin_entries qe
JOIN servers s ON qe.server_id = s.id
WHERE qe.run_id = :rid AND qe.branch = :br
AND qe.status NOT IN ('excluded','skipped')
AND qe.snap_done = true
ORDER BY s.hostname
"""), {"rid": run_id, "br": branch}).fetchall()
commands = []
for e in entries:
excludes = (e.general_excludes or "") + " " + (e.specific_excludes or "")
parts = [p.strip() for p in excludes.split() if p.strip()]
exclude_args = " ".join(f"--exclude={p}" for p in parts)
cmd = f"yum update -y {exclude_args}".strip()
db.execute(text("UPDATE quickwin_entries SET patch_command = :cmd WHERE id = :id"),
{"cmd": cmd, "id": e.id})
commands.append({"id": e.id, "hostname": e.hostname, "command": cmd})
db.commit()
return commands
def inject_yum_history(db, data):
"""Injecte l'historique yum dans quickwin_server_config.
data = [{"server": "hostname", "yum_commands": [...]}]"""
updated = 0
inserted = 0
for item in data:
hostname = item.get("server", item.get("server_name", "")).strip()
if not hostname:
continue
srv = db.execute(text("SELECT id FROM servers WHERE hostname = :h"), {"h": hostname}).fetchone()
if not srv:
continue
cmds = json.dumps(item.get("yum_commands", item.get("last_yum_commands", [])), ensure_ascii=False)
existing = db.execute(text(
"SELECT id FROM quickwin_server_config WHERE server_id = :sid"
), {"sid": srv.id}).fetchone()
if existing:
db.execute(text("""
UPDATE quickwin_server_config SET last_yum_commands = :cmds::jsonb, updated_at = now()
WHERE server_id = :sid
"""), {"sid": srv.id, "cmds": cmds})
updated += 1
else:
db.execute(text("""
INSERT INTO quickwin_server_config (server_id, last_yum_commands)
VALUES (:sid, :cmds::jsonb)
"""), {"sid": srv.id, "cmds": cmds})
inserted += 1
db.commit()
return updated, inserted
# ========== CORRESPONDANCE HPROD ↔ PROD ==========
def compute_correspondance(db, run_id, user=None):
"""Auto-apparie chaque serveur hprod avec son homologue prod (2e lettre → p).
Retourne (matched, unmatched, anomalies)."""
by = user.get("display_name", user.get("username", "")) if user else ""
hprod_rows = db.execute(text("""
SELECT qe.id, LOWER(s.hostname) as hostname
FROM quickwin_entries qe JOIN servers s ON qe.server_id = s.id
WHERE qe.run_id = :rid AND qe.branch = 'hprod' AND qe.status != 'excluded'
"""), {"rid": run_id}).fetchall()
prod_rows = db.execute(text("""
SELECT qe.id, LOWER(s.hostname) as hostname
FROM quickwin_entries qe JOIN servers s ON qe.server_id = s.id
WHERE qe.run_id = :rid AND qe.branch = 'prod' AND qe.status != 'excluded'
"""), {"rid": run_id}).fetchall()
prod_by_host = {r.hostname: r.id for r in prod_rows}
matched = 0
unmatched = 0
anomalies = 0
skipped = 0
# Existing pairs — ne pas toucher
existing = {r.id for r in db.execute(text("""
SELECT id FROM quickwin_entries
WHERE run_id = :rid AND branch = 'hprod' AND prod_pair_entry_id IS NOT NULL
"""), {"rid": run_id}).fetchall()}
for h in hprod_rows:
if h.id in existing:
skipped += 1
continue
if len(h.hostname) < 2:
unmatched += 1
continue
candidate = h.hostname[0] + 'p' + h.hostname[2:]
if candidate == h.hostname:
anomalies += 1
if candidate in prod_by_host:
db.execute(text("""
UPDATE quickwin_entries SET prod_pair_entry_id = :pid WHERE id = :hid
"""), {"pid": prod_by_host[candidate], "hid": h.id})
matched += 1
else:
unmatched += 1
log_info(db, run_id, "correspondance",
f"Auto-appariement: {matched} nouveaux, {skipped} conservés, {unmatched} sans homologue, {anomalies} anomalies",
created_by=by)
db.commit()
return matched, unmatched, anomalies
def get_correspondance(db, run_id, search=None, pair_filter=None, env_filter=None, domain_filter=None):
"""Retourne la liste des hprod avec leur homologue prod (ou NULL)."""
rows = db.execute(text("""
SELECT hp.id as hprod_id, sh.hostname as hprod_hostname,
dh.name as hprod_domaine, eh.name as hprod_env,
SUBSTRING(LOWER(sh.hostname), 2, 1) as letter2,
hp.prod_pair_entry_id,
pp.id as prod_id, sp.hostname as prod_hostname,
dp.name as prod_domaine
FROM quickwin_entries hp
JOIN servers sh ON hp.server_id = sh.id
LEFT JOIN domain_environments deh ON sh.domain_env_id = deh.id
LEFT JOIN domains dh ON deh.domain_id = dh.id
LEFT JOIN environments eh ON deh.environment_id = eh.id
LEFT JOIN quickwin_entries pp ON hp.prod_pair_entry_id = pp.id
LEFT JOIN servers sp ON pp.server_id = sp.id
LEFT JOIN domain_environments dep ON sp.domain_env_id = dep.id
LEFT JOIN domains dp ON dep.domain_id = dp.id
WHERE hp.run_id = :rid AND hp.branch = 'hprod' AND hp.status != 'excluded'
ORDER BY sh.hostname
"""), {"rid": run_id}).fetchall()
result = []
for r in rows:
candidate = ""
if len(r.hprod_hostname) >= 2:
candidate = r.hprod_hostname[0] + 'p' + r.hprod_hostname[2:]
is_anomaly = (r.letter2 == 'p')
is_matched = r.prod_pair_entry_id is not None
if pair_filter == "matched" and not is_matched:
continue
if pair_filter == "unmatched" and is_matched:
continue
if pair_filter == "anomaly" and not is_anomaly:
continue
if env_filter:
env_map = {"preprod": "i", "recette": "r", "dev": "d", "test": "vt"}
allowed_letters = env_map.get(env_filter, "")
if r.letter2 not in allowed_letters:
continue
if domain_filter and (r.hprod_domaine or '') != domain_filter:
continue
if search and search.lower() not in r.hprod_hostname.lower():
if not (r.prod_hostname and search.lower() in r.prod_hostname.lower()):
continue
result.append({
"hprod_id": r.hprod_id,
"hprod_hostname": r.hprod_hostname,
"hprod_domaine": r.hprod_domaine or "",
"hprod_env": r.hprod_env or "",
"letter2": r.letter2,
"candidate": candidate,
"is_anomaly": is_anomaly,
"prod_id": r.prod_id,
"prod_hostname": r.prod_hostname or "",
"prod_domaine": r.prod_domaine or "",
"is_matched": is_matched,
})
return result
def get_available_prod_entries(db, run_id):
"""Retourne toutes les entries prod (un prod peut etre apparie a plusieurs hprod)."""
return db.execute(text("""
SELECT qe.id, s.hostname, d.name as domaine
FROM quickwin_entries qe
JOIN servers s ON qe.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
WHERE qe.run_id = :rid AND qe.branch = 'prod' AND qe.status != 'excluded'
ORDER BY s.hostname
"""), {"rid": run_id}).fetchall()
def set_prod_pair(db, hprod_entry_id, prod_entry_id):
"""Associe manuellement un hprod à un prod (ou NULL pour dissocier)."""
pid = prod_entry_id if prod_entry_id else None
db.execute(text("""
UPDATE quickwin_entries SET prod_pair_entry_id = :pid, updated_at = now() WHERE id = :hid
"""), {"pid": pid, "hid": hprod_entry_id})
db.commit()
def clear_all_pairs(db, run_id):
"""Supprime tous les appariements d'un run."""
db.execute(text("""
UPDATE quickwin_entries SET prod_pair_entry_id = NULL, updated_at = now()
WHERE run_id = :rid AND branch = 'hprod'
"""), {"rid": run_id})
db.commit()

View File

@ -0,0 +1,144 @@
"""Service snapshot QuickWin — prise de snapshots VM via vSphere/pyvmomi
Ordre de recherche des VM sur les vCenters:
- Hors-prod: Senlis (vpgesavcs1) Nanterre (vpmetavcs1) DR (vpsicavcs1)
- Prod: Nanterre (vpmetavcs1) Senlis (vpgesavcs1) DR (vpsicavcs1)
Physiques: pas de snapshot, alerte Commvault."""
import ssl
import logging
from datetime import datetime
log = logging.getLogger("quickwin.snapshot")
try:
from pyVim.connect import SmartConnect, Disconnect
from pyVmomi import vim
PYVMOMI_OK = True
except ImportError:
PYVMOMI_OK = False
log.warning("pyvmomi non disponible — snapshots impossibles")
def _get_secret(db, key):
try:
from ..services.secrets_service import get_secret
return get_secret(db, key)
except Exception:
return None
def _connect_vcenter(endpoint, user, password):
"""Connexion a un vCenter. Retourne un ServiceInstance ou None."""
try:
ctx = ssl.SSLContext(ssl.PROTOCOL_TLS_CLIENT)
ctx.check_hostname = False
ctx.verify_mode = ssl.CERT_NONE
si = SmartConnect(host=endpoint, user=user, pwd=password, sslContext=ctx)
return si
except Exception as e:
log.warning(f"Connexion vCenter {endpoint} echouee: {e}")
return None
def _find_vm(si, vm_name):
"""Cherche une VM par nom dans le vCenter. Retourne l'objet VM ou None."""
content = si.RetrieveContent()
container = content.viewManager.CreateContainerView(
content.rootFolder, [vim.VirtualMachine], True)
try:
for vm in container.view:
if vm.name.lower() == vm_name.lower():
return vm
finally:
container.Destroy()
return None
def _take_snapshot(vm, snap_name, description=""):
"""Prend un snapshot de la VM. Retourne (ok, message)."""
try:
task = vm.CreateSnapshot_Task(
name=snap_name,
description=description,
memory=False,
quiesce=True,
)
# Attendre la fin du task
while task.info.state in (vim.TaskInfo.State.queued, vim.TaskInfo.State.running):
import time
time.sleep(2)
if task.info.state == vim.TaskInfo.State.success:
return True, "Snapshot OK"
else:
err = str(task.info.error) if task.info.error else "Echec inconnu"
return False, f"Snapshot echoue: {err}"
except Exception as e:
return False, f"Erreur snapshot: {e}"
def get_vcenter_order(db, branch):
"""Retourne la liste ordonnee des vCenters selon la branche.
hprod: Senlis Nanterre DR
prod: Nanterre Senlis DR"""
from sqlalchemy import text
vcenters = db.execute(text(
"SELECT id, name, endpoint FROM vcenters WHERE is_active = true ORDER BY id"
)).fetchall()
vc_map = {}
for vc in vcenters:
ep = vc.endpoint.lower()
if "vpgesavcs1" in ep:
vc_map["senlis"] = vc
elif "vpmetavcs1" in ep:
vc_map["nanterre"] = vc
elif "vpsicavcs1" in ep:
vc_map["dr"] = vc
else:
vc_map.setdefault("other", []).append(vc)
if branch == "prod":
order = [vc_map.get("nanterre"), vc_map.get("senlis"), vc_map.get("dr")]
else:
order = [vc_map.get("senlis"), vc_map.get("nanterre"), vc_map.get("dr")]
return [v for v in order if v is not None]
def snapshot_server(hostname, vm_name, branch, db, snap_name=None):
"""Prend un snapshot pour un serveur.
Cherche la VM sur les vCenters dans l'ordre selon la branche.
Retourne dict: {ok, vcenter, detail, skipped}"""
if not PYVMOMI_OK:
return {"ok": False, "vcenter": "", "detail": "pyvmomi non installe", "skipped": True}
vc_user = _get_secret(db, "vcenter_user")
vc_pass = _get_secret(db, "vcenter_pass")
if not vc_user or not vc_pass:
return {"ok": False, "vcenter": "", "detail": "Credentials vCenter manquants (vcenter_user/vcenter_pass dans Settings > Secrets)", "skipped": True}
search_name = vm_name or hostname
if not snap_name:
snap_name = f"QW_{datetime.now().strftime('%Y%m%d_%H%M')}"
vcenters = get_vcenter_order(db, branch)
if not vcenters:
return {"ok": False, "vcenter": "", "detail": "Aucun vCenter actif configure", "skipped": True}
for vc in vcenters:
si = _connect_vcenter(vc.endpoint, vc_user, vc_pass)
if not si:
continue
try:
vm = _find_vm(si, search_name)
if vm:
ok, msg = _take_snapshot(vm, snap_name,
description=f"QuickWin auto-snapshot {hostname}")
return {"ok": ok, "vcenter": vc.name, "detail": msg}
finally:
try:
Disconnect(si)
except Exception:
pass
tried = ", ".join(vc.name for vc in vcenters)
return {"ok": False, "vcenter": "", "detail": f"VM '{search_name}' non trouvee sur: {tried}"}

View File

@ -119,6 +119,11 @@ def list_servers(db, filters, page=1, per_page=50, sort="hostname", sort_dir="as
where.append("s.licence_support = 'eol'")
else:
where.append("s.etat = :etat"); params["etat"] = filters["etat"]
where.append("COALESCE(s.licence_support, '') != 'eol'")
if filters.get("os"):
where.append("s.os_family = :os"); params["os"] = filters["os"]
if filters.get("owner"):
where.append("s.patch_os_owner = :owner"); params["owner"] = filters["owner"]
if filters.get("search"):
where.append("s.hostname ILIKE :search"); params["search"] = f"%{filters['search']}%"
@ -203,7 +208,7 @@ def update_server(db, server_id, data, username):
params = {"id": server_id}
direct_fields = ["tier", "etat", "patch_os_owner", "responsable_nom",
"referent_nom", "mode_operatoire", "commentaire", "ssh_method",
"pref_patch_jour", "pref_patch_heure"]
"domain_ltd", "pref_patch_jour", "pref_patch_heure"]
changed = []
for field in direct_fields:
if data.get(field) is not None:

View File

@ -13,6 +13,8 @@
<a href="/audit-full/patching?year={{ year }}" class="btn-sm {% if not scope %}bg-cyber-accent text-black{% else %}bg-cyber-border text-gray-400{% endif %} px-3 py-1">Tous</a>
<a href="/audit-full/patching?year={{ year }}&scope=secops" class="btn-sm {% if scope == 'secops' %}bg-cyber-green text-black{% else %}bg-cyber-border text-gray-400{% endif %} px-3 py-1">SecOps</a>
<a href="/audit-full/patching?year={{ year }}&scope=other" class="btn-sm {% if scope == 'other' %}bg-cyber-yellow text-black{% else %}bg-cyber-border text-gray-400{% endif %} px-3 py-1">Hors SecOps</a>
<span class="text-gray-600 mx-1">|</span>
<a href="/audit-full/patching/export-csv?year={{ year }}{% if scope %}&scope={{ scope }}{% endif %}{% if search %}&q={{ search }}{% endif %}{% if domain %}&domain={{ domain }}{% endif %}" class="btn-sm bg-cyber-green text-black px-3 py-1">CSV</a>
</div>
</div>
@ -187,7 +189,7 @@
<tr class="hover:bg-cyber-hover cursor-pointer" onclick="location.href='/audit-full/{{ s.id }}'">
<td class="p-2 font-mono text-cyber-accent font-bold">{{ s.hostname }}</td>
<td class="p-2 text-center text-gray-400">{{ s.domain or '-' }}</td>
<td class="p-2 text-center"><span class="badge {% if s.env == 'Production' %}badge-green{% elif s.env == 'Recette' %}badge-yellow{% else %}badge-gray{% endif %}"title="{{ s.env or '' }}">{{ (s.env or '-')[:6] }}</span></td>
<td class="p-2 text-center"><span class="badge {% if s.env == 'Production' %}badge-green{% elif s.env == 'Recette' %}badge-yellow{% else %}badge-gray{% endif %}">{{ (s.env or '-')[:6] }}</span></td>
<td class="p-2 text-center">{% if s.zone == 'DMZ' %}<span class="badge badge-red">DMZ</span>{% else %}{{ s.zone or '-' }}{% endif %}</td>
<td class="p-2 text-center font-bold {% if (s.patch_count or 0) >= 2 %}text-cyber-green{% elif (s.patch_count or 0) == 1 %}text-green-300{% else %}text-cyber-red{% endif %}">{{ s.patch_count or 0 }}</td>
<td class="p-2 font-mono text-gray-400">{% if s.patch_weeks %}{% for w in s.patch_weeks.split(',') %}<span class="inline-block px-1 rounded text-xs {% if w == 'S15' %}bg-green-900/30 text-cyber-green{% else %}bg-cyber-border text-gray-400{% endif %} mr-1">{{ w }}</span>{% endfor %}{% else %}-{% endif %}</td>

View File

@ -51,7 +51,7 @@
</div>
<nav class="flex-1 p-3 space-y-1">
{% set p = perms if perms is defined else request.state.perms %}
<a href="/dashboard" class="block px-3 py-2 rounded-md text-sm hover:bg-cyber-border/30 {% if 'dashboard' in request.url.path %}bg-cyber-border/30 text-cyber-accent{% else %}text-gray-400{% endif %}">Dashboard</a>
{% if p.servers or p.qualys or p.audit or p.planning %}<a href="/dashboard" class="block px-3 py-2 rounded-md text-sm hover:bg-cyber-border/30 {% if 'dashboard' in request.url.path %}bg-cyber-border/30 text-cyber-accent{% else %}text-gray-400{% endif %}">Dashboard</a>{% endif %}
{% if p.servers %}<a href="/servers" class="block px-3 py-2 rounded-md text-sm hover:bg-cyber-border/30 {% if request.url.path == '/servers' or '/servers/' in request.url.path %}bg-cyber-border/30 text-cyber-accent{% else %}text-gray-400{% endif %}">Serveurs</a>{% endif %}
{% if p.specifics %}<a href="/specifics" class="block px-3 py-2 rounded-md text-sm hover:bg-cyber-border/30 {% if 'specifics' in request.url.path %}bg-cyber-border/30 text-cyber-accent{% else %}text-gray-400{% endif %} pl-6 text-xs">Specifiques</a>{% endif %}
{% if p.campaigns %}<a href="/campaigns" class="block px-3 py-2 rounded-md text-sm hover:bg-cyber-border/30 {% if 'campaigns' in request.url.path and 'assignments' not in request.url.path %}bg-cyber-border/30 text-cyber-accent{% else %}text-gray-400{% endif %}">Campagnes</a>{% endif %}
@ -61,6 +61,9 @@
{% if p.qualys %}<a href="/qualys/decoder" class="block px-3 py-2 rounded-md text-sm hover:bg-cyber-border/30 {% if 'decoder' in request.url.path %}bg-cyber-border/30 text-cyber-accent{% else %}text-gray-400{% endif %} pl-6 text-xs">Décodeur</a>{% endif %}
{% if p.qualys %}<a href="/qualys/agents" class="block px-3 py-2 rounded-md text-sm hover:bg-cyber-border/30 {% if 'agents' in request.url.path %}bg-cyber-border/30 text-cyber-accent{% else %}text-gray-400{% endif %} pl-6 text-xs">Agents</a>{% endif %}
{% if p.campaigns %}<a href="/safe-patching" class="block px-3 py-2 rounded-md text-sm hover:bg-cyber-border/30 {% if 'safe-patching' in request.url.path %}bg-cyber-border/30 text-cyber-accent{% else %}text-gray-400{% endif %}">Safe Patching</a>{% endif %}
{% if p.campaigns or p.quickwin %}<a href="/quickwin" class="block px-3 py-2 rounded-md text-sm hover:bg-cyber-border/30 {% if 'quickwin' in request.url.path and 'safe' not in request.url.path and 'config' not in request.url.path and 'correspondance' not in request.url.path %}bg-cyber-border/30 text-cyber-accent{% else %}text-gray-400{% endif %}">QuickWin</a>{% endif %}
{% if p.campaigns or p.quickwin %}<a href="/quickwin/config" class="block px-3 py-2 rounded-md text-sm hover:bg-cyber-border/30 {% if '/quickwin/config' in request.url.path %}bg-cyber-border/30 text-cyber-accent{% else %}text-gray-400{% endif %} pl-6 text-xs">Config exclusions</a>{% endif %}
{% if p.campaigns or p.quickwin %}<a href="/quickwin/correspondance" class="block px-3 py-2 rounded-md text-sm hover:bg-cyber-border/30 {% if 'correspondance' in request.url.path %}bg-cyber-border/30 text-cyber-accent{% else %}text-gray-400{% endif %} pl-6 text-xs">Correspondance</a>{% endif %}
{% if p.planning %}<a href="/planning" class="block px-3 py-2 rounded-md text-sm hover:bg-cyber-border/30 {% if 'planning' in request.url.path %}bg-cyber-border/30 text-cyber-accent{% else %}text-gray-400{% endif %}">Planning</a>{% endif %}
{% if p.audit %}<a href="/audit" class="block px-3 py-2 rounded-md text-sm hover:bg-cyber-border/30 {% if request.url.path == '/audit' %}bg-cyber-border/30 text-cyber-accent{% else %}text-gray-400{% endif %}">Audit</a>{% endif %}
{% if p.audit in ('edit', 'admin') %}<a href="/audit/specific" class="block px-3 py-2 rounded-md text-sm hover:bg-cyber-border/30 {% if 'specific' in request.url.path %}bg-cyber-border/30 text-cyber-accent{% else %}text-gray-400{% endif %} pl-6 text-xs">Spécifique</a>{% endif %}
@ -69,6 +72,7 @@
{% if p.servers %}<a href="/contacts" class="block px-3 py-2 rounded-md text-sm hover:bg-cyber-border/30 {% if 'contacts' in request.url.path %}bg-cyber-border/30 text-cyber-accent{% else %}text-gray-400{% endif %}">Contacts</a>{% endif %}
{% if p.users %}<a href="/users" class="block px-3 py-2 rounded-md text-sm hover:bg-cyber-border/30 {% if 'users' in request.url.path %}bg-cyber-border/30 text-cyber-accent{% else %}text-gray-400{% endif %}">Utilisateurs</a>{% endif %}
{% if p.settings %}<a href="/settings" class="block px-3 py-2 rounded-md text-sm hover:bg-cyber-border/30 {% if 'settings' in request.url.path %}bg-cyber-border/30 text-cyber-accent{% else %}text-gray-400{% endif %}">Settings</a>{% endif %}
{% if p.settings %}<a href="/referentiel" class="block px-3 py-2 rounded-md text-sm hover:bg-cyber-border/30 {% if 'referentiel' in request.url.path %}bg-cyber-border/30 text-cyber-accent{% else %}text-gray-400{% endif %} pl-6 text-xs">R&eacute;f&eacute;rentiel</a>{% endif %}
</nav>
</aside>
<main class="flex-1 flex flex-col overflow-hidden">

View File

@ -36,10 +36,17 @@
{% for e in envs %}<option value="{{ e.code }}" {% if e.name == s.environnement %}selected{% endif %}>{{ e.name }}</option>{% endfor %}
</select>
</div>
<div>
<label class="text-xs text-gray-500">Domaine DNS (domain_ltd)</label>
<select name="domain_ltd" class="w-full">
<option value="">-- Aucun --</option>
{% for d in dns_list %}<option value="{{ d }}" {% if d == s.domain_ltd %}selected{% endif %}>{{ d }}</option>{% endfor %}
</select>
</div>
<div>
<label class="text-xs text-gray-500">Zone</label>
<select name="zone" class="w-full">
{% for z in ['LAN','DMZ','EMV'] %}<option value="{{ z }}" {% if z == s.zone %}selected{% endif %}>{{ z }}</option>{% endfor %}
{% for z in zones_list %}<option value="{{ z }}" {% if z == s.zone %}selected{% endif %}>{{ z }}</option>{% endfor %}
</select>
</div>
<div>
@ -70,18 +77,18 @@
</div>
<div class="grid grid-cols-2 gap-2">
<div>
<label class="text-xs text-gray-500">Jour préféré patching</label>
<label class="text-xs text-gray-500">Jour pr&eacute;f&eacute;r&eacute; patching</label>
<select name="pref_patch_jour" class="w-full">
{% for j in ['indifferent','lundi','mardi','mercredi','jeudi'] %}<option value="{{ j }}" {% if j == s.pref_patch_jour %}selected{% endif %}>{{ j }}</option>{% endfor %}
</select>
</div>
<div>
<label class="text-xs text-gray-500">Heure préférée</label>
<input type="text" name="pref_patch_heure" value="{{ s.pref_patch_heure or '' }}" placeholder="ex: 14h00, tôt le matin" class="w-full">
<label class="text-xs text-gray-500">Heure pr&eacute;f&eacute;r&eacute;e</label>
<input type="text" name="pref_patch_heure" value="{{ s.pref_patch_heure or '' }}" placeholder="ex: 14h00, t&ocirc;t le matin" class="w-full">
</div>
</div>
<div>
<label class="text-xs text-gray-500">Mode opératoire</label>
<label class="text-xs text-gray-500">Mode op&eacute;ratoire</label>
<textarea name="mode_operatoire" rows="3" class="w-full">{{ s.mode_operatoire or '' }}</textarea>
</div>
<div>

View File

@ -85,8 +85,35 @@
<!-- Serveurs sans agent Qualys -->
{% if no_agent_servers %}
<div class="card p-4 mb-4">
<h3 class="text-sm font-bold text-cyber-red mb-3">Serveurs en production sans agent Qualys ({{ no_agent_servers|length }})</h3>
<div class="card p-4 mb-4" x-data="{fHost:'', fOs:'', fDom:'', fEnv:'', fEtat:''}">
<div class="flex justify-between items-center mb-3">
<h3 class="text-sm font-bold text-cyber-red">Serveurs sans agent Qualys ({{ no_agent_servers|length }})</h3>
<a href="/qualys/agents/export-no-agent" class="btn-sm bg-cyber-green text-black px-3 py-1 text-xs">Exporter CSV</a>
</div>
<div class="flex gap-2 mb-3">
<input type="text" x-model="fHost" placeholder="Hostname..." class="text-xs py-1 px-2 flex-1 font-mono">
<select x-model="fOs" class="text-xs py-1 px-2">
<option value="">OS</option>
<option value="linux">Linux</option>
<option value="windows">Windows</option>
</select>
<select x-model="fDom" class="text-xs py-1 px-2">
<option value="">Domaine</option>
{% set doms = no_agent_servers|map(attribute='domain')|unique|sort %}
{% for d in doms %}{% if d %}<option value="{{ d }}">{{ d }}</option>{% endif %}{% endfor %}
</select>
<select x-model="fEnv" class="text-xs py-1 px-2">
<option value="">Env</option>
{% set envs = no_agent_servers|map(attribute='env')|unique|sort %}
{% for e in envs %}{% if e %}<option value="{{ e }}">{{ e }}</option>{% endif %}{% endfor %}
</select>
<select x-model="fEtat" class="text-xs py-1 px-2">
<option value="">État</option>
{% set etats = no_agent_servers|map(attribute='etat')|unique|sort %}
{% for e in etats %}{% if e %}<option value="{{ e }}">{{ e }}</option>{% endif %}{% endfor %}
</select>
<button @click="fHost='';fOs='';fDom='';fEnv='';fEtat=''" class="text-xs text-gray-400 hover:text-cyber-accent">Reset</button>
</div>
<table class="w-full table-cyber text-xs">
<thead><tr>
<th class="text-left p-2">Hostname</th>
@ -94,15 +121,23 @@
<th class="p-2">Domaine</th>
<th class="p-2">Env</th>
<th class="p-2">Zone</th>
<th class="p-2">État</th>
</tr></thead>
<tbody>
<tbody id="noagent-body">
{% for s in no_agent_servers %}
<tr>
<tr x-show="
(fHost === '' || '{{ s.hostname }}'.toLowerCase().includes(fHost.toLowerCase()))
&& (fOs === '' || '{{ s.os_family or '' }}'.toLowerCase() === fOs.toLowerCase())
&& (fDom === '' || '{{ s.domain or '' }}' === fDom)
&& (fEnv === '' || '{{ s.env or '' }}' === fEnv)
&& (fEtat === '' || '{{ s.etat or '' }}' === fEtat)
">
<td class="p-2 font-mono text-cyber-accent">{{ s.hostname }}</td>
<td class="p-2 text-center">{{ s.os_family or '-' }}</td>
<td class="p-2 text-center text-gray-400">{{ s.domain or '-' }}</td>
<td class="p-2 text-center">{{ s.env or '-' }}</td>
<td class="p-2 text-center">{% if s.zone == 'DMZ' %}<span class="badge badge-red">DMZ</span>{% else %}{{ s.zone or '-' }}{% endif %}</td>
<td class="p-2 text-center" title="{{ s.etat or '' }}"><span class="badge {% if s.etat == 'en_production' %}badge-green{% elif s.etat == 'decommissionne' %}badge-red{% elif s.etat == 'eteint' %}badge-gray{% else %}badge-yellow{% endif %}">{{ (s.etat or '-')[:8] }}</span></td>
</tr>
{% endfor %}
</tbody>
@ -113,7 +148,10 @@
<!-- Agents inactifs -->
{% if inactive_agents %}
<div id="inactive-list" class="card p-4 mb-4">
<h3 class="text-sm font-bold text-cyber-red mb-3">* Agents inactifs ({{ inactive_agents|length }})</h3>
<div class="flex justify-between items-center mb-3">
<h3 class="text-sm font-bold text-cyber-red">* Agents inactifs ({{ inactive_agents|length }})</h3>
<a href="/qualys/agents/export-inactive" class="btn-sm bg-cyber-green text-black px-3 py-1 text-xs">Exporter CSV</a>
</div>
<div class="card p-3 mb-3 text-xs text-gray-400" style="background:#111827;">
<b>* Légende :</b> Ces serveurs ont un agent Qualys installé mais qui ne communique plus avec le cloud Qualys.
Causes possibles : serveur éteint, flux réseau bloqué (port 443 vers qualysagent.qualys.eu), agent crashé, ou OS non supporté (RHEL 5 EOL).

136
app/templates/quickwin.html Normal file
View File

@ -0,0 +1,136 @@
{% extends "base.html" %}
{% block title %}QuickWin{% endblock %}
{% block content %}
<div class="flex items-center justify-between mb-6">
<div>
<h1 class="text-2xl font-bold" style="color:#00d4ff">QuickWin</h1>
<p class="text-sm text-gray-500">Campagnes patching rapide &mdash; exclusions par serveur &mdash; hors-prod d'abord &mdash; pas de reboot n&eacute;cessaire</p>
</div>
<div class="flex gap-2">
{% if can_create %}
<a href="/quickwin/config" class="btn-sm" style="background:#1e3a5f;color:#00d4ff;padding:6px 16px">Config exclusions</a>
<button onclick="document.getElementById('createModal').style.display='flex'" class="btn-primary" style="padding:6px 16px;font-size:0.85rem">+ Nouveau QuickWin</button>
{% endif %}
</div>
</div>
{% if msg %}
<div style="background:#1a5a2e;color:#8f8;padding:8px 16px;border-radius:6px;margin-bottom:12px;font-size:0.85rem">
{% if msg == 'deleted' %}Campagne supprim&eacute;e{% elif msg == 'error' %}Erreur cr&eacute;ation{% elif msg == 'no_servers' %}Aucun serveur configur&eacute;{% else %}{{ msg }}{% endif %}
</div>
{% endif %}
<!-- KPIs -->
<div class="grid grid-cols-4 gap-4 mb-6">
<div class="card p-4 text-center">
<div class="text-3xl font-bold" style="color:#00d4ff">{{ runs|length }}</div>
<div class="text-xs text-gray-500">Campagnes</div>
</div>
<div class="card p-4 text-center">
<div class="text-3xl font-bold" style="color:#00ff88">{{ config_count }}</div>
<div class="text-xs text-gray-500">Serveurs configur&eacute;s</div>
</div>
<div class="card p-4 text-center">
<div class="text-3xl font-bold" style="color:#ffcc00">S{{ current_week }}</div>
<div class="text-xs text-gray-500">Semaine courante</div>
</div>
<div class="card p-4 text-center">
<div class="text-3xl font-bold" style="color:#fff">{{ current_year }}</div>
<div class="text-xs text-gray-500">Ann&eacute;e</div>
</div>
</div>
<!-- Runs list -->
<div class="card">
<div class="p-4 flex items-center justify-between" style="border-bottom:1px solid #1e3a5f">
<h2 class="text-sm font-bold" style="color:#00d4ff">CAMPAGNES QUICKWIN</h2>
<span class="text-xs text-gray-500">{{ runs|length }} campagne(s)</span>
</div>
<div class="table-wrap">
<table class="table-cyber w-full">
<thead>
<tr>
<th class="px-3 py-2">ID</th>
<th class="px-3 py-2">Semaine</th>
<th class="px-3 py-2">Label</th>
<th class="px-3 py-2">Statut</th>
<th class="px-3 py-2">Cr&eacute;&eacute; par</th>
<th class="px-3 py-2">Serveurs</th>
<th class="px-3 py-2">H-Prod</th>
<th class="px-3 py-2">Prod</th>
<th class="px-3 py-2">Patch&eacute;s</th>
<th class="px-3 py-2">KO</th>
<th class="px-3 py-2">Date</th>
<th class="px-3 py-2">Actions</th>
</tr>
</thead>
<tbody>
{% for r in runs %}
<tr onclick="window.location='/quickwin/{{ r.id }}'" style="cursor:pointer">
<td class="px-3 py-2" style="color:#00d4ff;font-weight:bold">#{{ r.id }}</td>
<td class="px-3 py-2">S{{ '%02d'|format(r.week_number) }} {{ r.year }}</td>
<td class="px-3 py-2">{{ r.label }}</td>
<td class="px-3 py-2">
{% if r.status == 'draft' %}<span class="badge badge-gray">Brouillon</span>
{% elif r.status == 'hprod_in_progress' %}<span class="badge badge-yellow">H-Prod en cours</span>
{% elif r.status == 'hprod_done' %}<span class="badge badge-blue">H-Prod termin&eacute;</span>
{% elif r.status == 'prod_in_progress' %}<span class="badge badge-yellow">Prod en cours</span>
{% elif r.status == 'completed' %}<span class="badge badge-green">Termin&eacute;</span>
{% elif r.status == 'cancelled' %}<span class="badge badge-red">Annul&eacute;</span>
{% endif %}
</td>
<td class="px-3 py-2 text-gray-400">{{ r.created_by_name or '?' }}</td>
<td class="px-3 py-2 text-center">{{ r.total_entries }}</td>
<td class="px-3 py-2 text-center">{{ r.hprod_count }}</td>
<td class="px-3 py-2 text-center">{{ r.prod_count }}</td>
<td class="px-3 py-2 text-center" style="color:#00ff88">{{ r.patched_count }}</td>
<td class="px-3 py-2 text-center" style="color:#ff3366">{{ r.failed_count }}</td>
<td class="px-3 py-2 text-gray-500 text-xs">{{ r.created_at.strftime('%d/%m %H:%M') if r.created_at else '' }}</td>
<td class="px-3 py-2">
<a href="/quickwin/{{ r.id }}" class="btn-sm" style="background:#1e3a5f;color:#00d4ff">Voir</a>
</td>
</tr>
{% endfor %}
{% if not runs %}
<tr><td colspan="12" class="px-3 py-8 text-center text-gray-500">Aucune campagne QuickWin</td></tr>
{% endif %}
</tbody>
</table>
</div>
</div>
<!-- Create modal -->
<div id="createModal" style="display:none;position:fixed;top:0;left:0;right:0;bottom:0;background:rgba(0,0,0,.7);z-index:100;align-items:center;justify-content:center" onclick="if(event.target===this)this.style.display='none'">
<div class="card" style="width:500px;max-width:90vw;padding:24px">
<h3 style="color:#00d4ff;font-size:1.1rem;font-weight:bold;margin-bottom:16px">Nouveau QuickWin</h3>
<form method="post" action="/quickwin/create">
<div class="mb-3">
<label class="text-xs text-gray-400 block mb-1">Label</label>
<input type="text" name="label" placeholder="Quick Win S{{ '%02d'|format(current_week) }} {{ current_year }}" style="width:100%">
</div>
<div class="flex gap-3 mb-3">
<div class="flex-1">
<label class="text-xs text-gray-400 block mb-1">Semaine</label>
<input type="number" name="week_number" value="{{ current_week }}" min="1" max="53" style="width:100%">
</div>
<div class="flex-1">
<label class="text-xs text-gray-400 block mb-1">Ann&eacute;e</label>
<input type="number" name="year" value="{{ current_year }}" style="width:100%">
</div>
</div>
<div class="mb-3">
<label class="text-xs text-gray-400 block mb-1">Serveurs (IDs, vide = tous les configur&eacute;s)</label>
<input type="text" name="server_ids" placeholder="Laisser vide pour tous les serveurs configur&eacute;s" style="width:100%">
</div>
<div class="mb-3">
<label class="text-xs text-gray-400 block mb-1">Notes</label>
<textarea name="notes" rows="2" style="width:100%" placeholder="Commentaires..."></textarea>
</div>
<div class="flex gap-2 justify-end mt-4">
<button type="button" class="btn-sm" style="background:#333;color:#ccc;padding:6px 16px" onclick="document.getElementById('createModal').style.display='none'">Annuler</button>
<button type="submit" class="btn-primary" style="padding:6px 20px">Cr&eacute;er</button>
</div>
</form>
</div>
</div>
{% endblock %}

View File

@ -0,0 +1,206 @@
{% extends "base.html" %}
{% block title %}QuickWin Config{% endblock %}
{% macro qs(p) -%}
?page={{ p }}&per_page={{ per_page }}&search={{ filters.search or '' }}&env={{ filters.env or '' }}&domain={{ filters.domain or '' }}&zone={{ filters.zone or '' }}
{%- endmacro %}
{% block content %}
<div class="flex items-center justify-between mb-6">
<div>
<a href="/quickwin" class="text-xs text-gray-500 hover:text-gray-300">&larr; Retour QuickWin</a>
<h1 class="text-xl font-bold" style="color:#00d4ff">Exclusions par serveur</h1>
<p class="text-xs text-gray-500">Tous les serveurs Linux en_production / secops &mdash; exclusions g&eacute;n&eacute;rales par d&eacute;faut pr&eacute;-remplies &mdash; pas de reboot n&eacute;cessaire</p>
</div>
<div class="flex gap-2 items-center">
<span class="text-sm text-gray-400">{{ total_count }} serveur(s)</span>
<button onclick="document.getElementById('bulkModal').style.display='flex'" class="btn-primary" style="padding:6px 16px;font-size:0.85rem">Modifier en masse</button>
</div>
</div>
{% if msg %}
<div style="background:#1a5a2e;color:#8f8;padding:8px 16px;border-radius:6px;margin-bottom:12px;font-size:0.85rem">
{% if 'saved' in msg %}Configuration sauvegard&eacute;e{% elif 'deleted' in msg %}Exclusions sp&eacute;cifiques retir&eacute;es{% elif 'added' in msg %}{{ msg.split('_')[1] }} serveur(s) mis &agrave; jour{% elif 'bulk' in msg %}Mise &agrave; jour group&eacute;e OK{% else %}{{ msg }}{% endif %}
</div>
{% endif %}
<!-- Filtre -->
<form method="GET" class="card mb-4" style="padding:10px 16px;display:flex;gap:12px;align-items:center">
<input type="text" name="search" value="{{ filters.search or '' }}" placeholder="Recherche serveur..." style="width:200px">
<select name="env" onchange="this.form.submit()" style="width:140px">
<option value="">Tous env.</option>
{% set envs = all_configs|map(attribute='environnement')|select('string')|unique|sort %}
{% for e in envs %}<option value="{{ e }}" {% if filters.env == e %}selected{% endif %}>{{ e }}</option>{% endfor %}
</select>
<select name="domain" onchange="this.form.submit()" style="width:140px">
<option value="">Tous domaines</option>
{% set doms = all_configs|map(attribute='domaine')|select('string')|unique|sort %}
{% for d in doms %}<option value="{{ d }}" {% if filters.domain == d %}selected{% endif %}>{{ d }}</option>{% endfor %}
</select>
<select name="zone" onchange="this.form.submit()" style="width:100px">
<option value="">Zone</option>
{% set zones = all_configs|map(attribute='zone')|select('string')|unique|sort %}
{% for z in zones %}<option value="{{ z }}" {% if filters.zone == z %}selected{% endif %}>{{ z }}</option>{% endfor %}
</select>
<select name="per_page" onchange="this.form.submit()" style="width:140px">
<option value="">Affichage / page</option>
{% for n in [14, 25, 50, 100] %}<option value="{{ n }}" {% if per_page == n %}selected{% endif %}>{{ n }} par page</option>{% endfor %}
</select>
<button type="submit" class="btn-primary" style="padding:4px 14px;font-size:0.8rem">Filtrer</button>
<a href="/quickwin/config" class="text-xs text-gray-500 hover:text-gray-300">Reset</a>
<span class="text-xs text-gray-500">{{ total_count }} serveur(s)</span>
</form>
<!-- Cartouche detail serveur -->
<div id="srvDetail" class="card mb-4" style="display:none;border-left:3px solid #00d4ff;padding:12px 16px">
<div class="flex items-center justify-between mb-2">
<h3 style="color:#00d4ff;font-weight:bold;font-size:0.95rem" id="detailName"></h3>
<button onclick="document.getElementById('srvDetail').style.display='none'" class="text-gray-500 hover:text-gray-300" style="font-size:1.2rem">&times;</button>
</div>
<div class="grid grid-cols-2 gap-4">
<div>
<div class="text-xs text-gray-500 mb-1 font-bold">Exclusions g&eacute;n&eacute;rales (OS / reboot)</div>
<pre id="detailGeneral" style="font-size:0.7rem;color:#ffcc00;white-space:pre-wrap;margin:0"></pre>
</div>
<div>
<div class="text-xs text-gray-500 mb-1 font-bold">Exclusions sp&eacute;cifiques (applicatifs &mdash; hors p&eacute;rim&egrave;tre secops)</div>
<pre id="detailSpecific" style="font-size:0.7rem;color:#ff8800;white-space:pre-wrap;margin:0"></pre>
</div>
</div>
</div>
<!-- Tableau serveurs -->
<div class="card">
<div class="table-wrap">
<table class="table-cyber w-full" id="srvTable">
<thead><tr>
<th class="px-2 py-2" style="width:30px"><input type="checkbox" id="checkAll" onchange="toggleAll(this)"></th>
<th class="px-2 py-2">Serveur</th>
<th class="px-2 py-2">Domaine</th>
<th class="px-2 py-2">Env</th>
<th class="px-2 py-2">Zone</th>
<th class="px-2 py-2">Tier</th>
<th class="px-2 py-2">Exclusions g&eacute;n&eacute;rales</th>
<th class="px-2 py-2">Exclusions sp&eacute;cifiques</th>
<th class="px-2 py-2">Notes</th>
<th class="px-2 py-2" style="width:60px">Save</th>
<th class="px-2 py-2" style="width:60px">Cmd</th>
</tr></thead>
<tbody>
{% for s in all_servers %}
<tr>
<td class="px-2 py-2"><input type="checkbox" class="srv-check" value="{{ s.server_id }}"></td>
<td class="px-2 py-2 font-bold" style="color:#00d4ff;cursor:pointer" onclick="showDetail('{{ s.hostname }}', this)">{{ s.hostname }}</td>
<td class="px-2 py-2 text-gray-400 text-xs">{{ s.domaine or '?' }}</td>
<td class="px-2 py-2 text-gray-400 text-xs">{{ s.environnement or '?' }}</td>
<td class="px-2 py-2 text-center"><span class="badge {% if s.zone == 'DMZ' %}badge-red{% else %}badge-blue{% endif %}">{{ s.zone or 'LAN' }}</span></td>
<td class="px-2 py-2 text-gray-400 text-xs">{{ s.tier }}</td>
<td class="px-2 py-2">
<form method="post" action="/quickwin/config/save" class="inline-form" style="display:flex;gap:4px;align-items:center">
<input type="hidden" name="server_id" value="{{ s.server_id }}">
<input type="text" name="general_excludes" value="{{ s.general_excludes }}"
style="width:200px;font-size:0.7rem;padding:2px 6px" title="{{ s.general_excludes }}">
</td>
<td class="px-2 py-2">
<input type="text" name="specific_excludes" value="{{ s.specific_excludes }}"
style="width:150px;font-size:0.7rem;padding:2px 6px" placeholder="sdcss* custom*...">
</td>
<td class="px-2 py-2">
<input type="text" name="notes" value="{{ s.notes }}"
style="width:80px;font-size:0.7rem;padding:2px 6px" placeholder="...">
<button type="submit" class="btn-sm" style="background:#1e3a5f;color:#00d4ff;font-size:0.65rem">OK</button>
</form>
</td>
<td class="px-2 py-2">
<button type="button" class="btn-sm" style="background:#1a3a1a;color:#00ff88;font-size:0.6rem;white-space:nowrap" onclick="showDryRun('{{ s.hostname }}', this)">Dry Run</button>
</td>
</tr>
{% endfor %}
{% if not all_servers %}<tr><td colspan="11" class="px-2 py-8 text-center text-gray-500">Aucun serveur trouv&eacute;</td></tr>{% endif %}
</tbody>
</table>
</div>
</div>
<!-- Pagination -->
<div class="flex justify-between items-center mt-4 text-sm text-gray-500">
<span>Page {{ page }} / {{ total_pages }} &mdash; {{ total_count }} serveurs</span>
<div class="flex gap-2">
{% if page > 1 %}<a href="{{ qs(page - 1) }}" class="btn-sm bg-cyber-border text-gray-300">Pr&eacute;c&eacute;dent</a>{% endif %}
{% if page < total_pages %}<a href="{{ qs(page + 1) }}" class="btn-sm bg-cyber-border text-gray-300">Suivant</a>{% endif %}
</div>
</div>
<!-- Bulk modal -->
<div id="bulkModal" style="display:none;position:fixed;top:0;left:0;right:0;bottom:0;background:rgba(0,0,0,.7);z-index:100;align-items:center;justify-content:center" onclick="if(event.target===this)this.style.display='none'">
<div class="card" style="width:550px;max-width:90vw;padding:24px">
<h3 style="color:#00d4ff;font-weight:bold;margin-bottom:12px">Modification group&eacute;e</h3>
<p class="text-xs text-gray-500 mb-3">Cochez les serveurs dans le tableau, puis appliquez les exclusions.</p>
<form method="post" action="/quickwin/config/bulk-add">
<input type="hidden" name="server_ids" id="bulkIds">
<div class="mb-3">
<label class="text-xs text-gray-400 block mb-1">Exclusions g&eacute;n&eacute;rales</label>
<textarea name="general_excludes" rows="3" style="width:100%;font-size:0.75rem">{{ default_excludes }}</textarea>
</div>
<div class="flex gap-2 justify-end">
<button type="button" class="btn-sm" style="background:#333;color:#ccc;padding:6px 16px" onclick="document.getElementById('bulkModal').style.display='none'">Annuler</button>
<button type="submit" class="btn-primary" style="padding:6px 20px" onclick="collectIds()">Appliquer</button>
</div>
</form>
</div>
</div>
<!-- Dry Run modal -->
<div id="dryRunModal" style="display:none;position:fixed;top:0;left:0;right:0;bottom:0;background:rgba(0,0,0,.7);z-index:100;align-items:center;justify-content:center" onclick="if(event.target===this)this.style.display='none'">
<div class="card" style="width:700px;max-width:90vw;padding:24px">
<div class="flex items-center justify-between mb-3">
<h3 style="color:#00ff88;font-weight:bold" id="dryRunTitle">Dry Run</h3>
<button id="copyBtn" onclick="copyDryRun()" class="btn-sm" style="background:#1e3a5f;color:#00d4ff;font-size:0.75rem;padding:4px 12px">Copier</button>
</div>
<pre id="dryRunCmd" style="background:#0a0e17;border:1px solid #1e3a5f;border-radius:6px;padding:12px;font-size:0.75rem;color:#00ff88;white-space:pre-wrap;word-break:break-all;max-height:400px;overflow-y:auto"></pre>
<div class="flex justify-end mt-3">
<button type="button" class="btn-sm" style="background:#333;color:#ccc;padding:6px 16px" onclick="document.getElementById('dryRunModal').style.display='none'">Fermer</button>
</div>
</div>
</div>
<script>
function showDetail(hostname, td) {
const tr = td.closest('tr');
const ge = tr.querySelector('input[name=general_excludes]').value.trim();
const se = tr.querySelector('input[name=specific_excludes]').value.trim();
document.getElementById('detailName').textContent = hostname;
document.getElementById('detailGeneral').textContent = ge ? ge.split(/\s+/).join('\n') : '(aucune)';
document.getElementById('detailSpecific').textContent = se ? se.split(/\s+/).join('\n') : '(aucune)';
const panel = document.getElementById('srvDetail');
panel.style.display = 'block';
panel.scrollIntoView({behavior: 'smooth', block: 'nearest'});
}
function showDryRun(hostname, btn) {
const tr = btn.closest('tr');
const ge = tr.querySelector('input[name=general_excludes]').value.trim();
const se = tr.querySelector('input[name=specific_excludes]').value.trim();
const all = (ge + ' ' + se).trim().split(/\s+/).filter(x => x);
const excludes = all.map(e => '--exclude=' + e).join(' \\\n ');
const cmd = 'yum update -y \\\n ' + excludes;
document.getElementById('dryRunTitle').textContent = 'Dry Run — ' + hostname;
document.getElementById('dryRunCmd').textContent = cmd;
document.getElementById('dryRunModal').style.display = 'flex';
}
function copyDryRun() {
const text = document.getElementById('dryRunCmd').textContent;
navigator.clipboard.writeText(text).then(() => {
const btn = document.getElementById('copyBtn');
btn.textContent = 'Copi\u00e9 !';
setTimeout(() => btn.textContent = 'Copier', 1500);
});
}
function toggleAll(cb) {
document.querySelectorAll('.srv-check').forEach(c => c.checked = cb.checked);
}
function collectIds() {
const ids = Array.from(document.querySelectorAll('.srv-check:checked')).map(c => c.value);
document.getElementById('bulkIds').value = ids.join(',');
}
</script>
{% endblock %}

View File

@ -0,0 +1,258 @@
{% extends "base.html" %}
{% block title %}Correspondance QuickWin #{{ run.id }}{% endblock %}
{% macro qs(pg=page) -%}
?page={{ pg }}&per_page={{ per_page }}&search={{ filters.search or '' }}&pair_filter={{ filters.pair_filter or '' }}&domain_filter={{ filters.domain_filter or '' }}&env_filter={{ filters.env_filter or '' }}
{%- endmacro %}
{% block content %}
<div class="flex items-center justify-between mb-4">
<div>
<a href="/quickwin/{{ run.id }}" class="text-xs text-gray-500 hover:text-gray-300">&larr; Retour campagne</a>
<h1 class="text-xl font-bold" style="color:#a78bfa">Correspondance H-Prod &harr; Prod</h1>
<p class="text-xs text-gray-500">{{ run.label }} &mdash; Appariement des serveurs hors-production avec leur homologue production</p>
</div>
<div class="flex gap-2 items-center">
<form method="post" action="/quickwin/{{ run.id }}/correspondance/auto">
<button class="btn-primary" style="padding:5px 16px;font-size:0.85rem">Auto-apparier</button>
</form>
<form method="post" action="/quickwin/{{ run.id }}/correspondance/clear-all"
onsubmit="return confirm('Supprimer tous les appariements ?')">
<button class="btn-sm btn-danger" style="padding:4px 12px">Tout effacer</button>
</form>
</div>
</div>
{% if msg %}
{% if msg == 'auto' %}
{% set am = request.query_params.get('am', '0') %}
{% set au = request.query_params.get('au', '0') %}
{% set aa = request.query_params.get('aa', '0') %}
<div style="background:#1a5a2e;color:#8f8;padding:8px 16px;border-radius:6px;margin-bottom:12px;font-size:0.85rem">
Auto-appariement termin&eacute; : {{ am }} appari&eacute;(s), {{ au }} sans homologue, {{ aa }} anomalie(s)
</div>
{% elif msg == 'cleared' %}
<div style="background:#5a3a1a;color:#ffcc00;padding:8px 16px;border-radius:6px;margin-bottom:12px;font-size:0.85rem">
Tous les appariements ont &eacute;t&eacute; supprim&eacute;s.
</div>
{% elif msg == 'bulk' %}
{% set bc = request.query_params.get('bc', '0') %}
<div style="background:#1a5a2e;color:#8f8;padding:8px 16px;border-radius:6px;margin-bottom:12px;font-size:0.85rem">
{{ bc }} appariement(s) modifi&eacute;(s) en masse.
</div>
{% endif %}
{% endif %}
<!-- KPIs -->
<div style="display:grid;grid-template-columns:repeat(4,1fr);gap:12px;margin-bottom:16px">
<div class="card p-3 text-center">
<div class="text-2xl font-bold" style="color:#fff">{{ stats.total }}</div>
<div class="text-xs text-gray-500">Total H-Prod</div>
</div>
<div class="card p-3 text-center">
<div class="text-2xl font-bold" style="color:#00ff88">{{ stats.matched }}</div>
<div class="text-xs text-gray-500">Appari&eacute;s</div>
</div>
<div class="card p-3 text-center">
<div class="text-2xl font-bold" style="color:#ffcc00">{{ stats.unmatched }}</div>
<div class="text-xs text-gray-500">Sans homologue</div>
</div>
<div class="card p-3 text-center">
<div class="text-2xl font-bold" style="color:#ff3366">{{ stats.anomalies }}</div>
<div class="text-xs text-gray-500">Anomalies</div>
</div>
</div>
<!-- Filtres -->
<form method="GET" class="card mb-4" style="padding:10px 16px;display:flex;gap:12px;align-items:center;flex-wrap:wrap">
<input type="text" name="search" value="{{ filters.search or '' }}" placeholder="Recherche hostname..." style="width:200px">
<select name="pair_filter" onchange="this.form.submit()" style="width:160px">
<option value="">Tous</option>
<option value="matched" {% if filters.pair_filter == 'matched' %}selected{% endif %}>Appari&eacute;s</option>
<option value="unmatched" {% if filters.pair_filter == 'unmatched' %}selected{% endif %}>Sans homologue</option>
<option value="anomaly" {% if filters.pair_filter == 'anomaly' %}selected{% endif %}>Anomalies</option>
</select>
<select name="domain_filter" onchange="this.form.submit()" style="width:150px">
<option value="">Tous domaines</option>
{% for d in domains_in_run %}
<option value="{{ d }}" {% if filters.domain_filter == d %}selected{% endif %}>{{ d }}</option>
{% endfor %}
</select>
<select name="env_filter" onchange="this.form.submit()" style="width:140px">
<option value="">Tous envs</option>
<option value="preprod" {% if filters.env_filter == 'preprod' %}selected{% endif %}>Pr&eacute;-Prod</option>
<option value="recette" {% if filters.env_filter == 'recette' %}selected{% endif %}>Recette</option>
<option value="dev" {% if filters.env_filter == 'dev' %}selected{% endif %}>D&eacute;veloppement</option>
<option value="test" {% if filters.env_filter == 'test' %}selected{% endif %}>Test</option>
</select>
<select name="per_page" onchange="this.form.submit()" style="width:70px">
{% for n in [20,50,100,200] %}
<option value="{{ n }}" {% if per_page == n %}selected{% endif %}>{{ n }}</option>
{% endfor %}
</select>
<button type="submit" class="btn-primary" style="padding:4px 14px;font-size:0.8rem">Filtrer</button>
<a href="/quickwin/{{ run.id }}/correspondance" class="text-xs text-gray-500 hover:text-gray-300">Reset</a>
<span class="text-xs text-gray-500" style="margin-left:auto">{{ total_filtered }} r&eacute;sultat(s)</span>
</form>
<!-- Actions en masse -->
<div class="card mb-3" style="padding:8px 16px;display:flex;gap:10px;align-items:center">
<span class="text-xs text-gray-400"><span id="sel-count">0</span> s&eacute;lectionn&eacute;(s)</span>
<button class="btn-sm" style="background:#ff336622;color:#ff3366;padding:3px 12px" onclick="bulkClear()">Dissocier la s&eacute;lection</button>
<span style="color:#1e3a5f">|</span>
<span class="text-xs text-gray-400">Associer la s&eacute;lection &agrave; :</span>
<select id="bulk-prod" style="width:220px;font-size:0.8rem;padding:3px 6px">
<option value="">-- Serveur prod --</option>
{% for a in available %}
<option value="{{ a.id }}">{{ a.hostname }}{% if a.domaine %} ({{ a.domaine }}){% endif %}</option>
{% endfor %}
</select>
<button class="btn-sm" style="background:#00ff8822;color:#00ff88;padding:3px 12px" onclick="bulkAssign()">Associer</button>
</div>
<!-- Table -->
<div class="card">
<div class="table-wrap" style="max-height:65vh;overflow-y:auto">
<table class="table-cyber w-full">
<thead style="position:sticky;top:0;z-index:1"><tr>
<th class="px-1 py-2" style="width:28px"><input type="checkbox" id="check-all" title="Tout"></th>
<th class="px-2 py-2" style="width:160px">Serveur H-Prod</th>
<th class="px-2 py-2" style="width:100px">Domaine</th>
<th class="px-2 py-2" style="width:90px">Env</th>
<th class="px-2 py-2" style="width:160px">Candidat auto</th>
<th class="px-2 py-2" style="width:50px">Statut</th>
<th class="px-2 py-2">Serveur Prod appari&eacute;</th>
<th class="px-2 py-2" style="width:100px">Domaine Prod</th>
<th class="px-2 py-2" style="width:80px">Action</th>
</tr></thead>
<tbody>
{% for p in pairs %}
<tr id="row-{{ p.hprod_id }}" style="{% if p.is_anomaly %}background:#ff336610{% elif not p.is_matched %}background:#ffcc0008{% endif %}">
<td class="px-1 py-2"><input type="checkbox" class="row-check" value="{{ p.hprod_id }}"></td>
<td class="px-2 py-2 font-bold" style="color:#00d4ff">{{ p.hprod_hostname }}</td>
<td class="px-2 py-2 text-xs text-gray-400">{{ p.hprod_domaine }}</td>
<td class="px-2 py-2 text-xs">
{% if p.is_anomaly %}<span class="badge badge-red" title="Lettre 'p' mais class&eacute; hprod">{{ p.hprod_env or '?' }}</span>
{% else %}<span class="text-gray-400">{{ p.hprod_env }}</span>{% endif %}
</td>
<td class="px-2 py-2 text-xs text-gray-500">{{ p.candidate }}</td>
<td class="px-2 py-2 text-center">
{% if p.is_matched %}<span class="badge badge-green">OK</span>
{% elif p.is_anomaly %}<span class="badge badge-red">!</span>
{% else %}<span class="badge badge-yellow">--</span>{% endif %}
</td>
<td class="px-2 py-2">
{% if p.is_matched %}
<span style="color:#00ff88;font-weight:600">{{ p.prod_hostname }}</span>
{% else %}
<select class="prod-select" data-hprod="{{ p.hprod_id }}" style="width:100%;font-size:0.8rem;padding:3px 6px">
<option value="">-- Choisir serveur prod --</option>
{% for a in available %}
<option value="{{ a.id }}">{{ a.hostname }}{% if a.domaine %} ({{ a.domaine }}){% endif %}</option>
{% endfor %}
</select>
{% endif %}
</td>
<td class="px-2 py-2 text-xs text-gray-400">
{% if p.is_matched %}{{ p.prod_domaine }}{% endif %}
</td>
<td class="px-2 py-2 text-center">
{% if p.is_matched %}
<button class="btn-sm" style="background:#ff336622;color:#ff3366;padding:2px 8px"
onclick="clearPair({{ p.hprod_id }})">X</button>
{% else %}
<button class="btn-sm" style="background:#00ff8822;color:#00ff88;padding:2px 8px"
onclick="setPairFromSelect({{ p.hprod_id }})">OK</button>
{% endif %}
</td>
</tr>
{% endfor %}
{% if not pairs %}
<tr><td colspan="9" class="px-2 py-8 text-center text-gray-500">Aucun r&eacute;sultat{% if filters.search or filters.pair_filter %} pour ces filtres{% endif %}</td></tr>
{% endif %}
</tbody>
</table>
</div>
</div>
<!-- Pagination -->
{% if total_pages > 1 %}
<div style="display:flex;justify-content:center;gap:6px;margin-top:12px">
{% if page > 1 %}
<a href="/quickwin/{{ run.id }}/correspondance{{ qs(page - 1) }}" class="btn-sm" style="background:#1e3a5f;color:#94a3b8;padding:4px 10px">&larr;</a>
{% endif %}
{% for pg in range(1, total_pages + 1) %}
{% if pg == page %}
<span class="btn-sm" style="background:#00d4ff;color:#0a0e17;padding:4px 10px;font-weight:bold">{{ pg }}</span>
{% elif pg <= 3 or pg >= total_pages - 1 or (pg >= page - 1 and pg <= page + 1) %}
<a href="/quickwin/{{ run.id }}/correspondance{{ qs(pg) }}" class="btn-sm" style="background:#1e3a5f;color:#94a3b8;padding:4px 10px">{{ pg }}</a>
{% elif pg == 4 or pg == total_pages - 2 %}
<span class="text-gray-500" style="padding:4px 4px">&hellip;</span>
{% endif %}
{% endfor %}
{% if page < total_pages %}
<a href="/quickwin/{{ run.id }}/correspondance{{ qs(page + 1) }}" class="btn-sm" style="background:#1e3a5f;color:#94a3b8;padding:4px 10px">&rarr;</a>
{% endif %}
</div>
{% endif %}
<script>
/* ---- Select all / count ---- */
const checkAll = document.getElementById('check-all');
if (checkAll) {
checkAll.addEventListener('change', function() {
document.querySelectorAll('.row-check').forEach(cb => cb.checked = this.checked);
updateSelCount();
});
}
document.querySelectorAll('.row-check').forEach(cb => cb.addEventListener('change', updateSelCount));
function updateSelCount() {
document.getElementById('sel-count').textContent = document.querySelectorAll('.row-check:checked').length;
}
/* ---- Single actions ---- */
function setPairFromSelect(hprodId) {
const sel = document.querySelector('select[data-hprod="' + hprodId + '"]');
if (!sel) return;
const prodId = parseInt(sel.value);
if (!prodId) { alert('Choisissez un serveur prod'); return; }
apiSetPair(hprodId, prodId).then(() => location.reload());
}
function clearPair(hprodId) {
if (!confirm('Dissocier cet appariement ?')) return;
apiSetPair(hprodId, 0).then(() => location.reload());
}
/* ---- Bulk actions ---- */
function getSelected() {
return [...document.querySelectorAll('.row-check:checked')].map(cb => parseInt(cb.value));
}
function bulkClear() {
const ids = getSelected();
if (!ids.length) { alert('Aucune ligne sélectionnée'); return; }
if (!confirm('Dissocier ' + ids.length + ' appariement(s) ?')) return;
Promise.all(ids.map(id => apiSetPair(id, 0))).then(() => {
location.href = '/quickwin/{{ run.id }}/correspondance?msg=bulk&bc=' + ids.length;
});
}
function bulkAssign() {
const ids = getSelected();
if (!ids.length) { alert('Aucune ligne sélectionnée'); return; }
const prodId = parseInt(document.getElementById('bulk-prod').value);
if (!prodId) { alert('Choisissez un serveur prod'); return; }
if (!confirm('Associer ' + ids.length + ' serveur(s) au même prod ?')) return;
Promise.all(ids.map(id => apiSetPair(id, prodId))).then(() => {
location.href = '/quickwin/{{ run.id }}/correspondance?msg=bulk&bc=' + ids.length;
});
}
/* ---- API call ---- */
function apiSetPair(hprodId, prodId) {
return fetch('/api/quickwin/correspondance/set-pair', {
method: 'POST',
headers: {'Content-Type': 'application/json'},
body: JSON.stringify({hprod_id: hprodId, prod_id: prodId})
}).then(r => r.json());
}
</script>
{% endblock %}

View File

@ -0,0 +1,890 @@
{% extends "base.html" %}
{% block title %}QuickWin #{{ run.id }}{% endblock %}
{% set STEPS = [
("draft", "Brouillon", "#94a3b8"),
("prereq", "Pr\u00e9requis", "#00d4ff"),
("snapshot", "Snapshot", "#a78bfa"),
("patching", "Patching", "#ffcc00"),
("result", "R\u00e9sultats", "#00ff88"),
("completed", "Termin\u00e9", "#10b981"),
] %}
{% set current_step_idx = namespace(val=0) %}
{% for s in STEPS %}{% if s[0] == run.status %}{% set current_step_idx.val = loop.index0 %}{% endif %}{% endfor %}
{% set can_modify = run.status in ('draft', 'prereq') %}
{% macro qs(hp=hp_page, pp=p_page) -%}
?hp_page={{ hp }}&p_page={{ pp }}&per_page={{ per_page }}&search={{ filters.search or '' }}&status={{ filters.status or '' }}&domain={{ filters.domain or '' }}
{%- endmacro %}
{% block content %}
<div class="flex items-center justify-between mb-4">
<div>
<a href="/quickwin" class="text-xs text-gray-500 hover:text-gray-300">&larr; Retour campagnes</a>
<h1 class="text-xl font-bold" style="color:#00d4ff">{{ run.label }}</h1>
<p class="text-xs text-gray-500">S{{ '%02d'|format(run.week_number) }} {{ run.year }} &mdash; Cr&eacute;&eacute; par {{ run.created_by_name or '?' }}</p>
</div>
<div class="flex gap-2 items-center">
<a href="/quickwin/{{ run.id }}/correspondance" class="btn-sm" style="background:#1e3a5f;color:#a78bfa;padding:4px 14px;text-decoration:none">Correspondance</a>
<a href="/quickwin/{{ run.id }}/logs" class="btn-sm" style="background:#1e3a5f;color:#94a3b8;padding:4px 14px;text-decoration:none">Logs</a>
<form method="post" action="/quickwin/{{ run.id }}/delete" onsubmit="return confirm('Supprimer cette campagne ?')">
<button class="btn-sm btn-danger" style="padding:4px 12px">Supprimer</button>
</form>
</div>
</div>
{% if msg %}
<div style="background:#1a5a2e;color:#8f8;padding:8px 16px;border-radius:6px;margin-bottom:12px;font-size:0.85rem">{{ msg }}</div>
{% endif %}
<!-- Step Progress Bar -->
<div class="card mb-4" style="padding:16px 20px">
<div style="display:flex;align-items:center;gap:0">
{% for step_id, step_label, step_color in STEPS %}
{% set idx = loop.index0 %}
{% set is_current = (step_id == run.status) %}
{% set is_done = (idx < current_step_idx.val) %}
<div style="flex:1;text-align:center;position:relative">
<div style="width:32px;height:32px;border-radius:50%;margin:0 auto 4px;display:flex;align-items:center;justify-content:center;font-weight:bold;font-size:0.8rem;
{% if is_done %}background:{{ step_color }};color:#0a0e17{% elif is_current %}background:{{ step_color }};color:#0a0e17;box-shadow:0 0 12px {{ step_color }}{% else %}background:#1e3a5f;color:#4a5568{% endif %}">
{% if is_done %}&check;{% else %}{{ idx + 1 }}{% endif %}
</div>
<div style="font-size:0.7rem;{% if is_current %}color:{{ step_color }};font-weight:bold{% elif is_done %}color:#94a3b8{% else %}color:#4a5568{% endif %}">{{ step_label }}</div>
</div>
{% if not loop.last %}
<div style="flex:1;height:2px;{% if idx < current_step_idx.val %}background:{{ step_color }}{% else %}background:#1e3a5f{% endif %};margin-bottom:18px"></div>
{% endif %}
{% endfor %}
</div>
<div style="display:flex;justify-content:space-between;margin-top:12px">
{% if current_step_idx.val > 0 %}
<form method="post" action="/quickwin/{{ run.id }}/advance">
<input type="hidden" name="target" value="{{ STEPS[current_step_idx.val - 1][0] }}">
<button class="btn-sm" style="background:#1e3a5f;color:#94a3b8;padding:4px 14px">&larr; &Eacute;tape pr&eacute;c&eacute;dente</button>
</form>
{% else %}<div></div>{% endif %}
{% if current_step_idx.val < 5 %}
<form method="post" action="/quickwin/{{ run.id }}/advance">
<input type="hidden" name="target" value="{{ STEPS[current_step_idx.val + 1][0] }}">
<button class="btn-primary" style="padding:4px 18px;font-size:0.85rem">&Eacute;tape suivante : {{ STEPS[current_step_idx.val + 1][1] }} &rarr;</button>
</form>
{% else %}<div></div>{% endif %}
</div>
</div>
<!-- KPIs -->
<div style="display:grid;grid-template-columns:repeat(6,1fr);gap:12px;margin-bottom:20px">
<div class="card p-3 text-center">
<div class="text-2xl font-bold" style="color:#fff">{{ 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" style="color:#00d4ff">{{ stats.hprod_total }}</div>
<div class="text-xs text-gray-500">H-Prod</div>
</div>
<div class="card p-3 text-center">
<div class="text-2xl font-bold" style="color:#ffcc00">{{ stats.prod_total }}</div>
<div class="text-xs text-gray-500">Prod</div>
</div>
<div class="card p-3 text-center">
<div class="text-2xl font-bold" style="color:#00ff88">{{ stats.patched }}</div>
<div class="text-xs text-gray-500">Patch&eacute;s</div>
</div>
<div class="card p-3 text-center">
<div class="text-2xl font-bold" style="color:#ff3366">{{ stats.failed }}</div>
<div class="text-xs text-gray-500">KO</div>
</div>
<div class="card p-3 text-center">
<div class="text-2xl font-bold" style="color:#ff8800">{{ stats.reboot_count }}</div>
<div class="text-xs text-gray-500">Reboot</div>
</div>
</div>
<!-- ========== SCOPE SELECTOR (draft + prereq) ========== -->
{% if run.status == 'draft' and scope %}
<div class="card mb-4" style="border-left:3px solid {% if run.status == 'draft' %}#94a3b8{% else %}#00d4ff{% endif %}">
<div class="p-3" style="border-bottom:1px solid #1e3a5f">
<h3 style="color:{% if run.status == 'draft' %}#94a3b8{% else %}#00d4ff{% endif %};font-weight:bold;font-size:0.9rem">
P&eacute;rim&egrave;tre de la campagne
</h3>
<p class="text-xs text-gray-500 mt-1">Cochez les domaines et zones &agrave; inclure. Les serveurs hors p&eacute;rim&egrave;tre seront marqu&eacute;s &laquo;&nbsp;Exclu&nbsp;&raquo;.</p>
</div>
<form method="post" action="/quickwin/{{ run.id }}/apply-scope" id="scope-form" style="padding:16px">
<input type="hidden" name="scope_domains" id="h-scope-domains" value="">
<input type="hidden" name="scope_zones" id="h-scope-zones" value="">
<div style="display:grid;grid-template-columns:2fr 1fr;gap:20px;margin-bottom:14px">
<!-- Domaines -->
<div>
<div class="text-xs font-bold text-gray-400 mb-2">DOMAINES</div>
<div style="display:grid;grid-template-columns:1fr 1fr;gap:2px;background:#0d1520;border-radius:6px;padding:8px 10px">
{% for d in scope.domains %}
{% set active = scope.dom_active.get(d, 0) %}
{% set total = scope.dom_counts.get(d, 0) %}
<label style="display:flex;align-items:center;gap:6px;padding:3px 0;cursor:pointer;font-size:0.82rem;color:#cbd5e1">
<input type="checkbox" class="scope-dom" value="{{ d }}" {% if active > 0 %}checked{% endif %}>
{{ d }}
<span style="color:#4a5568;font-size:0.7rem">({{ total }})</span>
{% if active > 0 and active < total %}<span style="color:#ffcc00;font-size:0.65rem">{{ active }} actifs</span>{% endif %}
</label>
{% endfor %}
</div>
</div>
<!-- Zones -->
<div>
<div class="text-xs font-bold text-gray-400 mb-2">ZONES</div>
<div style="background:#0d1520;border-radius:6px;padding:8px 10px">
{% for z in scope.zones %}
{% set active = scope.zone_active.get(z, 0) %}
{% set total = scope.zone_counts.get(z, 0) %}
<label style="display:flex;align-items:center;gap:6px;padding:3px 0;cursor:pointer;font-size:0.82rem;color:#cbd5e1">
<input type="checkbox" class="scope-zone" value="{{ z }}" {% if active > 0 %}checked{% endif %}>
{{ z }}
<span style="color:#4a5568;font-size:0.7rem">({{ total }})</span>
{% if active > 0 and active < total %}<span style="color:#ffcc00;font-size:0.65rem">{{ active }} actifs</span>{% endif %}
</label>
{% endfor %}
</div>
</div>
</div>
<div class="flex gap-3 items-center">
<button type="submit" class="btn-primary" style="padding:6px 20px;font-size:0.9rem"
onclick="return prepareScopeForm()">
Appliquer le p&eacute;rim&egrave;tre
</button>
<span class="text-xs text-gray-500" id="scope-preview"></span>
</div>
</form>
</div>
{% endif %}
<!-- Step-specific content -->
{% if run.status == 'prereq' %}
<div class="card mb-4" style="border-left:3px solid #00d4ff;padding:16px">
<h3 style="color:#00d4ff;font-weight:bold;margin-bottom:8px">V&eacute;rification des pr&eacute;requis</h3>
<p class="text-xs text-gray-400 mb-3">V&eacute;rifie : r&eacute;solution DNS, SSH (PSMP/Key), Satellite/YUM, espace disque (&lt;90%)</p>
<div style="display:flex;gap:16px;margin-bottom:12px">
<div>
<span class="badge badge-green">{{ step_hp.prereq_ok }} OK</span>
<span class="badge badge-red">{{ step_hp.prereq_ko }} KO</span>
<span class="badge badge-gray">{{ step_hp.prereq_pending }} en attente</span>
<span class="text-xs text-gray-500 ml-2">(H-Prod)</span>
</div>
</div>
<div class="flex gap-2 mb-3">
<button id="btn-check-hprod" class="btn-primary" style="padding:6px 18px;font-size:0.85rem"
onclick="startPrereqStream('hprod')">Lancer check H-Prod</button>
{% if prod_ok %}
<button id="btn-check-prod" class="btn-primary" style="padding:6px 18px;font-size:0.85rem;background:#ffcc00;color:#0a0e17"
onclick="startPrereqStream('prod')">Lancer check Prod</button>
{% endif %}
<button id="btn-stop" style="display:none;background:#ff3366;color:#fff;padding:6px 18px;font-size:0.85rem;border-radius:6px;cursor:pointer"
onclick="stopPrereqStream()">Arr&ecirc;ter</button>
</div>
<!-- Terminal -->
<div id="prereq-terminal" style="display:none">
<div style="display:flex;justify-content:space-between;align-items:center;margin-bottom:6px">
<span id="prereq-progress" class="text-xs text-gray-400"></span>
<span id="prereq-stats" class="text-xs"></span>
</div>
<div style="background:#000;border:1px solid #1e3a5f;border-radius:6px;padding:8px;font-family:monospace;font-size:0.75rem;max-height:350px;overflow-y:auto;color:#8f8" id="prereq-log"></div>
</div>
</div>
{% endif %}
{% if run.status == 'snapshot' %}
<div class="card mb-4" style="border-left:3px solid #a78bfa;padding:16px">
<h3 style="color:#a78bfa;font-weight:bold;margin-bottom:8px">Snapshots VM</h3>
<p class="text-xs text-gray-400 mb-3">Connexion vSphere &rarr; recherche VM &rarr; snapshot automatique. Les serveurs physiques sont ignor&eacute;s (v&eacute;rifier backup Commvault).</p>
<div style="display:flex;gap:16px;margin-bottom:12px">
<div>
<span class="badge badge-green">{{ step_hp.snap_ok }} fait(s)</span>
<span class="badge badge-gray">{{ step_hp.snap_pending }} en attente</span>
<span class="text-xs text-gray-500 ml-2">(H-Prod)</span>
</div>
</div>
<div class="flex gap-2 mb-3" style="flex-wrap:wrap;align-items:center">
<button id="btn-snap-hprod" class="btn-primary" style="padding:6px 18px;font-size:0.85rem;background:#a78bfa;color:#0a0e17"
onclick="startSnapshotStream('hprod')">Prendre Snapshots H-Prod</button>
{% if prod_ok %}
<button id="btn-snap-prod" class="btn-primary" style="padding:6px 18px;font-size:0.85rem;background:#ffcc00;color:#0a0e17"
onclick="startSnapshotStream('prod')">Prendre Snapshots Prod</button>
{% endif %}
<form method="post" action="/quickwin/{{ run.id }}/snapshot/mark-all" style="display:inline">
<input type="hidden" name="branch" value="hprod">
<button class="btn-sm" style="background:#1e3a5f;color:#a78bfa;padding:4px 14px">Tout marquer fait (H-Prod)</button>
</form>
<button id="btn-snap-stop" style="display:none;background:#ff3366;color:#fff;padding:6px 18px;font-size:0.85rem;border-radius:6px;cursor:pointer"
onclick="stopSnapshotStream()">Arr&ecirc;ter</button>
<span class="text-xs text-gray-500" style="margin-left:8px">Ordre vCenter : H-Prod = Senlis &rarr; Nanterre &rarr; DR | Prod = Nanterre &rarr; Senlis &rarr; DR</span>
</div>
<!-- Terminal snapshot -->
<div id="snap-terminal" style="display:none">
<div style="display:flex;justify-content:space-between;align-items:center;margin-bottom:6px">
<span id="snap-progress" class="text-xs text-gray-400"></span>
<span id="snap-stats" class="text-xs"></span>
</div>
<div style="background:#000;border:1px solid #1e3a5f;border-radius:6px;padding:8px;font-family:monospace;font-size:0.75rem;max-height:350px;overflow-y:auto;color:#8f8" id="snap-log"></div>
</div>
</div>
{% endif %}
{% if run.status == 'patching' %}
{% if prod_ok and stats.prod_total > 0 %}
<!-- Prereq + Snapshot Prod (si pas encore faits) -->
<div class="card mb-4" style="border-left:3px solid #ff8800;padding:16px">
<h3 style="color:#ff8800;font-weight:bold;margin-bottom:8px">Pr&eacute;paration Production</h3>
<p class="text-xs text-gray-400 mb-3">Avant de patcher la prod, lancez les checks prereq et snapshots sur les serveurs production.</p>
<div class="flex gap-2 mb-3" style="flex-wrap:wrap;align-items:center">
<button id="btn-check-prod-p" class="btn-primary" style="padding:6px 18px;font-size:0.85rem;background:#ff8800;color:#0a0e17"
onclick="startPrereqStream('prod')">Check Prereq Prod</button>
<button id="btn-stop-prereq-prod" style="display:none;background:#ff3366;color:#fff;padding:6px 18px;font-size:0.85rem;border-radius:6px;cursor:pointer"
onclick="stopPrereqStream()">Arr&ecirc;ter</button>
<button id="btn-snap-prod-p" class="btn-primary" style="padding:6px 18px;font-size:0.85rem;background:#a78bfa;color:#0a0e17"
onclick="startSnapshotStream('prod')">Prendre Snapshots Prod</button>
<button id="btn-snap-stop-prod" style="display:none;background:#ff3366;color:#fff;padding:6px 18px;font-size:0.85rem;border-radius:6px;cursor:pointer"
onclick="stopSnapshotStream()">Arr&ecirc;ter</button>
<form method="post" action="/quickwin/{{ run.id }}/snapshot/mark-all">
<input type="hidden" name="branch" value="prod">
<button class="btn-sm" style="background:#a78bfa22;color:#a78bfa;padding:4px 14px">Tout marquer snap OK (prod)</button>
</form>
</div>
<!-- Terminal prereq prod -->
<div id="prereq-terminal" style="display:none;margin-bottom:12px">
<div style="display:flex;justify-content:space-between;align-items:center;margin-bottom:6px">
<span id="prereq-progress" class="text-xs text-gray-400"></span>
<span id="prereq-stats" class="text-xs"></span>
</div>
<div style="background:#000;border:1px solid #1e3a5f;border-radius:6px;padding:8px;font-family:monospace;font-size:0.75rem;max-height:350px;overflow-y:auto;color:#8f8" id="prereq-log"></div>
</div>
<!-- Terminal snapshot prod -->
<div id="snap-terminal" style="display:none">
<div style="display:flex;justify-content:space-between;align-items:center;margin-bottom:6px">
<span id="snap-progress" class="text-xs text-gray-400"></span>
<span id="snap-stats" class="text-xs"></span>
</div>
<div style="background:#000;border:1px solid #1e3a5f;border-radius:6px;padding:8px;font-family:monospace;font-size:0.75rem;max-height:350px;overflow-y:auto;color:#8f8" id="snap-log"></div>
</div>
</div>
{% endif %}
<div class="card mb-4" style="border-left:3px solid #ffcc00;padding:16px">
<h3 style="color:#ffcc00;font-weight:bold;margin-bottom:8px">Ex&eacute;cution du patching</h3>
<p class="text-xs text-gray-400 mb-3">&Eacute;tape 1 : G&eacute;n&eacute;rer les commandes. &Eacute;tape 2 : V&eacute;rifier. &Eacute;tape 3 : Ex&eacute;cuter via SSH.</p>
<!-- Boutons generation -->
<div class="flex gap-2 mb-3" style="flex-wrap:wrap;align-items:center">
<form method="post" action="/quickwin/{{ run.id }}/build-commands">
<input type="hidden" name="branch" value="hprod">
<button class="btn-primary" style="padding:6px 18px;font-size:0.85rem;background:#ffcc00;color:#0a0e17">1. G&eacute;n&eacute;rer commandes H-Prod</button>
</form>
{% if prod_ok %}
<form method="post" action="/quickwin/{{ run.id }}/build-commands">
<input type="hidden" name="branch" value="prod">
<button class="btn-primary" style="padding:6px 18px;font-size:0.85rem;background:#ff8800;color:#0a0e17">1. G&eacute;n&eacute;rer commandes Prod</button>
</form>
{% endif %}
<button class="btn-sm" style="background:#1e3a5f;color:#ffcc00;padding:4px 14px" onclick="loadCommands('hprod')">Voir commandes H-Prod</button>
{% if prod_ok %}
<button class="btn-sm" style="background:#1e3a5f;color:#ff8800;padding:4px 14px" onclick="loadCommands('prod')">Voir commandes Prod</button>
{% endif %}
</div>
<!-- Tableau commandes (charge en JS) -->
<div id="cmd-panel" style="display:none;margin-bottom:16px">
<div style="display:flex;justify-content:space-between;align-items:center;margin-bottom:8px">
<h4 id="cmd-title" class="text-sm font-bold" style="color:#ffcc00"></h4>
<div class="flex gap-2">
<button id="btn-exec-patch" class="btn-primary" style="padding:6px 20px;font-size:0.85rem;background:#ff3366;color:#fff"
onclick="confirmExec()">2. Ex&eacute;cuter les commandes</button>
<button id="btn-patch-stop" style="display:none;background:#ff3366;color:#fff;padding:6px 18px;font-size:0.85rem;border-radius:6px;cursor:pointer"
onclick="stopPatchStream()">Arr&ecirc;ter</button>
</div>
</div>
<div style="max-height:250px;overflow-y:auto;background:#0a0e17;border:1px solid #1e3a5f;border-radius:6px">
<table class="table-cyber w-full" style="font-size:0.75rem">
<thead><tr>
<th class="px-2 py-1" style="width:150px">Serveur</th>
<th class="px-2 py-1">Commande</th>
</tr></thead>
<tbody id="cmd-tbody"></tbody>
</table>
</div>
</div>
<!-- Terminal patching -->
<div id="patch-terminal" style="display:none">
<div style="display:flex;justify-content:space-between;align-items:center;margin-bottom:6px">
<span id="patch-progress" class="text-xs text-gray-400"></span>
<span id="patch-stats" class="text-xs"></span>
</div>
<div style="background:#000;border:1px solid #1e3a5f;border-radius:6px;padding:8px;font-family:monospace;font-size:0.75rem;max-height:400px;overflow-y:auto;color:#8f8" id="patch-log"></div>
</div>
</div>
{% endif %}
{% if run.status == 'result' %}
<div class="card mb-4" style="border-left:3px solid #00ff88;padding:16px">
<h3 style="color:#00ff88;font-weight:bold;margin-bottom:8px">R&eacute;sultats</h3>
<div style="display:grid;grid-template-columns:repeat(4,1fr);gap:12px">
<div class="card p-3 text-center" style="border-color:#00ff88">
<div class="text-xl font-bold" style="color:#00ff88">{{ stats.patched }}</div>
<div class="text-xs text-gray-500">Patch&eacute;s</div>
</div>
<div class="card p-3 text-center" style="border-color:#ff3366">
<div class="text-xl font-bold" style="color:#ff3366">{{ stats.failed }}</div>
<div class="text-xs text-gray-500">KO</div>
</div>
<div class="card p-3 text-center" style="border-color:#94a3b8">
<div class="text-xl font-bold" style="color:#94a3b8">{{ stats.pending }}</div>
<div class="text-xs text-gray-500">En attente</div>
</div>
<div class="card p-3 text-center" style="border-color:#ff8800">
<div class="text-xl font-bold" style="color:#ff8800">{{ stats.reboot_count }}</div>
<div class="text-xs text-gray-500">Reboot</div>
</div>
</div>
</div>
{% endif %}
{% if run.status == 'completed' %}
<div class="card mb-4" style="border-left:3px solid #10b981;padding:16px">
<h3 style="color:#10b981;font-weight:bold;margin-bottom:8px">Campagne termin&eacute;e</h3>
<p class="text-xs text-gray-400 mb-3">{{ stats.patched }} patch&eacute;(s), {{ stats.failed }} KO, {{ stats.reboot_count }} reboot(s).</p>
<a href="/quickwin/{{ run.id }}/report" class="btn-primary" style="padding:6px 18px;font-size:0.85rem;display:inline-block;text-decoration:none">T&eacute;l&eacute;charger le rapport</a>
</div>
{% endif %}
<!-- Filtres table (contextuels selon l'etape) -->
<form method="GET" class="card mb-4" style="padding:10px 16px;display:flex;gap:12px;align-items:center;flex-wrap:wrap">
<input type="text" name="search" value="{{ filters.search or '' }}" placeholder="Recherche serveur..." style="width:200px">
<select name="domain" onchange="this.form.submit()" style="width:160px">
<option value="">Tous domaines</option>
{% set doms = entries|map(attribute='domaine')|select('string')|unique|sort %}
{% for d in doms %}<option value="{{ d }}" {% if filters.domain == d %}selected{% endif %}>{{ d }}</option>{% endfor %}
</select>
{% if run.status in ('prereq','snapshot','patching','result','completed') %}
<select name="prereq_filter" onchange="this.form.submit()" style="width:140px">
<option value="">Prereq: tous</option>
<option value="ok" {% if filters.prereq == 'ok' %}selected{% endif %}>Prereq OK</option>
<option value="ko" {% if filters.prereq == 'ko' %}selected{% endif %}>Prereq KO</option>
<option value="pending" {% if filters.prereq == 'pending' %}selected{% endif %}>Non v&eacute;rifi&eacute;</option>
</select>
{% endif %}
{% if run.status in ('snapshot','patching','result','completed') %}
<select name="snap_filter" onchange="this.form.submit()" style="width:130px">
<option value="">Snap: tous</option>
<option value="ok" {% if filters.snap == 'ok' %}selected{% endif %}>Snap fait</option>
<option value="pending" {% if filters.snap == 'pending' %}selected{% endif %}>Snap en attente</option>
</select>
{% endif %}
{% if run.status in ('patching','result','completed') %}
<select name="status" onchange="this.form.submit()" style="width:140px">
<option value="">Tous statuts</option>
<option value="pending" {% if filters.status == 'pending' %}selected{% endif %}>En attente</option>
<option value="patched" {% if filters.status == 'patched' %}selected{% endif %}>Patch&eacute;</option>
<option value="failed" {% if filters.status == 'failed' %}selected{% endif %}>KO</option>
<option value="excluded" {% if filters.status == 'excluded' %}selected{% endif %}>Exclu</option>
<option value="skipped" {% if filters.status == 'skipped' %}selected{% endif %}>Ignor&eacute;</option>
</select>
{% endif %}
<select name="per_page" onchange="this.form.submit()" style="width:130px">
<option value="">Par page</option>
{% for n in [14, 25, 50, 100] %}<option value="{{ n }}" {% if per_page == n %}selected{% endif %}>{{ n }}</option>{% endfor %}
</select>
<button type="submit" class="btn-primary" style="padding:4px 14px;font-size:0.8rem">Filtrer</button>
<a href="/quickwin/{{ run.id }}" class="text-xs text-gray-500 hover:text-gray-300">Reset</a>
</form>
{% if not prod_ok %}
<div class="card mb-4" style="border-left:3px solid #ff3366;padding:12px 16px">
<p style="color:#ff3366;font-size:0.85rem;font-weight:600">Hors-production d'abord : {{ stats.hprod_pending }} serveur(s) hprod en attente.</p>
</div>
{% endif %}
<!-- ========== MACRO: Entry table ========== -->
{% macro entry_table(rows, branch_label, branch_color, branch_key, page_num, total_pages, total_count) %}
<div class="card mb-4">
<div class="p-3 flex items-center justify-between" style="border-bottom:1px solid #1e3a5f">
<h2 class="text-sm font-bold" style="color:{{ branch_color }}">{{ branch_label }} ({{ total_count }})</h2>
<div class="flex gap-1 items-center">
<span class="badge badge-green">{{ rows|selectattr('status','eq','patched')|list|length }} OK</span>
<span class="badge badge-red">{{ rows|selectattr('status','eq','failed')|list|length }} KO</span>
<span class="badge badge-gray">{{ rows|selectattr('status','eq','pending')|list|length }} en attente</span>
{% if can_modify %}
<button class="btn-sm" style="background:#ff336622;color:#ff3366;padding:2px 10px;font-size:0.7rem;margin-left:8px"
onclick="removeSelected('{{ branch_key }}')">Supprimer s&eacute;lection</button>
{% endif %}
</div>
</div>
<div class="table-wrap">
<table class="table-cyber w-full">
<thead><tr>
{% if can_modify %}<th class="px-1 py-2" style="width:28px"><input type="checkbox" class="rm-check-all" data-branch="{{ branch_key }}" title="Tout"></th>{% endif %}
<th class="px-2 py-2">Serveur</th>
<th class="px-2 py-2">Domaine</th>
<th class="px-2 py-2">Env</th>
<th class="px-2 py-2">Statut</th>
{% if run.status in ('prereq','snapshot','patching','result','completed') %}
<th class="px-2 py-2">Prereq</th>
{% endif %}
{% if run.status in ('snapshot','patching','result','completed') %}
<th class="px-2 py-2">Snap</th>
{% endif %}
<th class="px-2 py-2">Exclusions g&eacute;n.</th>
<th class="px-2 py-2">Exclusions sp&eacute;c.</th>
<th class="px-2 py-2">Packages</th>
<th class="px-2 py-2">Date patch</th>
<th class="px-2 py-2">Reboot</th>
<th class="px-2 py-2">Notes</th>
{% if run.status == 'patching' %}
<th class="px-2 py-2">Action</th>
{% endif %}
</tr></thead>
<tbody>
{% for e in rows %}
<tr data-id="{{ e.id }}">
{% if can_modify %}<td class="px-1 py-2"><input type="checkbox" class="rm-check rm-{{ branch_key }}" value="{{ e.id }}"></td>{% endif %}
<td class="px-2 py-2 font-bold" style="color:{{ branch_color }}">{{ e.hostname }}</td>
<td class="px-2 py-2 text-gray-400">{{ e.domaine or '?' }}</td>
<td class="px-2 py-2 text-gray-400">{{ e.environnement or '?' }}</td>
<td class="px-2 py-2">
{% if e.status == 'patched' %}<span class="badge badge-green">Patch&eacute;</span>
{% elif e.status == 'failed' %}<span class="badge badge-red">KO</span>
{% elif e.status == 'in_progress' %}<span class="badge badge-yellow">En cours</span>
{% elif e.status == 'excluded' %}<span class="badge badge-gray">Exclu</span>
{% elif e.status == 'skipped' %}<span class="badge badge-gray">Ignor&eacute;</span>
{% else %}<span class="badge badge-gray">En attente</span>{% endif %}
</td>
{% if run.status in ('prereq','snapshot','patching','result','completed') %}
<td class="px-2 py-2 text-center">
{% if e.prereq_ok == true %}<span style="color:#00ff88" title="{{ e.prereq_detail }}">&#10003;</span>
{% elif e.prereq_ok == false %}<span style="color:#ff3366" title="{{ e.prereq_detail }}">&#10007;</span>
{% else %}<span style="color:#4a5568">&mdash;</span>{% endif %}
</td>
{% endif %}
{% if run.status in ('snapshot','patching','result','completed') %}
<td class="px-2 py-2 text-center">
{% if e.snap_done %}<span style="color:#a78bfa">&#10003;</span>
{% else %}
{% if run.status == 'snapshot' %}
<form method="post" action="/quickwin/{{ run.id }}/snapshot/mark" style="display:inline">
<input type="hidden" name="entry_id" value="{{ e.id }}">
<input type="hidden" name="done" value="true">
<button class="btn-sm" style="background:#a78bfa22;color:#a78bfa;font-size:0.6rem">Fait</button>
</form>
{% else %}<span style="color:#4a5568">&mdash;</span>{% endif %}
{% endif %}
</td>
{% endif %}
<td class="px-2 py-2 text-xs" style="color:#ffcc00;max-width:150px;overflow:hidden;text-overflow:ellipsis;white-space:nowrap" title="{{ e.general_excludes }}">
<span class="editable" data-id="{{ e.id }}" data-field="general_excludes">{{ e.general_excludes or '—' }}</span>
</td>
<td class="px-2 py-2 text-xs" style="color:#ff8800;max-width:150px;overflow:hidden;text-overflow:ellipsis;white-space:nowrap" title="{{ e.specific_excludes }}">
<span class="editable" data-id="{{ e.id }}" data-field="specific_excludes">{{ e.specific_excludes or '—' }}</span>
</td>
<td class="px-2 py-2 text-center">{{ e.patch_packages_count or '—' }}</td>
<td class="px-2 py-2 text-xs text-gray-500">{{ e.patch_date.strftime('%d/%m %H:%M') if e.patch_date else '—' }}</td>
<td class="px-2 py-2 text-center">{% if e.reboot_required %}<span style="color:#ff3366">OUI</span>{% else %}—{% endif %}</td>
<td class="px-2 py-2 text-xs text-gray-500">
<span class="editable" data-id="{{ e.id }}" data-field="notes">{{ e.notes or '—' }}</span>
</td>
{% if run.status == 'patching' %}
<td class="px-2 py-2">
{% if e.status == 'pending' and e.prereq_ok and e.snap_done %}
<div class="flex gap-1">
<form method="post" action="/quickwin/{{ run.id }}/mark-patched" style="display:inline">
<input type="hidden" name="entry_id" value="{{ e.id }}">
<input type="hidden" name="patch_status" value="patched">
<button class="btn-sm" style="background:#00ff8822;color:#00ff88;font-size:0.6rem">OK</button>
</form>
<form method="post" action="/quickwin/{{ run.id }}/mark-patched" style="display:inline">
<input type="hidden" name="entry_id" value="{{ e.id }}">
<input type="hidden" name="patch_status" value="failed">
<button class="btn-sm" style="background:#ff336622;color:#ff3366;font-size:0.6rem">KO</button>
</form>
</div>
{% endif %}
</td>
{% endif %}
</tr>
{% endfor %}
{% if not rows %}<tr><td colspan="16" class="px-2 py-6 text-center text-gray-500">Aucun serveur{% if filters.search or filters.status or filters.domain %} (filtre actif){% endif %}</td></tr>{% endif %}
</tbody>
</table>
</div>
{% if total_pages > 1 %}
<div class="flex justify-between items-center p-3 text-sm text-gray-500" style="border-top:1px solid #1e3a5f">
<span>Page {{ page_num }} / {{ total_pages }} &mdash; {{ total_count }} serveur(s)</span>
<div class="flex gap-2">
{% if branch_key == 'hp' %}
{% if page_num > 1 %}<a href="{{ qs(hp=page_num - 1, pp=p_page) }}" class="btn-sm bg-cyber-border text-gray-300">Pr&eacute;c.</a>{% endif %}
{% if page_num < total_pages %}<a href="{{ qs(hp=page_num + 1, pp=p_page) }}" class="btn-sm bg-cyber-border text-gray-300">Suiv.</a>{% endif %}
{% else %}
{% if page_num > 1 %}<a href="{{ qs(hp=hp_page, pp=page_num - 1) }}" class="btn-sm bg-cyber-border text-gray-300">Pr&eacute;c.</a>{% endif %}
{% if page_num < total_pages %}<a href="{{ qs(hp=hp_page, pp=page_num + 1) }}" class="btn-sm bg-cyber-border text-gray-300">Suiv.</a>{% endif %}
{% endif %}
</div>
</div>
{% endif %}
</div>
{% endmacro %}
<!-- H-PROD -->
{{ entry_table(hprod, "HORS-PRODUCTION", "#00d4ff", "hp", hp_page, hp_total_pages, hprod_total) }}
<!-- PROD -->
{% if prod_ok %}
{{ entry_table(prod, "PRODUCTION", "#ffcc00", "pr", p_page, p_total_pages, prod_total) }}
{% endif %}
{% if run.notes %}
<div class="card p-4 mb-4">
<h3 class="text-xs font-bold text-gray-500 mb-2">NOTES</h3>
<p class="text-sm text-gray-300">{{ run.notes }}</p>
</div>
{% endif %}
<!-- Hidden remove form -->
<form method="post" action="/quickwin/{{ run.id }}/remove-entries" id="remove-form" style="display:none">
<input type="hidden" name="entry_ids" id="remove-entry-ids" value="">
</form>
<script>
/* ---- Scope selector: collect checked domains/zones into hidden fields ---- */
function prepareScopeForm() {
const doms = [...document.querySelectorAll('.scope-dom:checked')].map(c => c.value);
const zones = [...document.querySelectorAll('.scope-zone:checked')].map(c => c.value);
if (!doms.length && !zones.length) {
alert('Sélectionnez au moins un domaine ou une zone');
return false;
}
document.getElementById('h-scope-domains').value = doms.join(',');
document.getElementById('h-scope-zones').value = zones.join(',');
return true;
}
/* ---- Scope: live preview of selection ---- */
document.querySelectorAll('.scope-dom, .scope-zone').forEach(cb => {
cb.addEventListener('change', function() {
const dc = document.querySelectorAll('.scope-dom:checked').length;
const dt = document.querySelectorAll('.scope-dom').length;
const zc = document.querySelectorAll('.scope-zone:checked').length;
const zt = document.querySelectorAll('.scope-zone').length;
const el = document.getElementById('scope-preview');
if (el) el.textContent = dc + '/' + dt + ' domaines, ' + zc + '/' + zt + ' zones';
});
});
/* ---- Add panel: select-all + prepare form ---- */
const addCheckAll = document.getElementById('add-check-all');
if (addCheckAll) {
addCheckAll.addEventListener('change', function() {
document.querySelectorAll('.add-check').forEach(cb => cb.checked = this.checked);
updateAddCount();
});
document.querySelectorAll('.add-check').forEach(cb => {
cb.addEventListener('change', updateAddCount);
});
}
function updateAddCount() {
const cnt = document.querySelectorAll('.add-check:checked').length;
const el = document.getElementById('add-count');
if (el) el.textContent = cnt;
}
function prepareAddForm() {
const ids = [...document.querySelectorAll('.add-check:checked')].map(cb => cb.value);
if (!ids.length) { alert('Aucun serveur s\u00e9lectionn\u00e9'); return false; }
document.getElementById('add-server-ids').value = ids.join(',');
return true;
}
/* ---- Remove: select-all per branch + submit ---- */
document.querySelectorAll('.rm-check-all').forEach(masterCb => {
masterCb.addEventListener('change', function() {
const branch = this.dataset.branch;
document.querySelectorAll('.rm-' + branch).forEach(cb => cb.checked = this.checked);
});
});
function removeSelected(branch) {
const ids = [...document.querySelectorAll('.rm-' + branch + ':checked')].map(cb => cb.value);
if (!ids.length) { alert('Aucun serveur s\u00e9lectionn\u00e9'); return; }
if (!confirm('Supprimer ' + ids.length + ' serveur(s) de la campagne ?')) return;
document.getElementById('remove-entry-ids').value = ids.join(',');
document.getElementById('remove-form').submit();
}
/* ---- Inline edit ---- */
document.querySelectorAll('.editable').forEach(el => {
el.style.cursor = 'pointer';
el.addEventListener('dblclick', function() {
const field = this.dataset.field;
const id = this.dataset.id;
const current = this.textContent.trim() === '\u2014' ? '' : this.textContent.trim();
const input = document.createElement('input');
input.value = current;
input.style.cssText = 'background:#0a0e17;border:1px solid #00d4ff;color:#fff;padding:2px 6px;border-radius:4px;font-size:0.75rem;width:100%';
this.textContent = '';
this.appendChild(input);
input.focus();
input.select();
const save = () => {
const val = input.value.trim();
this.textContent = val || '\u2014';
fetch('/api/quickwin/entry/update', {
method: 'POST',
headers: {'Content-Type': 'application/json'},
body: JSON.stringify({id: parseInt(id), field: field, value: val})
});
};
input.addEventListener('blur', save);
input.addEventListener('keydown', e => {
if (e.key === 'Enter') { e.preventDefault(); input.blur(); }
if (e.key === 'Escape') { this.textContent = current || '\u2014'; }
});
});
});
/* ---- SSE Prereq Terminal ---- */
let prereqSource = null;
function startPrereqStream(branch) {
const terminal = document.getElementById('prereq-terminal');
const log = document.getElementById('prereq-log');
const progress = document.getElementById('prereq-progress');
const stats = document.getElementById('prereq-stats');
const btnStop = document.getElementById('btn-stop');
terminal.style.display = 'block';
log.innerHTML = '';
const btnStop2 = document.getElementById('btn-stop-prereq-prod');
if (btnStop) btnStop.style.display = 'inline-block';
if (btnStop2) btnStop2.style.display = 'inline-block';
const btnHprod = document.getElementById('btn-check-hprod');
if (btnHprod) btnHprod.disabled = true;
const btnProd = document.getElementById('btn-check-prod');
if (btnProd) btnProd.disabled = true;
const btnProdP = document.getElementById('btn-check-prod-p');
if (btnProdP) btnProdP.disabled = true;
let okCount = 0, koCount = 0;
prereqSource = new EventSource('/quickwin/{{ run.id }}/prereq-stream?branch=' + branch);
prereqSource.onmessage = function(ev) {
const d = JSON.parse(ev.data);
if (d.type === 'start') {
addLine(log, '>>> Lancement check ' + d.branch + ' (' + d.total + ' serveurs)', '#00d4ff');
} else if (d.type === 'progress') {
progress.textContent = d.idx + '/' + d.total + ' — ' + d.hostname + '...';
} else if (d.type === 'result') {
const color = d.ok ? '#00ff88' : '#ff3366';
const icon = d.ok ? '\u2713' : '\u2717';
let line = icon + ' ' + d.hostname;
if (d.fqdn) line += ' (' + d.fqdn + ')';
line += ' DNS:' + (d.dns?'OK':'KO') + ' SSH:' + (d.ssh?'OK':'KO') + ' SAT:' + (d.sat?'OK':'KO') + ' DISK:' + (d.disk?'OK':'KO');
if (!d.ok && d.detail) line += '\n \u2514 ' + d.detail;
addLine(log, line, color);
if (d.ok) okCount++; else koCount++;
stats.innerHTML = '<span style="color:#00ff88">' + okCount + ' OK</span> &mdash; <span style="color:#ff3366">' + koCount + ' KO</span>';
progress.textContent = d.idx + '/' + d.total;
} else if (d.type === 'done') {
addLine(log, '\n>>> Termin\u00e9 : ' + d.ok + ' OK, ' + d.ko + ' KO sur ' + d.total, '#00d4ff');
stopPrereqStream();
progress.textContent = 'Termin\u00e9';
}
};
prereqSource.onerror = function() {
addLine(log, '>>> Connexion interrompue', '#ff3366');
stopPrereqStream();
};
}
function stopPrereqStream() {
if (prereqSource) { prereqSource.close(); prereqSource = null; }
const btnStop = document.getElementById('btn-stop');
if (btnStop) btnStop.style.display = 'none';
const btnStop2 = document.getElementById('btn-stop-prereq-prod');
if (btnStop2) btnStop2.style.display = 'none';
const btnHprod = document.getElementById('btn-check-hprod');
if (btnHprod) btnHprod.disabled = false;
const btnProd = document.getElementById('btn-check-prod');
if (btnProd) btnProd.disabled = false;
const btnProdP = document.getElementById('btn-check-prod-p');
if (btnProdP) btnProdP.disabled = false;
}
function addLine(container, text, color) {
const el = document.createElement('div');
el.style.color = color || '#ccc';
el.style.whiteSpace = 'pre-wrap';
el.style.wordBreak = 'break-all';
el.textContent = text;
container.appendChild(el);
container.scrollTop = container.scrollHeight;
}
/* ---- SSE Snapshot Terminal ---- */
let snapSource = null;
function startSnapshotStream(branch) {
const terminal = document.getElementById('snap-terminal');
const log = document.getElementById('snap-log');
const progress = document.getElementById('snap-progress');
const stats = document.getElementById('snap-stats');
const btnStop = document.getElementById('btn-snap-stop');
terminal.style.display = 'block';
log.innerHTML = '';
const btnStop2 = document.getElementById('btn-snap-stop-prod');
if (btnStop) btnStop.style.display = 'inline-block';
if (btnStop2) btnStop2.style.display = 'inline-block';
const btnSnapHprod = document.getElementById('btn-snap-hprod');
if (btnSnapHprod) btnSnapHprod.disabled = true;
const btnProd = document.getElementById('btn-snap-prod');
if (btnProd) btnProd.disabled = true;
const btnProdP = document.getElementById('btn-snap-prod-p');
if (btnProdP) btnProdP.disabled = true;
let okCount = 0, koCount = 0;
snapSource = new EventSource('/quickwin/{{ run.id }}/snapshot-stream?branch=' + branch);
snapSource.onmessage = function(ev) {
const d = JSON.parse(ev.data);
if (d.type === 'start') {
addLine(log, '>>> Snapshots ' + d.branch + ' : ' + d.vms + ' VMs, ' + d.physical + ' physique(s) ignor\u00e9(s)', '#a78bfa');
if (d.physical > 0) {
addLine(log, '\u26a0 ' + d.physical + ' serveur(s) physique(s) : pas de snapshot VM. V\u00e9rifier les backups Commvault.', '#ffcc00');
}
} else if (d.type === 'progress') {
progress.textContent = d.idx + '/' + d.total + ' \u2014 ' + d.hostname + '...';
} else if (d.type === 'result') {
const color = d.ok ? '#00ff88' : '#ff3366';
const icon = d.ok ? '\u2713' : '\u2717';
let line = icon + ' ' + d.hostname;
if (d.vcenter) line += ' [' + d.vcenter + ']';
line += ' \u2014 ' + d.detail;
addLine(log, line, color);
if (d.ok) okCount++; else koCount++;
stats.innerHTML = '<span style="color:#00ff88">' + okCount + ' OK</span> \u2014 <span style="color:#ff3366">' + koCount + ' KO</span>';
progress.textContent = d.idx + '/' + d.total;
} else if (d.type === 'done') {
let summary = '\n>>> Termin\u00e9 : ' + d.ok + ' OK, ' + d.ko + ' KO sur ' + d.total + ' VMs';
if (d.physical > 0) summary += ' (' + d.physical + ' physiques ignor\u00e9s)';
addLine(log, summary, '#a78bfa');
stopSnapshotStream();
progress.textContent = 'Termin\u00e9';
}
};
snapSource.onerror = function() {
addLine(log, '>>> Connexion interrompue', '#ff3366');
stopSnapshotStream();
};
}
function stopSnapshotStream() {
if (snapSource) { snapSource.close(); snapSource = null; }
const btnStop = document.getElementById('btn-snap-stop');
if (btnStop) btnStop.style.display = 'none';
const btnStop2 = document.getElementById('btn-snap-stop-prod');
if (btnStop2) btnStop2.style.display = 'none';
const btnSnapHprod = document.getElementById('btn-snap-hprod');
if (btnSnapHprod) btnSnapHprod.disabled = false;
const btnProd = document.getElementById('btn-snap-prod');
if (btnProd) btnProd.disabled = false;
const btnProdP = document.getElementById('btn-snap-prod-p');
if (btnProdP) btnProdP.disabled = false;
}
/* ---- Patching: load commands + execute via SSE ---- */
let patchBranch = 'hprod';
function loadCommands(branch) {
patchBranch = branch;
fetch('/api/quickwin/{{ run.id }}/commands/' + branch)
.then(r => r.json())
.then(cmds => {
const panel = document.getElementById('cmd-panel');
const tbody = document.getElementById('cmd-tbody');
const title = document.getElementById('cmd-title');
title.textContent = cmds.length + ' commande(s) ' + (branch === 'hprod' ? 'H-Prod' : 'Prod');
tbody.innerHTML = '';
if (!cmds.length) {
tbody.innerHTML = '<tr><td colspan="2" class="px-2 py-4 text-center text-gray-500">Aucune commande. G\u00e9n\u00e9rez d\'abord les commandes.</td></tr>';
}
cmds.forEach(c => {
const tr = document.createElement('tr');
tr.innerHTML = '<td class="px-2 py-1 font-bold" style="color:#00d4ff">' + c.hostname + '</td>'
+ '<td class="px-2 py-1" style="color:#ffcc00;font-family:monospace;word-break:break-all">' + c.command + '</td>';
tbody.appendChild(tr);
});
panel.style.display = 'block';
});
}
function confirmExec() {
if (!confirm('ATTENTION : Ceci va ex\u00e9cuter yum update sur tous les serveurs ' + patchBranch.toUpperCase() + '.\n\nConfirmer l\'ex\u00e9cution ?')) return;
startPatchStream(patchBranch);
}
let patchSource = null;
function startPatchStream(branch) {
const terminal = document.getElementById('patch-terminal');
const log = document.getElementById('patch-log');
const progress = document.getElementById('patch-progress');
const stats = document.getElementById('patch-stats');
const btnStop = document.getElementById('btn-patch-stop');
const btnExec = document.getElementById('btn-exec-patch');
terminal.style.display = 'block';
log.innerHTML = '';
btnStop.style.display = 'inline-block';
btnExec.disabled = true;
let okCount = 0, koCount = 0;
patchSource = new EventSource('/quickwin/{{ run.id }}/patch-stream?branch=' + branch);
patchSource.onmessage = function(ev) {
const d = JSON.parse(ev.data);
if (d.type === 'start') {
addLine(log, '>>> Patching ' + d.branch + ' (' + d.total + ' serveurs)', '#ffcc00');
} else if (d.type === 'progress') {
const st = d.status === 'connecting' ? 'Connexion SSH...' : 'Ex\u00e9cution yum...';
progress.textContent = d.idx + '/' + d.total + ' \u2014 ' + d.hostname + ' \u2014 ' + st;
if (d.status === 'connecting') {
addLine(log, '\n[' + d.idx + '/' + d.total + '] ' + d.hostname + ' \u2014 connexion...', '#94a3b8');
} else {
addLine(log, ' \u2192 ' + d.command, '#ffcc00');
}
} else if (d.type === 'result') {
const color = d.ok ? '#00ff88' : '#ff3366';
const icon = d.ok ? '\u2713' : '\u2717';
let line = ' ' + icon + ' ' + d.hostname;
if (d.packages) line += ' (' + d.packages + ' paquets)';
if (d.reboot) line += ' [REBOOT REQUIS]';
if (d.exit_code !== undefined && d.exit_code !== 0) line += ' (exit ' + d.exit_code + ')';
addLine(log, line, color);
if (d.detail) {
const color2 = d.ok ? '#6b7280' : '#ff6688';
const lines = d.detail.split('\n').filter(l => l.trim());
lines.forEach(l => addLine(log, ' \u2502 ' + l.trim(), color2));
}
if (d.ok) okCount++; else koCount++;
stats.innerHTML = '<span style="color:#00ff88">' + okCount + ' OK</span> \u2014 <span style="color:#ff3366">' + koCount + ' KO</span>';
progress.textContent = d.idx + '/' + d.total;
} else if (d.type === 'done') {
addLine(log, '\n>>> Termin\u00e9 : ' + d.ok + ' OK, ' + d.ko + ' KO sur ' + d.total, '#ffcc00');
stopPatchStream();
progress.textContent = 'Termin\u00e9';
}
};
patchSource.onerror = function() {
addLine(log, '>>> Connexion interrompue', '#ff3366');
stopPatchStream();
};
}
function stopPatchStream() {
if (patchSource) { patchSource.close(); patchSource = null; }
document.getElementById('btn-patch-stop').style.display = 'none';
document.getElementById('btn-exec-patch').disabled = false;
}
/* ---- Auto-load commands if redirected with show_cmds ---- */
(function() {
const params = new URLSearchParams(window.location.search);
const showBranch = params.get('show_cmds');
if (showBranch) loadCommands(showBranch);
})();
</script>
{% endblock %}

View File

@ -0,0 +1,107 @@
{% extends "base.html" %}
{% block title %}Logs QuickWin #{{ run.id }}{% endblock %}
{% block content %}
<div class="flex items-center justify-between mb-4">
<div>
<a href="/quickwin/{{ run.id }}" class="text-xs text-gray-500 hover:text-gray-300">&larr; Retour campagne</a>
<h1 class="text-xl font-bold" style="color:#00d4ff">Logs &mdash; {{ run.label }}</h1>
<p class="text-xs text-gray-500">S{{ '%02d'|format(run.week_number) }} {{ run.year }} &mdash; {{ total_logs }} entr&eacute;e(s)</p>
</div>
<div class="flex gap-2 items-center">
<a href="/quickwin/{{ run.id }}" class="btn-sm" style="background:#1e3a5f;color:#94a3b8;padding:4px 14px">Campagne</a>
<form method="post" action="/quickwin/{{ run.id }}/logs/clear" onsubmit="return confirm('Supprimer tous les logs ?')">
<button class="btn-sm btn-danger" style="padding:4px 12px">Vider les logs</button>
</form>
</div>
</div>
{% if msg %}
<div style="background:#1a5a2e;color:#8f8;padding:8px 16px;border-radius:6px;margin-bottom:12px;font-size:0.85rem">{{ msg }}</div>
{% endif %}
<!-- Stats -->
<div style="display:flex;gap:12px;margin-bottom:16px">
<div class="card p-3 text-center" style="flex:1">
<div class="text-xl font-bold" style="color:#fff">{{ total_logs }}</div>
<div class="text-xs text-gray-500">Total</div>
</div>
<div class="card p-3 text-center" style="flex:1">
<div class="text-xl font-bold" style="color:#00ff88">{{ log_stats.get('success', 0) }}</div>
<div class="text-xs text-gray-500">Success</div>
</div>
<div class="card p-3 text-center" style="flex:1">
<div class="text-xl font-bold" style="color:#00d4ff">{{ log_stats.get('info', 0) }}</div>
<div class="text-xs text-gray-500">Info</div>
</div>
<div class="card p-3 text-center" style="flex:1">
<div class="text-xl font-bold" style="color:#ffcc00">{{ log_stats.get('warn', 0) }}</div>
<div class="text-xs text-gray-500">Warn</div>
</div>
<div class="card p-3 text-center" style="flex:1">
<div class="text-xl font-bold" style="color:#ff3366">{{ log_stats.get('error', 0) }}</div>
<div class="text-xs text-gray-500">Error</div>
</div>
</div>
<!-- Filtres -->
<form method="GET" class="card mb-4" style="padding:10px 16px;display:flex;gap:12px;align-items:center;flex-wrap:wrap">
<select name="level" onchange="this.form.submit()" style="width:130px">
<option value="">Tous niveaux</option>
<option value="success" {% if filters.level == 'success' %}selected{% endif %}>Success</option>
<option value="info" {% if filters.level == 'info' %}selected{% endif %}>Info</option>
<option value="warn" {% if filters.level == 'warn' %}selected{% endif %}>Warn</option>
<option value="error" {% if filters.level == 'error' %}selected{% endif %}>Error</option>
</select>
<select name="step" onchange="this.form.submit()" style="width:140px">
<option value="">Toutes &eacute;tapes</option>
{% set steps_seen = logs|map(attribute='step')|unique|sort %}
{% for s in steps_seen %}<option value="{{ s }}" {% if filters.step == s %}selected{% endif %}>{{ s }}</option>{% endfor %}
</select>
<input type="text" name="hostname" value="{{ filters.hostname or '' }}" placeholder="Hostname..." style="width:180px">
<button type="submit" class="btn-primary" style="padding:4px 14px;font-size:0.8rem">Filtrer</button>
<a href="/quickwin/{{ run.id }}/logs" class="text-xs text-gray-500 hover:text-gray-300">Reset</a>
</form>
<!-- Logs table -->
<div class="card">
<div class="table-wrap" style="max-height:70vh;overflow-y:auto">
<table class="table-cyber w-full">
<thead style="position:sticky;top:0;z-index:1"><tr>
<th class="px-2 py-2" style="width:140px">Date</th>
<th class="px-2 py-2" style="width:70px">Niveau</th>
<th class="px-2 py-2" style="width:80px">&Eacute;tape</th>
<th class="px-2 py-2" style="width:120px">Hostname</th>
<th class="px-2 py-2">Message</th>
<th class="px-2 py-2" style="width:100px">Par</th>
</tr></thead>
<tbody>
{% for l in logs %}
<tr style="{% if l.level == 'error' %}background:#ff336610{% elif l.level == 'warn' %}background:#ffcc0008{% endif %}">
<td class="px-2 py-2 text-xs text-gray-500" style="white-space:nowrap">{{ l.created_at.strftime('%d/%m %H:%M:%S') }}</td>
<td class="px-2 py-2 text-center">
{% if l.level == 'success' %}<span class="badge badge-green">OK</span>
{% elif l.level == 'info' %}<span class="badge" style="background:#00d4ff22;color:#00d4ff">INFO</span>
{% elif l.level == 'warn' %}<span class="badge badge-yellow">WARN</span>
{% elif l.level == 'error' %}<span class="badge badge-red">ERR</span>
{% else %}<span class="badge badge-gray">{{ l.level }}</span>{% endif %}
</td>
<td class="px-2 py-2 text-xs text-gray-400">{{ l.step }}</td>
<td class="px-2 py-2 text-xs" style="color:#00d4ff">{{ l.hostname or '' }}</td>
<td class="px-2 py-2 text-sm">
{{ l.message }}
{% if l.detail %}
<div class="text-xs text-gray-500 mt-1" style="max-width:500px;word-break:break-all">{{ l.detail }}</div>
{% endif %}
</td>
<td class="px-2 py-2 text-xs text-gray-500">{{ l.created_by or '' }}</td>
</tr>
{% endfor %}
{% if not logs %}
<tr><td colspan="6" class="px-2 py-8 text-center text-gray-500">Aucun log{% if filters.level or filters.step or filters.hostname %} pour ces filtres{% endif %}</td></tr>
{% endif %}
</tbody>
</table>
</div>
</div>
{% endblock %}

View File

@ -0,0 +1,408 @@
{% extends "base.html" %}
{% block title %}R&eacute;f&eacute;rentiel{% endblock %}
{% block content %}
<div class="flex items-center justify-between mb-4">
<div>
<h1 class="text-xl font-bold" style="color:#00d4ff">R&eacute;f&eacute;rentiel</h1>
<p class="text-xs text-gray-500">Gestion centralis&eacute;e des domaines, environnements, zones et associations</p>
</div>
</div>
{% set msg = request.query_params.get('msg', '') %}
{% set detail = request.query_params.get('detail', '') %}
{% if msg == 'added' %}
<div style="background:#1a5a2e;color:#8f8;padding:8px 16px;border-radius:6px;margin-bottom:12px;font-size:0.85rem">
El&eacute;ment ajout&eacute; avec succ&egrave;s.
</div>
{% elif msg == 'updated' %}
<div style="background:#1a5a2e;color:#8f8;padding:8px 16px;border-radius:6px;margin-bottom:12px;font-size:0.85rem">
El&eacute;ment mis &agrave; jour.
</div>
{% elif msg == 'deleted' %}
<div style="background:#5a3a1a;color:#ffcc00;padding:8px 16px;border-radius:6px;margin-bottom:12px;font-size:0.85rem">
El&eacute;ment supprim&eacute;.
</div>
{% elif msg == 'nodelete' %}
<div style="background:#5a1a1a;color:#ff3366;padding:8px 16px;border-radius:6px;margin-bottom:12px;font-size:0.85rem">
Suppression impossible : {{ detail }} serveur(s) li&eacute;(s). Dissociez-les d'abord.
</div>
{% elif msg == 'exists' %}
<div style="background:#5a3a1a;color:#ffcc00;padding:8px 16px;border-radius:6px;margin-bottom:12px;font-size:0.85rem">
Cette association domaine &times; environnement existe d&eacute;j&agrave;.
</div>
{% endif %}
<!-- Onglets -->
<div style="display:flex;gap:4px;margin-bottom:16px">
{% for t, label in [('domains','Domaines'), ('envs','Environnements'), ('assocs','Associations'), ('zones','Zones'), ('dns','Domaines DNS')] %}
<a href="/referentiel?tab={{ t }}"
style="padding:8px 20px;border-radius:8px 8px 0 0;font-size:0.85rem;font-weight:600;
{% if tab == t %}background:#1e3a5f;color:#00d4ff;border-bottom:2px solid #00d4ff{% else %}background:#111827;color:#94a3b8{% endif %}">
{{ label }}
</a>
{% endfor %}
</div>
<!-- ============================================================ -->
<!-- ONGLET DOMAINES -->
<!-- ============================================================ -->
{% if tab == 'domains' %}
<div class="card">
<table class="table-cyber w-full">
<thead><tr>
<th class="px-2 py-2" style="width:40px">ID</th>
<th class="px-2 py-2">Nom</th>
<th class="px-2 py-2" style="width:70px">Code</th>
<th class="px-2 py-2">Description</th>
<th class="px-2 py-2" style="width:70px">Ordre</th>
<th class="px-2 py-2" style="width:70px">Actif</th>
<th class="px-2 py-2" style="width:80px">Serveurs</th>
<th class="px-2 py-2" style="width:140px">Actions</th>
</tr></thead>
<tbody>
{% for d in domains %}
<tr id="dom-row-{{ d.id }}">
<form method="post" action="/referentiel/domains/{{ d.id }}/edit">
<td class="px-2 py-2 text-xs text-gray-500">{{ d.id }}</td>
<td class="px-2 py-2"><input type="text" name="name" value="{{ d.name }}" style="width:100%" required></td>
<td class="px-2 py-2"><input type="text" name="code" value="{{ d.code }}" style="width:100%;text-transform:uppercase" required></td>
<td class="px-2 py-2"><input type="text" name="description" value="{{ d.description or '' }}" style="width:100%"></td>
<td class="px-2 py-2"><input type="number" name="display_order" value="{{ d.display_order }}" style="width:100%"></td>
<td class="px-2 py-2 text-center">
<input type="checkbox" name="is_active" {% if d.is_active %}checked{% endif %}>
</td>
<td class="px-2 py-2 text-center">
<span class="badge badge-blue">{{ dom_srv_counts.get(d.id, 0) }}</span>
</td>
<td class="px-2 py-2 text-center" style="white-space:nowrap">
<input type="hidden" name="default_excludes" value="{{ d.default_excludes or '' }}">
<input type="hidden" name="default_patch_window" value="{{ d.default_patch_window or '' }}">
<button type="submit" class="btn-sm" style="background:#00d4ff22;color:#00d4ff;padding:3px 10px">Sauver</button>
</form>
{% if dom_srv_counts.get(d.id, 0) == 0 %}
<form method="post" action="/referentiel/domains/{{ d.id }}/delete" style="display:inline"
onsubmit="return confirm('Supprimer le domaine {{ d.name }} ?')">
<button type="submit" class="btn-sm" style="background:#ff336622;color:#ff3366;padding:3px 10px">Suppr</button>
</form>
{% endif %}
</td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
{% if can_modify %}
<div class="card mt-3" style="padding:12px 16px">
<h3 class="text-sm font-bold mb-2" style="color:#00ff88">Ajouter un domaine</h3>
<form method="post" action="/referentiel/domains/add" style="display:flex;gap:10px;align-items:end;flex-wrap:wrap">
<div>
<label class="text-xs text-gray-500">Nom *</label>
<input type="text" name="name" required style="width:160px" placeholder="Infrastructure">
</div>
<div>
<label class="text-xs text-gray-500">Code *</label>
<input type="text" name="code" required style="width:80px;text-transform:uppercase" placeholder="INF">
</div>
<div>
<label class="text-xs text-gray-500">Description</label>
<input type="text" name="description" style="width:200px">
</div>
<div>
<label class="text-xs text-gray-500">Excludes par d&eacute;faut</label>
<input type="text" name="default_excludes" style="width:200px">
</div>
<div>
<label class="text-xs text-gray-500">Fen&ecirc;tre patch</label>
<input type="text" name="default_patch_window" style="width:120px" placeholder="mardi 22h">
</div>
<div>
<label class="text-xs text-gray-500">Ordre</label>
<input type="number" name="display_order" value="0" style="width:60px">
</div>
<button type="submit" class="btn-primary" style="padding:6px 18px;font-size:0.85rem">Ajouter</button>
</form>
</div>
{% endif %}
<!-- ============================================================ -->
<!-- ONGLET ENVIRONNEMENTS -->
<!-- ============================================================ -->
{% elif tab == 'envs' %}
<div class="card">
<table class="table-cyber w-full">
<thead><tr>
<th class="px-2 py-2" style="width:40px">ID</th>
<th class="px-2 py-2">Nom</th>
<th class="px-2 py-2" style="width:80px">Code</th>
<th class="px-2 py-2" style="width:80px">Serveurs</th>
<th class="px-2 py-2" style="width:140px">Actions</th>
</tr></thead>
<tbody>
{% for e in envs %}
<tr>
<form method="post" action="/referentiel/envs/{{ e.id }}/edit">
<td class="px-2 py-2 text-xs text-gray-500">{{ e.id }}</td>
<td class="px-2 py-2"><input type="text" name="name" value="{{ e.name }}" style="width:100%" required></td>
<td class="px-2 py-2"><input type="text" name="code" value="{{ e.code }}" style="width:100%;text-transform:uppercase" required></td>
<td class="px-2 py-2 text-center">
<span class="badge badge-blue">{{ env_srv_counts.get(e.id, 0) }}</span>
</td>
<td class="px-2 py-2 text-center" style="white-space:nowrap">
<button type="submit" class="btn-sm" style="background:#00d4ff22;color:#00d4ff;padding:3px 10px">Sauver</button>
</form>
{% if env_srv_counts.get(e.id, 0) == 0 %}
<form method="post" action="/referentiel/envs/{{ e.id }}/delete" style="display:inline"
onsubmit="return confirm('Supprimer l environnement {{ e.name }} ?')">
<button type="submit" class="btn-sm" style="background:#ff336622;color:#ff3366;padding:3px 10px">Suppr</button>
</form>
{% endif %}
</td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
{% if can_modify %}
<div class="card mt-3" style="padding:12px 16px">
<h3 class="text-sm font-bold mb-2" style="color:#00ff88">Ajouter un environnement</h3>
<form method="post" action="/referentiel/envs/add" style="display:flex;gap:10px;align-items:end">
<div>
<label class="text-xs text-gray-500">Nom *</label>
<input type="text" name="name" required style="width:200px" placeholder="Production">
</div>
<div>
<label class="text-xs text-gray-500">Code *</label>
<input type="text" name="code" required style="width:80px;text-transform:uppercase" placeholder="PRD">
</div>
<button type="submit" class="btn-primary" style="padding:6px 18px;font-size:0.85rem">Ajouter</button>
</form>
</div>
{% endif %}
<!-- ============================================================ -->
<!-- ONGLET ASSOCIATIONS -->
<!-- ============================================================ -->
{% elif tab == 'assocs' %}
<div class="card">
<div class="table-wrap" style="max-height:60vh;overflow-y:auto">
<table class="table-cyber w-full">
<thead style="position:sticky;top:0;z-index:1"><tr>
<th class="px-2 py-2" style="width:40px">ID</th>
<th class="px-2 py-2">Domaine</th>
<th class="px-2 py-2">Environnement</th>
<th class="px-2 py-2">Responsable</th>
<th class="px-2 py-2">Email resp.</th>
<th class="px-2 py-2">R&eacute;f&eacute;rent</th>
<th class="px-2 py-2">Email r&eacute;f.</th>
<th class="px-2 py-2" style="width:60px">Actif</th>
<th class="px-2 py-2" style="width:60px">Srv</th>
<th class="px-2 py-2" style="width:140px">Actions</th>
</tr></thead>
<tbody>
{% for a in assocs %}
<tr>
<form method="post" action="/referentiel/assocs/{{ a.id }}/edit">
<td class="px-2 py-2 text-xs text-gray-500">{{ a.id }}</td>
<td class="px-2 py-2" style="color:#00d4ff;font-weight:600">{{ a.domain_name }}</td>
<td class="px-2 py-2" style="color:#a78bfa">{{ a.env_name }}</td>
<td class="px-2 py-2"><input type="text" name="responsable_nom" value="{{ a.responsable_nom or '' }}" style="width:100%"></td>
<td class="px-2 py-2"><input type="text" name="responsable_email" value="{{ a.responsable_email or '' }}" style="width:100%"></td>
<td class="px-2 py-2"><input type="text" name="referent_nom" value="{{ a.referent_nom or '' }}" style="width:100%"></td>
<td class="px-2 py-2"><input type="text" name="referent_email" value="{{ a.referent_email or '' }}" style="width:100%"></td>
<td class="px-2 py-2 text-center">
<input type="checkbox" name="is_active" {% if a.is_active %}checked{% endif %}>
</td>
<td class="px-2 py-2 text-center">
<span class="badge badge-blue">{{ a.nb_servers or 0 }}</span>
</td>
<td class="px-2 py-2 text-center" style="white-space:nowrap">
<input type="hidden" name="patch_window" value="{{ a.patch_window or '' }}">
<input type="hidden" name="patch_excludes" value="{{ a.patch_excludes or '' }}">
<button type="submit" class="btn-sm" style="background:#00d4ff22;color:#00d4ff;padding:3px 10px">Sauver</button>
</form>
<form method="post" action="/referentiel/assocs/{{ a.id }}/delete" style="display:inline"
onsubmit="return confirm('Supprimer {{ a.domain_name }} x {{ a.env_name }} ?')">
<button type="submit" class="btn-sm" style="background:#ff336622;color:#ff3366;padding:3px 10px">Suppr</button>
</form>
</td>
</tr>
{% endfor %}
{% if not assocs %}
<tr><td colspan="10" class="px-2 py-8 text-center text-gray-500">Aucune association</td></tr>
{% endif %}
</tbody>
</table>
</div>
</div>
{% if can_modify %}
<div class="card mt-3" style="padding:12px 16px">
<h3 class="text-sm font-bold mb-2" style="color:#00ff88">Ajouter une association</h3>
<form method="post" action="/referentiel/assocs/add" style="display:flex;gap:10px;align-items:end;flex-wrap:wrap">
<div>
<label class="text-xs text-gray-500">Domaine *</label>
<select name="domain_id" required style="width:180px">
<option value="">-- Choisir --</option>
{% for d in domains %}<option value="{{ d.id }}">{{ d.name }} ({{ d.code }})</option>{% endfor %}
</select>
</div>
<div>
<label class="text-xs text-gray-500">Environnement *</label>
<select name="environment_id" required style="width:180px">
<option value="">-- Choisir --</option>
{% for e in envs %}<option value="{{ e.id }}">{{ e.name }} ({{ e.code }})</option>{% endfor %}
</select>
</div>
<div>
<label class="text-xs text-gray-500">Responsable</label>
<input type="text" name="responsable_nom" style="width:140px">
</div>
<div>
<label class="text-xs text-gray-500">Email resp.</label>
<input type="email" name="responsable_email" style="width:180px">
</div>
<div>
<label class="text-xs text-gray-500">R&eacute;f&eacute;rent</label>
<input type="text" name="referent_nom" style="width:140px">
</div>
<div>
<label class="text-xs text-gray-500">Email r&eacute;f.</label>
<input type="email" name="referent_email" style="width:180px">
</div>
<button type="submit" class="btn-primary" style="padding:6px 18px;font-size:0.85rem">Ajouter</button>
</form>
</div>
{% endif %}
<!-- ============================================================ -->
<!-- ONGLET ZONES -->
<!-- ============================================================ -->
{% elif tab == 'zones' %}
<div class="card">
<table class="table-cyber w-full">
<thead><tr>
<th class="px-2 py-2" style="width:40px">ID</th>
<th class="px-2 py-2">Nom</th>
<th class="px-2 py-2">Description</th>
<th class="px-2 py-2" style="width:70px">DMZ</th>
<th class="px-2 py-2" style="width:80px">Serveurs</th>
<th class="px-2 py-2" style="width:140px">Actions</th>
</tr></thead>
<tbody>
{% for z in zones %}
<tr>
<form method="post" action="/referentiel/zones/{{ z.id }}/edit">
<td class="px-2 py-2 text-xs text-gray-500">{{ z.id }}</td>
<td class="px-2 py-2"><input type="text" name="name" value="{{ z.name }}" style="width:100%" required></td>
<td class="px-2 py-2"><input type="text" name="description" value="{{ z.description or '' }}" style="width:100%"></td>
<td class="px-2 py-2 text-center">
<input type="checkbox" name="is_dmz" {% if z.is_dmz %}checked{% endif %}>
</td>
<td class="px-2 py-2 text-center">
<span class="badge badge-blue">{{ zone_srv_counts.get(z.id, 0) }}</span>
</td>
<td class="px-2 py-2 text-center" style="white-space:nowrap">
<button type="submit" class="btn-sm" style="background:#00d4ff22;color:#00d4ff;padding:3px 10px">Sauver</button>
</form>
{% if zone_srv_counts.get(z.id, 0) == 0 %}
<form method="post" action="/referentiel/zones/{{ z.id }}/delete" style="display:inline"
onsubmit="return confirm('Supprimer la zone {{ z.name }} ?')">
<button type="submit" class="btn-sm" style="background:#ff336622;color:#ff3366;padding:3px 10px">Suppr</button>
</form>
{% endif %}
</td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
{% if can_modify %}
<div class="card mt-3" style="padding:12px 16px">
<h3 class="text-sm font-bold mb-2" style="color:#00ff88">Ajouter une zone</h3>
<form method="post" action="/referentiel/zones/add" style="display:flex;gap:10px;align-items:end">
<div>
<label class="text-xs text-gray-500">Nom *</label>
<input type="text" name="name" required style="width:160px" placeholder="LAN">
</div>
<div>
<label class="text-xs text-gray-500">Description</label>
<input type="text" name="description" style="width:250px">
</div>
<div style="display:flex;align-items:center;gap:4px;padding-bottom:2px">
<input type="checkbox" name="is_dmz" id="new-dmz">
<label for="new-dmz" class="text-xs text-gray-400">DMZ</label>
</div>
<button type="submit" class="btn-primary" style="padding:6px 18px;font-size:0.85rem">Ajouter</button>
</form>
</div>
{% endif %}
<!-- ============================================================ -->
<!-- ONGLET DOMAINES DNS -->
<!-- ============================================================ -->
{% elif tab == 'dns' %}
<div class="card">
<div class="p-3" style="border-bottom:1px solid #1e3a5f">
<p class="text-xs text-gray-500">Suffixes DNS utilis&eacute;s dans le champ <code>domain_ltd</code> des serveurs (ex: sanef.groupe, sanef-rec.fr)</p>
</div>
<table class="table-cyber w-full">
<thead><tr>
<th class="px-2 py-2" style="width:40px">ID</th>
<th class="px-2 py-2">Nom (suffixe DNS)</th>
<th class="px-2 py-2">Description</th>
<th class="px-2 py-2" style="width:70px">Actif</th>
<th class="px-2 py-2" style="width:80px">Serveurs</th>
<th class="px-2 py-2" style="width:140px">Actions</th>
</tr></thead>
<tbody>
{% for d in dns_domains %}
<tr>
<form method="post" action="/referentiel/dns/{{ d.id }}/edit">
<td class="px-2 py-2 text-xs text-gray-500">{{ d.id }}</td>
<td class="px-2 py-2"><input type="text" name="name" value="{{ d.name }}" style="width:100%" required></td>
<td class="px-2 py-2"><input type="text" name="description" value="{{ d.description or '' }}" style="width:100%"></td>
<td class="px-2 py-2 text-center">
<input type="checkbox" name="is_active" {% if d.is_active %}checked{% endif %}>
</td>
<td class="px-2 py-2 text-center">
<span class="badge badge-blue">{{ dns_srv_counts.get(d.id, 0) }}</span>
</td>
<td class="px-2 py-2 text-center" style="white-space:nowrap">
<button type="submit" class="btn-sm" style="background:#00d4ff22;color:#00d4ff;padding:3px 10px">Sauver</button>
</form>
{% if dns_srv_counts.get(d.id, 0) == 0 %}
<form method="post" action="/referentiel/dns/{{ d.id }}/delete" style="display:inline"
onsubmit="return confirm('Supprimer {{ d.name }} ?')">
<button type="submit" class="btn-sm" style="background:#ff336622;color:#ff3366;padding:3px 10px">Suppr</button>
</form>
{% endif %}
</td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
{% if can_modify %}
<div class="card mt-3" style="padding:12px 16px">
<h3 class="text-sm font-bold mb-2" style="color:#00ff88">Ajouter un domaine DNS</h3>
<form method="post" action="/referentiel/dns/add" style="display:flex;gap:10px;align-items:end">
<div>
<label class="text-xs text-gray-500">Suffixe DNS *</label>
<input type="text" name="name" required style="width:200px" placeholder="sanef.groupe">
</div>
<div>
<label class="text-xs text-gray-500">Description</label>
<input type="text" name="description" style="width:250px" placeholder="Domaine production SANEF">
</div>
<button type="submit" class="btn-primary" style="padding:6px 18px;font-size:0.85rem">Ajouter</button>
</form>
</div>
{% endif %}
{% endif %}
{% endblock %}

View File

@ -2,7 +2,7 @@
{% block title %}Serveurs{% endblock %}
{% macro sort_url(col) -%}
?sort={{ col }}&sort_dir={% if sort == col and sort_dir == 'asc' %}desc{% else %}asc{% endif %}&search={{ filters.search or '' }}&domain={{ filters.domain or '' }}&env={{ filters.env or '' }}&tier={{ filters.tier or '' }}&etat={{ filters.etat or '' }}&page=1
?sort={{ col }}&sort_dir={% if sort == col and sort_dir == 'asc' %}desc{% else %}asc{% endif %}&search={{ filters.search or '' }}&domain={{ filters.domain or '' }}&env={{ filters.env or '' }}&tier={{ filters.tier or '' }}&etat={{ filters.etat or '' }}&os={{ filters.os or '' }}&owner={{ filters.owner or '' }}&page=1
{%- endmacro %}
{% macro sort_icon(col) -%}
@ -10,14 +10,14 @@
{%- endmacro %}
{% macro qs(p) -%}
?page={{ p }}&search={{ filters.search or '' }}&domain={{ filters.domain or '' }}&env={{ filters.env or '' }}&tier={{ filters.tier or '' }}&etat={{ filters.etat or '' }}&sort={{ sort }}&sort_dir={{ sort_dir }}
?page={{ p }}&search={{ filters.search or '' }}&domain={{ filters.domain or '' }}&env={{ filters.env or '' }}&tier={{ filters.tier or '' }}&etat={{ filters.etat or '' }}&os={{ filters.os or '' }}&owner={{ filters.owner or '' }}&sort={{ sort }}&sort_dir={{ sort_dir }}
{%- endmacro %}
{% block content %}
<div class="flex justify-between items-center mb-4">
<h2 class="text-xl font-bold text-cyber-accent">Serveurs <span class="text-sm text-gray-500">({{ total }})</span></h2>
<div class="flex gap-2">
<button class="btn-sm bg-cyber-green text-black" onclick="alert('Export bientot')">Export CSV</button>
<a href="/servers/export-csv?search={{ filters.search or '' }}&domain={{ filters.domain or '' }}&env={{ filters.env or '' }}&tier={{ filters.tier or '' }}&etat={{ filters.etat or '' }}&os={{ filters.os or '' }}&owner={{ filters.owner or '' }}" class="btn-sm bg-cyber-green text-black">Export CSV</a>
</div>
</div>
@ -38,6 +38,15 @@
<select name="etat" onchange="this.form.submit()"><option value="">Etat</option>
{% for e in ['en_production','en_implementation','en_decommissionnement','decommissionne','eteint','eol'] %}<option value="{{ e }}" {% if filters.etat == e %}selected{% endif %}>{{ e.replace("en_","En ").replace("_"," ").title() }}</option>{% endfor %}
</select>
<select name="os" onchange="this.form.submit()"><option value="">OS</option>
<option value="linux" {% if filters.os == 'linux' %}selected{% endif %}>Linux</option>
<option value="windows" {% if filters.os == 'windows' %}selected{% endif %}>Windows</option>
</select>
<select name="owner" onchange="this.form.submit()"><option value="">Owner</option>
<option value="secops" {% if filters.owner == 'secops' %}selected{% endif %}>secops</option>
<option value="ipop" {% if filters.owner == 'ipop' %}selected{% endif %}>ipop</option>
<option value="na" {% if filters.owner == 'na' %}selected{% endif %}>na</option>
</select>
<button type="submit" class="btn-primary px-3 py-1 text-sm">Filtrer</button>
<a href="/servers" class="text-xs text-gray-500 hover:text-gray-300">Reset</a>
</form>
@ -68,7 +77,7 @@ const bulkValues = {
domain_code: [{% for d in domains_list %}{v:"{{ d.code }}", l:"{{ d.name }}"},{% endfor %}],
env_code: [{% for e in envs_list %}{v:"{{ e.code }}", l:"{{ e.name }}"},{% endfor %}],
tier: [{v:"tier0",l:"tier0"},{v:"tier1",l:"tier1"},{v:"tier2",l:"tier2"},{v:"tier3",l:"tier3"}],
etat: [{v:"en_production",l:"en_production"},{v:"en_implementation",l:"en_implementation"},{v:"en_decommissionnement",l:"en_decommissionnement"},{v:"decommissionne",l:"décommissionné"}],
etat: [{v:"en_production",l:"En production"},{v:"en_implementation",l:"En implémentation"},{v:"en_decommissionnement",l:"En décommissionnement"},{v:"decommissionne",l:"Décommissionné"},{v:"eteint",l:"Éteint"},{v:"eol",l:"EOL"}],
patch_os_owner: [{v:"secops",l:"secops"},{v:"ipop",l:"ipop"},{v:"na",l:"na"}],
licence_support: [{v:"active",l:"active"},{v:"eol",l:"eol"},{v:"els",l:"els"}],
};