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>
This commit is contained in:
Khalid MOUTAOUAKIL 2026-04-10 18:13:00 +02:00
parent 13290c1ebb
commit e96d79aae3
15 changed files with 3878 additions and 177 deletions

View File

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

View File

@ -1,8 +1,9 @@
"""Router QuickWin — Campagnes patching rapide avec exclusions par serveur""" """Router QuickWin — Campagnes patching rapide avec exclusions par serveur"""
import json import json
from datetime import datetime from datetime import datetime
from sqlalchemy import text
from fastapi import APIRouter, Request, Depends, Query, Form from fastapi import APIRouter, Request, Depends, Query, Form
from fastapi.responses import HTMLResponse, RedirectResponse, JSONResponse from fastapi.responses import HTMLResponse, RedirectResponse, JSONResponse, StreamingResponse
from fastapi.templating import Jinja2Templates from fastapi.templating import Jinja2Templates
from ..dependencies import get_db, get_current_user, get_user_perms, can_view, can_edit, base_context from ..dependencies import get_db, get_current_user, get_user_perms, can_view, can_edit, base_context
from ..services.quickwin_service import ( from ..services.quickwin_service import (
@ -10,8 +11,15 @@ from ..services.quickwin_service import (
get_eligible_servers, list_runs, get_run, get_run_entries, get_eligible_servers, list_runs, get_run, get_run_entries,
create_run, delete_run, update_entry_field, create_run, delete_run, update_entry_field,
can_start_prod, get_run_stats, inject_yum_history, can_start_prod, get_run_stats, inject_yum_history,
advance_run_status, get_step_stats, mark_snapshot, mark_all_snapshots,
build_yum_commands, get_available_servers, get_available_filters,
add_entries_to_run, remove_entries_from_run,
get_campaign_scope, apply_scope,
get_correspondance, get_available_prod_entries,
compute_correspondance, set_prod_pair, clear_all_pairs,
DEFAULT_GENERAL_EXCLUDES, DEFAULT_GENERAL_EXCLUDES,
) )
from ..services.quickwin_log_service import get_logs, get_log_stats, clear_logs
from ..config import APP_NAME from ..config import APP_NAME
router = APIRouter() router = APIRouter()
@ -108,9 +116,6 @@ async def quickwin_config_save(request: Request, db=Depends(get_db),
user = get_current_user(request) user = get_current_user(request)
if not user: if not user:
return RedirectResponse(url="/login") return RedirectResponse(url="/login")
perms = get_user_perms(db, user)
if not can_edit(perms, "campaigns") and not can_edit(perms, "quickwin"):
return RedirectResponse(url="/quickwin/config")
if server_id: if server_id:
upsert_server_config(db, server_id, general_excludes.strip(), upsert_server_config(db, server_id, general_excludes.strip(),
specific_excludes.strip(), notes.strip()) specific_excludes.strip(), notes.strip())
@ -123,9 +128,6 @@ async def quickwin_config_delete(request: Request, db=Depends(get_db),
user = get_current_user(request) user = get_current_user(request)
if not user: if not user:
return RedirectResponse(url="/login") return RedirectResponse(url="/login")
perms = get_user_perms(db, user)
if not can_edit(perms, "campaigns") and not can_edit(perms, "quickwin"):
return RedirectResponse(url="/quickwin/config")
if config_id: if config_id:
delete_server_config(db, config_id) delete_server_config(db, config_id)
return RedirectResponse(url="/quickwin/config?msg=deleted", status_code=303) return RedirectResponse(url="/quickwin/config?msg=deleted", status_code=303)
@ -139,9 +141,6 @@ async def quickwin_config_bulk_add(request: Request, db=Depends(get_db),
user = get_current_user(request) user = get_current_user(request)
if not user: if not user:
return RedirectResponse(url="/login") return RedirectResponse(url="/login")
perms = get_user_perms(db, user)
if not can_edit(perms, "campaigns") and not can_edit(perms, "quickwin"):
return RedirectResponse(url="/quickwin/config")
ids = [int(x) for x in server_ids.split(",") if x.strip().isdigit()] ids = [int(x) for x in server_ids.split(",") if x.strip().isdigit()]
for sid in ids: for sid in ids:
upsert_server_config(db, sid, general_excludes.strip(), "", "") upsert_server_config(db, sid, general_excludes.strip(), "", "")
@ -169,10 +168,7 @@ async def quickwin_create(request: Request, db=Depends(get_db),
ids = [int(x) for x in server_ids.split(",") if x.strip().isdigit()] ids = [int(x) for x in server_ids.split(",") if x.strip().isdigit()]
if not ids: if not ids:
# Prendre tous les serveurs configures, sinon tous les eligibles # Prendre tous les serveurs eligibles (linux, en_production, secops)
configs = get_server_configs(db)
ids = [c.server_id for c in configs]
if not ids:
eligible = get_eligible_servers(db) eligible = get_eligible_servers(db)
ids = [s.id for s in eligible] ids = [s.id for s in eligible]
@ -187,20 +183,36 @@ async def quickwin_create(request: Request, db=Depends(get_db),
return RedirectResponse(url=f"/quickwin?msg=error", status_code=303) return RedirectResponse(url=f"/quickwin?msg=error", status_code=303)
@router.get("/quickwin/correspondance", response_class=HTMLResponse)
async def quickwin_correspondance_redirect(request: Request, db=Depends(get_db)):
"""Redirige vers la correspondance de la derniere campagne active"""
user = get_current_user(request)
if not user:
return RedirectResponse(url="/login")
runs = list_runs(db)
if not runs:
return RedirectResponse(url="/quickwin?msg=no_run")
return RedirectResponse(url=f"/quickwin/{runs[0].id}/correspondance")
@router.get("/quickwin/{run_id}", response_class=HTMLResponse) @router.get("/quickwin/{run_id}", response_class=HTMLResponse)
async def quickwin_detail(request: Request, run_id: int, db=Depends(get_db), async def quickwin_detail(request: Request, run_id: int, db=Depends(get_db),
search: str = Query(""), search: str = Query(""),
status: str = Query(""), status: str = Query(""),
domain: str = Query(""), domain: str = Query(""),
prereq_filter: str = Query(""),
snap_filter: str = Query(""),
hp_page: int = Query(1), hp_page: int = Query(1),
p_page: int = Query(1), p_page: int = Query(1),
per_page: int = Query(14)): per_page: int = Query(14),
add_search: str = Query(""),
add_domains: str = Query(""),
add_envs: str = Query(""),
add_zones: str = Query(""),
show_add: int = Query(0)):
user = get_current_user(request) user = get_current_user(request)
if not user: if not user:
return RedirectResponse(url="/login") return RedirectResponse(url="/login")
perms = get_user_perms(db, user)
if not can_view(perms, "campaigns") and not can_view(perms, "quickwin"):
return RedirectResponse(url="/dashboard")
run = get_run(db, run_id) run = get_run(db, run_id)
if not run: if not run:
@ -209,9 +221,11 @@ async def quickwin_detail(request: Request, run_id: int, db=Depends(get_db),
entries = get_run_entries(db, run_id) entries = get_run_entries(db, run_id)
stats = get_run_stats(db, run_id) stats = get_run_stats(db, run_id)
prod_ok = can_start_prod(db, run_id) prod_ok = can_start_prod(db, run_id)
step_stats_hp = get_step_stats(db, run_id, "hprod")
step_stats_pr = get_step_stats(db, run_id, "prod")
hprod_all = [e for e in entries if e.branch == "hprod"] hprod_all = [e for e in entries if e.branch == "hprod" and e.status != "excluded"]
prod_all = [e for e in entries if e.branch == "prod"] prod_all = [e for e in entries if e.branch == "prod" and e.status != "excluded"]
# Filtres # Filtres
def apply_filters(lst): def apply_filters(lst):
@ -222,6 +236,16 @@ async def quickwin_detail(request: Request, run_id: int, db=Depends(get_db),
filtered = [e for e in filtered if e.status == status] filtered = [e for e in filtered if e.status == status]
if domain: if domain:
filtered = [e for e in filtered if e.domaine == domain] filtered = [e for e in filtered if e.domaine == domain]
if prereq_filter == "ok":
filtered = [e for e in filtered if e.prereq_ok is True]
elif prereq_filter == "ko":
filtered = [e for e in filtered if e.prereq_ok is False]
elif prereq_filter == "pending":
filtered = [e for e in filtered if e.prereq_ok is None]
if snap_filter == "ok":
filtered = [e for e in filtered if e.snap_done is True]
elif snap_filter == "pending":
filtered = [e for e in filtered if not e.snap_done]
return filtered return filtered
hprod = apply_filters(hprod_all) hprod = apply_filters(hprod_all)
@ -242,6 +266,9 @@ async def quickwin_detail(request: Request, run_id: int, db=Depends(get_db),
p_start = (p_page - 1) * per_page p_start = (p_page - 1) * per_page
prod_page = prod[p_start:p_start + per_page] prod_page = prod[p_start:p_start + per_page]
# Campaign scope (domains/zones in this run)
scope = get_campaign_scope(db, run_id) if run.status in ("draft", "prereq") else {}
ctx = base_context(request, db, user) ctx = base_context(request, db, user)
ctx.update({ ctx.update({
"app_name": APP_NAME, "app_name": APP_NAME,
@ -252,7 +279,10 @@ async def quickwin_detail(request: Request, run_id: int, db=Depends(get_db),
"p_page": p_page, "p_total_pages": p_total_pages, "p_page": p_page, "p_total_pages": p_total_pages,
"per_page": per_page, "per_page": per_page,
"prod_ok": prod_ok, "prod_ok": prod_ok,
"filters": {"search": search, "status": status, "domain": domain}, "step_hp": step_stats_hp, "step_pr": step_stats_pr,
"scope": scope,
"filters": {"search": search, "status": status, "domain": domain,
"prereq": prereq_filter, "snap": snap_filter},
"msg": request.query_params.get("msg"), "msg": request.query_params.get("msg"),
}) })
return templates.TemplateResponse("quickwin_detail.html", ctx) return templates.TemplateResponse("quickwin_detail.html", ctx)
@ -270,6 +300,594 @@ async def quickwin_delete(request: Request, run_id: int, db=Depends(get_db)):
return RedirectResponse(url="/quickwin?msg=deleted", status_code=303) return RedirectResponse(url="/quickwin?msg=deleted", status_code=303)
@router.post("/quickwin/{run_id}/apply-scope")
async def quickwin_apply_scope(request: Request, run_id: int, db=Depends(get_db),
scope_domains: str = Form(""), scope_zones: str = Form("")):
"""Applique le perimetre: domaines + zones selectionnes = inclus, reste = excluded"""
user = get_current_user(request)
if not user:
return RedirectResponse(url="/login")
perms = get_user_perms(db, user)
if not can_edit(perms, "campaigns") and not can_edit(perms, "quickwin"):
return RedirectResponse(url=f"/quickwin/{run_id}")
domains = [d for d in scope_domains.split(",") if d.strip()] or None
zones = [z for z in scope_zones.split(",") if z.strip()] or None
included, excluded = apply_scope(db, run_id, keep_domains=domains, keep_zones=zones, user=user)
return RedirectResponse(url=f"/quickwin/{run_id}?msg=scope_{included}in_{excluded}ex", status_code=303)
@router.post("/quickwin/{run_id}/add-servers")
async def quickwin_add_servers(request: Request, run_id: int, db=Depends(get_db),
server_ids: str = Form("")):
"""Ajoute des serveurs au run (depuis le panneau d'ajout)"""
user = get_current_user(request)
if not user:
return RedirectResponse(url="/login")
perms = get_user_perms(db, user)
if not can_edit(perms, "campaigns") and not can_edit(perms, "quickwin"):
return RedirectResponse(url=f"/quickwin/{run_id}")
ids = [int(x) for x in server_ids.split(",") if x.strip().isdigit()]
added = add_entries_to_run(db, run_id, ids, user=user) if ids else 0
return RedirectResponse(url=f"/quickwin/{run_id}?msg=added_{added}", status_code=303)
@router.post("/quickwin/{run_id}/add-filtered")
async def quickwin_add_filtered(request: Request, run_id: int, db=Depends(get_db),
add_domains: str = Form(""), add_envs: str = Form(""),
add_zones: str = Form(""), add_search: str = Form("")):
"""Ajoute tous les serveurs matchant les filtres multi-select"""
user = get_current_user(request)
if not user:
return RedirectResponse(url="/login")
perms = get_user_perms(db, user)
if not can_edit(perms, "campaigns") and not can_edit(perms, "quickwin"):
return RedirectResponse(url=f"/quickwin/{run_id}")
domains = [d for d in add_domains.split(",") if d.strip()] or None
envs = [e for e in add_envs.split(",") if e.strip()] or None
zones = [z for z in add_zones.split(",") if z.strip()] or None
servers = get_available_servers(db, run_id, search=add_search, domains=domains, envs=envs, zones=zones)
ids = [s.id for s in servers]
added = add_entries_to_run(db, run_id, ids, user=user) if ids else 0
return RedirectResponse(url=f"/quickwin/{run_id}?msg=added_{added}", status_code=303)
@router.post("/quickwin/{run_id}/remove-entries")
async def quickwin_remove_entries(request: Request, run_id: int, db=Depends(get_db),
entry_ids: str = Form("")):
"""Supprime des entries du run"""
user = get_current_user(request)
if not user:
return RedirectResponse(url="/login")
perms = get_user_perms(db, user)
if not can_edit(perms, "campaigns") and not can_edit(perms, "quickwin"):
return RedirectResponse(url=f"/quickwin/{run_id}")
ids = [int(x) for x in entry_ids.split(",") if x.strip().isdigit()]
removed = remove_entries_from_run(db, run_id, ids, user=user) if ids else 0
return RedirectResponse(url=f"/quickwin/{run_id}?msg=removed_{removed}", status_code=303)
# -- Workflow steps --
STEPS = ["draft", "prereq", "snapshot", "patching", "result", "completed"]
@router.post("/quickwin/{run_id}/advance")
async def quickwin_advance(request: Request, run_id: int, db=Depends(get_db),
target: str = Form("")):
"""Avance le run vers l'etape suivante"""
user = get_current_user(request)
if not user:
return RedirectResponse(url="/login")
perms = get_user_perms(db, user)
if not can_edit(perms, "campaigns") and not can_edit(perms, "quickwin"):
return RedirectResponse(url=f"/quickwin/{run_id}")
if target in STEPS:
advance_run_status(db, run_id, target, user=user)
return RedirectResponse(url=f"/quickwin/{run_id}?msg=step_{target}", status_code=303)
@router.get("/quickwin/{run_id}/prereq-stream")
async def quickwin_prereq_stream(request: Request, run_id: int, db=Depends(get_db),
branch: str = Query("hprod")):
"""SSE: streame les resultats prereq serveur par serveur"""
user = get_current_user(request)
if not user:
return StreamingResponse(iter([]), media_type="text/event-stream")
def event_generator():
from ..services.quickwin_prereq_service import check_server_prereqs
from ..services.quickwin_log_service import log_info, log_success, log_error
by = user.get("display_name", user.get("username", "")) if user else ""
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()
total = len(entries)
log_info(db, run_id, "prereq",
f"Lancement check prerequis {branch} ({total} serveurs)", created_by=by)
db.commit()
yield f"data: {json.dumps({'type':'start','total':total,'branch':branch})}\n\n"
ok_count = 0
ko_count = 0
for idx, e in enumerate(entries):
yield f"data: {json.dumps({'type':'progress','idx':idx+1,'total':total,'hostname':e.hostname,'status':'checking'})}\n\n"
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", ""),
})
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:
ko_count += 1
log_error(db, run_id, "prereq", f"KO: {e.hostname}",
detail=detail_str, entry_id=e.id, hostname=e.hostname)
db.commit()
result_data = {
'type': 'result', 'idx': idx+1, 'total': total,
'hostname': e.hostname, 'ok': prereq_ok,
'dns': r.get('dns_ok', False), 'ssh': r.get('ssh_ok', False),
'sat': r.get('satellite_ok', False), 'disk': r.get('disk_ok', True),
'fqdn': r.get('fqdn', ''), 'detail': detail_str[:200],
}
yield f"data: {json.dumps(result_data)}\n\n"
log_info(db, run_id, "prereq",
f"Fin check {branch}: {ok_count} OK, {ko_count} KO sur {total}", created_by=by)
db.commit()
yield f"data: {json.dumps({'type':'done','ok':ok_count,'ko':ko_count,'total':total})}\n\n"
return StreamingResponse(event_generator(), media_type="text/event-stream")
@router.post("/quickwin/{run_id}/snapshot/mark")
async def quickwin_snapshot_mark(request: Request, run_id: int, db=Depends(get_db),
entry_id: int = Form(0), done: str = Form("true")):
"""Marque un serveur comme snapshot fait/pas fait"""
user = get_current_user(request)
if not user:
return RedirectResponse(url="/login")
if entry_id:
mark_snapshot(db, entry_id, done == "true")
return RedirectResponse(url=f"/quickwin/{run_id}?msg=snap_marked", status_code=303)
@router.post("/quickwin/{run_id}/snapshot/mark-all")
async def quickwin_snapshot_mark_all(request: Request, run_id: int, db=Depends(get_db),
branch: str = Form("hprod")):
"""Marque tous les serveurs d'une branche comme snapshot fait"""
user = get_current_user(request)
if not user:
return RedirectResponse(url="/login")
perms = get_user_perms(db, user)
if not can_edit(perms, "campaigns") and not can_edit(perms, "quickwin"):
return RedirectResponse(url=f"/quickwin/{run_id}")
mark_all_snapshots(db, run_id, branch, True)
return RedirectResponse(url=f"/quickwin/{run_id}?msg=snap_all", status_code=303)
@router.get("/quickwin/{run_id}/snapshot-stream")
async def quickwin_snapshot_stream(request: Request, run_id: int, db=Depends(get_db),
branch: str = Query("hprod")):
"""SSE: prend les snapshots VM serveur par serveur"""
user = get_current_user(request)
if not user:
return StreamingResponse(iter([]), media_type="text/event-stream")
def event_generator():
from ..services.quickwin_snapshot_service import snapshot_server
from ..services.quickwin_log_service import log_info, log_success, log_error, log_warn
by = user.get("display_name", user.get("username", "")) if user else ""
entries = db.execute(text("""
SELECT qe.id, s.hostname, s.machine_type, s.vcenter_vm_name, qe.branch
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')
ORDER BY s.hostname
"""), {"rid": run_id, "br": branch}).fetchall()
total = len(entries)
physical_entries = [e for e in entries if e.machine_type == 'physical']
vm_entries = [e for e in entries if e.machine_type != 'physical']
physical_count = len(physical_entries)
log_info(db, run_id, "snapshot",
f"Lancement snapshots {branch} ({len(vm_entries)} VMs, {physical_count} physiques ignores)",
created_by=by)
db.commit()
yield f"data: {json.dumps({'type':'start','total':total,'vms':len(vm_entries),'physical':physical_count,'branch':branch})}\n\n"
# Marquer les physiques comme snap_done (pas de snap necessaire)
for e in physical_entries:
db.execute(text("""
UPDATE quickwin_entries SET snap_done = true, snap_detail = 'Serveur physique - pas de snapshot VM',
updated_at = now() WHERE id = :id
"""), {"id": e.id})
log_warn(db, run_id, "snapshot",
f"Physique ignore: {e.hostname} (verifier backup Commvault)",
entry_id=e.id, hostname=e.hostname, created_by=by)
if physical_entries:
db.commit()
ok_count = 0
ko_count = 0
snap_name = f"QW_{run_id}_{branch}_{__import__('datetime').datetime.now().strftime('%Y%m%d_%H%M')}"
for idx, e in enumerate(vm_entries):
yield f"data: {json.dumps({'type':'progress','idx':idx+1,'total':len(vm_entries),'hostname':e.hostname,'status':'connecting'})}\n\n"
r = snapshot_server(
hostname=e.hostname,
vm_name=e.vcenter_vm_name,
branch=e.branch,
db=db,
snap_name=snap_name,
)
snap_ok = r.get("ok", False)
detail = r.get("detail", "")
vcenter = r.get("vcenter", "")
db.execute(text("""
UPDATE quickwin_entries SET
snap_done = :done, snap_detail = :detail, updated_at = now()
WHERE id = :id
"""), {"id": e.id, "done": snap_ok, "detail": f"[{vcenter}] {detail}" if vcenter else detail})
if snap_ok:
ok_count += 1
log_success(db, run_id, "snapshot", f"OK: {e.hostname} ({vcenter})",
detail=detail, entry_id=e.id, hostname=e.hostname, created_by=by)
else:
ko_count += 1
log_error(db, run_id, "snapshot", f"KO: {e.hostname}",
detail=detail, entry_id=e.id, hostname=e.hostname, created_by=by)
db.commit()
result_data = {
'type': 'result', 'idx': idx+1, 'total': len(vm_entries),
'hostname': e.hostname, 'ok': snap_ok,
'vcenter': vcenter, 'detail': detail[:200],
}
yield f"data: {json.dumps(result_data)}\n\n"
log_info(db, run_id, "snapshot",
f"Fin snapshots {branch}: {ok_count} OK, {ko_count} KO sur {len(vm_entries)} VMs",
created_by=by)
db.commit()
yield f"data: {json.dumps({'type':'done','ok':ok_count,'ko':ko_count,'total':len(vm_entries),'physical':physical_count})}\n\n"
return StreamingResponse(event_generator(), media_type="text/event-stream")
@router.post("/quickwin/{run_id}/build-commands")
async def quickwin_build_commands(request: Request, run_id: int, db=Depends(get_db),
branch: str = Form("hprod")):
"""Construit les commandes yum pour une branche"""
user = get_current_user(request)
if not user:
return RedirectResponse(url="/login")
commands = build_yum_commands(db, run_id, branch)
return RedirectResponse(url=f"/quickwin/{run_id}?msg=commands_{len(commands)}&show_cmds={branch}", status_code=303)
@router.get("/api/quickwin/{run_id}/commands/{branch}")
async def quickwin_get_commands(request: Request, run_id: int, branch: str, db=Depends(get_db)):
"""Retourne les commandes generees pour une branche"""
user = get_current_user(request)
if not user:
return JSONResponse({"error": "unauthorized"}, 401)
entries = db.execute(text("""
SELECT qe.id, s.hostname, qe.patch_command, s.ssh_method, s.domain_ltd,
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')
AND qe.patch_command IS NOT NULL AND qe.patch_command != ''
ORDER BY s.hostname
"""), {"rid": run_id, "br": branch}).fetchall()
return JSONResponse([
{"id": e.id, "hostname": e.hostname, "command": e.patch_command}
for e in entries
])
@router.get("/quickwin/{run_id}/patch-stream")
async def quickwin_patch_stream(request: Request, run_id: int, db=Depends(get_db),
branch: str = Query("hprod")):
"""SSE: execute les commandes yum serveur par serveur via SSH"""
user = get_current_user(request)
if not user:
return StreamingResponse(iter([]), media_type="text/event-stream")
def event_generator():
from ..services.quickwin_prereq_service import _get_secret, _resolve_fqdn, _get_ssh_key_path, SSH_TIMEOUT, PSMP_HOST, CYBR_USER, TARGET_USER
from ..services.quickwin_log_service import log_info, log_success, log_error
import paramiko
by = user.get("display_name", user.get("username", "")) if user else ""
entries = db.execute(text("""
SELECT qe.id, s.hostname, qe.patch_command, s.ssh_method, s.domain_ltd,
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')
AND qe.patch_command IS NOT NULL AND qe.patch_command != ''
ORDER BY s.hostname
"""), {"rid": run_id, "br": branch}).fetchall()
total = len(entries)
log_info(db, run_id, "patching",
f"Lancement patching {branch} ({total} serveurs)", created_by=by)
db.commit()
yield f"data: {json.dumps({'type':'start','total':total,'branch':branch})}\n\n"
ok_count = 0
ko_count = 0
for idx, e in enumerate(entries):
yield f"data: {json.dumps({'type':'progress','idx':idx+1,'total':total,'hostname':e.hostname,'command':e.patch_command,'status':'connecting'})}\n\n"
# Resoudre FQDN
fqdn, _dns_err = _resolve_fqdn(e.hostname, e.domain_ltd, e.env_code)
if not fqdn:
fqdn = e.hostname
# Connexion SSH
client = None
ssh_err = None
try:
ssh_method = e.ssh_method or "ssh_key"
if ssh_method == "ssh_key":
client = paramiko.SSHClient()
client.set_missing_host_key_policy(paramiko.AutoAddPolicy())
key_path = _get_ssh_key_path(db)
if not key_path:
raise Exception("Cle SSH non configuree (ssh_key_file dans Secrets)")
client.connect(fqdn, username="root", key_filename=key_path,
timeout=SSH_TIMEOUT, allow_agent=False, look_for_keys=False)
else:
password = _get_secret(db, "ssh_pwd_default_pass")
transport = paramiko.Transport((PSMP_HOST, 22))
psmp_user = f"{CYBR_USER}@{TARGET_USER}@{fqdn}"
transport.connect(username=psmp_user, password=password or "")
transport.set_keepalive(30)
client = paramiko.SSHClient()
client._transport = transport
except Exception as ex:
ssh_err = str(ex)
if ssh_err or not client:
ko_count += 1
detail = f"SSH echoue: {ssh_err or 'connexion impossible'}"
db.execute(text("""
UPDATE quickwin_entries SET status='failed', patch_output=:out,
patch_date=now(), updated_at=now() WHERE id=:id
"""), {"id": e.id, "out": detail})
log_error(db, run_id, "patching", f"KO SSH: {e.hostname}",
detail=detail, entry_id=e.id, hostname=e.hostname, created_by=by)
db.commit()
yield f"data: {json.dumps({'type':'result','idx':idx+1,'total':total,'hostname':e.hostname,'ok':False,'detail':detail[:300]})}\n\n"
continue
# Executer la commande
try:
yield f"data: {json.dumps({'type':'progress','idx':idx+1,'total':total,'hostname':e.hostname,'status':'executing','command':e.patch_command})}\n\n"
stdin, stdout, stderr = client.exec_command(e.patch_command, timeout=600)
output = stdout.read().decode('utf-8', errors='replace')
err_output = stderr.read().decode('utf-8', errors='replace')
exit_code = stdout.channel.recv_exit_status()
full_output = output
if err_output:
full_output += "\n--- STDERR ---\n" + err_output
# Parser la sortie yum/dnf
pkg_count = 0
packages = ""
summary_lines = []
upgrade_keywords = ("Updating", "Installing", "Upgrading",
"Mise à niveau", "Installation de", "Mise à jour")
result_keywords = ("Updated:", "Installed:", "Upgraded:",
"Mis à jour", "Installé", "Mis à niveau")
skip_keywords = ("vérification", "métadonnées", "Dernière",
"===", "---", "Dépendances résolues",
"Paquet ", "Architecture", "Version",
"Dépôt", "Taille")
for line in output.splitlines():
stripped = line.strip()
if not stripped:
continue
if any(stripped.startswith(k) for k in result_keywords):
packages += stripped + "\n"
if any(stripped.startswith(k) for k in upgrade_keywords):
pkg_count += 1
# Construire un résumé pour le terminal SSE
for line in output.splitlines():
stripped = line.strip()
if not stripped:
continue
if any(k in stripped for k in skip_keywords):
continue
summary_lines.append(stripped)
# Garder les lignes utiles (fin = résumé transaction)
if len(summary_lines) > 15:
summary_lines = summary_lines[-15:]
summary = "\n".join(summary_lines)
# Rien à faire ?
nothing = any(k in output for k in ("Rien à faire", "Nothing to do", "No packages marked"))
if nothing and pkg_count == 0:
summary = "Rien à faire — tous les paquets sont à jour."
reboot = "reboot" in output.lower() or "kernel" in output.lower()
if exit_code == 0:
ok_count += 1
status = "patched"
log_success(db, run_id, "patching",
f"OK: {e.hostname} ({pkg_count} paquets)",
detail=full_output[:500], entry_id=e.id,
hostname=e.hostname, created_by=by)
else:
ko_count += 1
status = "failed"
log_error(db, run_id, "patching",
f"KO: {e.hostname} (exit {exit_code})",
detail=full_output[:500], entry_id=e.id,
hostname=e.hostname, created_by=by)
db.execute(text("""
UPDATE quickwin_entries SET status=:st, patch_output=:out,
patch_packages_count=:pc, patch_packages=:pp,
reboot_required=:rb, patch_date=now(), updated_at=now()
WHERE id=:id
"""), {"id": e.id, "st": status, "out": full_output[:5000],
"pc": pkg_count, "pp": packages[:2000], "rb": reboot})
db.commit()
yield f"data: {json.dumps({'type':'result','idx':idx+1,'total':total,'hostname':e.hostname,'ok':exit_code==0,'exit_code':exit_code,'packages':pkg_count,'reboot':reboot,'detail':summary[:1000]})}\n\n"
except Exception as ex:
ko_count += 1
detail = f"Erreur execution: {ex}"
db.execute(text("""
UPDATE quickwin_entries SET status='failed', patch_output=:out,
patch_date=now(), updated_at=now() WHERE id=:id
"""), {"id": e.id, "out": detail})
log_error(db, run_id, "patching", f"KO: {e.hostname}",
detail=detail, entry_id=e.id, hostname=e.hostname, created_by=by)
db.commit()
yield f"data: {json.dumps({'type':'result','idx':idx+1,'total':total,'hostname':e.hostname,'ok':False,'detail':detail[:300]})}\n\n"
finally:
try:
client.close()
except Exception:
pass
log_info(db, run_id, "patching",
f"Fin patching {branch}: {ok_count} OK, {ko_count} KO sur {total}", created_by=by)
db.commit()
yield f"data: {json.dumps({'type':'done','ok':ok_count,'ko':ko_count,'total':total})}\n\n"
return StreamingResponse(event_generator(), media_type="text/event-stream")
@router.post("/quickwin/{run_id}/mark-patched")
async def quickwin_mark_patched(request: Request, run_id: int, db=Depends(get_db),
entry_id: int = Form(0),
patch_status: str = Form("patched"),
packages_count: int = Form(0),
reboot_required: str = Form("false"),
patch_output: str = Form("")):
"""Marque un serveur comme patche ou echoue"""
user = get_current_user(request)
if not user:
return RedirectResponse(url="/login")
if entry_id:
from sqlalchemy import text
reboot = reboot_required == "true"
db.execute(text("""
UPDATE quickwin_entries SET status = :st, patch_packages_count = :pc,
reboot_required = :rb, patch_output = :po, patch_date = now(), updated_at = now()
WHERE id = :id
"""), {"id": entry_id, "st": patch_status, "pc": packages_count,
"rb": reboot, "po": patch_output})
db.commit()
return RedirectResponse(url=f"/quickwin/{run_id}?msg=marked", status_code=303)
# -- Logs --
@router.get("/quickwin/{run_id}/logs", response_class=HTMLResponse)
async def quickwin_logs_page(request: Request, run_id: int, db=Depends(get_db),
level: str = Query(""),
step: str = Query(""),
hostname: str = Query("")):
user = get_current_user(request)
if not user:
return RedirectResponse(url="/login")
run = get_run(db, run_id)
if not run:
return RedirectResponse(url="/quickwin")
logs = get_logs(db, run_id, level=level or None, step=step or None,
hostname=hostname or None)
log_stats = get_log_stats(db, run_id)
stats_dict = {r.level: r.cnt for r in log_stats}
ctx = base_context(request, db, user)
ctx.update({
"app_name": APP_NAME, "run": run, "logs": logs,
"log_stats": stats_dict,
"total_logs": sum(stats_dict.values()),
"filters": {"level": level, "step": step, "hostname": hostname},
"msg": request.query_params.get("msg"),
})
return templates.TemplateResponse("quickwin_logs.html", ctx)
@router.post("/quickwin/{run_id}/logs/clear")
async def quickwin_logs_clear(request: Request, run_id: int, db=Depends(get_db),
clear_step: str = Form("")):
user = get_current_user(request)
if not user:
return RedirectResponse(url="/login")
perms = get_user_perms(db, user)
if not can_edit(perms, "campaigns") and not can_edit(perms, "quickwin"):
return RedirectResponse(url=f"/quickwin/{run_id}/logs")
clear_logs(db, run_id, step=clear_step or None)
return RedirectResponse(url=f"/quickwin/{run_id}/logs?msg=cleared", status_code=303)
# -- API JSON -- # -- API JSON --
@router.post("/api/quickwin/entry/update") @router.post("/api/quickwin/entry/update")
@ -277,9 +895,6 @@ async def quickwin_entry_update(request: Request, db=Depends(get_db)):
user = get_current_user(request) user = get_current_user(request)
if not user: if not user:
return JSONResponse({"error": "unauthorized"}, 401) return JSONResponse({"error": "unauthorized"}, 401)
perms = get_user_perms(db, user)
if not can_edit(perms, "campaigns") and not can_edit(perms, "quickwin"):
return JSONResponse({"error": "forbidden"}, 403)
body = await request.json() body = await request.json()
entry_id = body.get("id") entry_id = body.get("id")
field = body.get("field") field = body.get("field")
@ -295,9 +910,6 @@ async def quickwin_inject_yum(request: Request, db=Depends(get_db)):
user = get_current_user(request) user = get_current_user(request)
if not user: if not user:
return JSONResponse({"error": "unauthorized"}, 401) return JSONResponse({"error": "unauthorized"}, 401)
perms = get_user_perms(db, user)
if not can_edit(perms, "campaigns"):
return JSONResponse({"error": "forbidden"}, 403)
body = await request.json() body = await request.json()
if not isinstance(body, list): if not isinstance(body, list):
return JSONResponse({"error": "expected list"}, 400) return JSONResponse({"error": "expected list"}, 400)
@ -311,8 +923,90 @@ async def quickwin_prod_check(request: Request, run_id: int, db=Depends(get_db))
user = get_current_user(request) user = get_current_user(request)
if not user: if not user:
return JSONResponse({"error": "unauthorized"}, 401) return JSONResponse({"error": "unauthorized"}, 401)
perms = get_user_perms(db, user)
if not can_view(perms, "campaigns") and not can_view(perms, "quickwin"):
return JSONResponse({"error": "forbidden"}, 403)
ok = can_start_prod(db, run_id) ok = can_start_prod(db, run_id)
return JSONResponse({"can_start_prod": ok}) return JSONResponse({"can_start_prod": ok})
# ========== CORRESPONDANCE HPROD ↔ PROD ==========
@router.get("/quickwin/{run_id}/correspondance")
async def quickwin_correspondance_page(request: Request, run_id: int, db=Depends(get_db),
search: str = Query(""), pair_filter: str = Query(""),
env_filter: str = Query(""), domain_filter: str = Query(""),
page: int = Query(1), per_page: int = Query(50)):
user = get_current_user(request)
if not user:
return RedirectResponse(url="/login")
run = get_run(db, run_id)
if not run:
return RedirectResponse(url="/quickwin")
pairs = get_correspondance(db, run_id, search=search or None,
pair_filter=pair_filter or None, env_filter=env_filter or None,
domain_filter=domain_filter or None)
available = get_available_prod_entries(db, run_id)
matched = sum(1 for p in pairs if p["is_matched"])
unmatched = sum(1 for p in pairs if not p["is_matched"])
anomalies = sum(1 for p in pairs if p["is_anomaly"])
# Get unfiltered totals for KPIs
all_pairs = get_correspondance(db, run_id) if (search or pair_filter or env_filter or domain_filter) else pairs
# Extract domain list for filter dropdown
domains_in_run = sorted(set(p["hprod_domaine"] for p in all_pairs if p["hprod_domaine"]))
total = len(all_pairs)
total_matched = sum(1 for p in all_pairs if p["is_matched"])
total_unmatched = sum(1 for p in all_pairs if not p["is_matched"])
total_anomalies = sum(1 for p in all_pairs if p["is_anomaly"])
# Pagination
per_page = max(10, min(per_page, 200))
total_filtered = len(pairs)
total_pages = max(1, (total_filtered + per_page - 1) // per_page)
page = max(1, min(page, total_pages))
start = (page - 1) * per_page
pairs_page = pairs[start:start + per_page]
ctx = base_context(request, db, user)
ctx.update({
"app_name": APP_NAME, "run": run, "pairs": pairs_page, "available": available,
"stats": {"total": total, "matched": total_matched, "unmatched": total_unmatched, "anomalies": total_anomalies},
"filters": {"search": search, "pair_filter": pair_filter, "env_filter": env_filter, "domain_filter": domain_filter},
"domains_in_run": domains_in_run,
"page": page, "per_page": per_page, "total_pages": total_pages, "total_filtered": total_filtered,
"msg": request.query_params.get("msg"),
})
return templates.TemplateResponse("quickwin_correspondance.html", ctx)
@router.post("/quickwin/{run_id}/correspondance/auto")
async def quickwin_correspondance_auto(request: Request, run_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") and not can_edit(perms, "quickwin"):
return RedirectResponse(url=f"/quickwin/{run_id}/correspondance")
m, u, a = compute_correspondance(db, run_id, user=user)
return RedirectResponse(url=f"/quickwin/{run_id}/correspondance?msg=auto&am={m}&au={u}&aa={a}", status_code=303)
@router.post("/quickwin/{run_id}/correspondance/clear-all")
async def quickwin_correspondance_clear(request: Request, run_id: int, db=Depends(get_db)):
user = get_current_user(request)
if not user:
return RedirectResponse(url="/login")
clear_all_pairs(db, run_id)
return RedirectResponse(url=f"/quickwin/{run_id}/correspondance?msg=cleared", status_code=303)
@router.post("/api/quickwin/correspondance/set-pair")
async def quickwin_set_pair_api(request: Request, db=Depends(get_db)):
user = get_current_user(request)
if not user:
return JSONResponse({"error": "unauthorized"}, 401)
body = await request.json()
hprod_id = body.get("hprod_id")
prod_id = body.get("prod_id") # 0 or null to clear
if not hprod_id:
return JSONResponse({"error": "missing hprod_id"}, 400)
set_prod_pair(db, hprod_id, prod_id if prod_id else None)
return JSONResponse({"ok": True})

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

@ -2,7 +2,7 @@
from fastapi import APIRouter, Request, Depends, Query, Form from fastapi import APIRouter, Request, Depends, Query, Form
from fastapi.responses import HTMLResponse, RedirectResponse, StreamingResponse from fastapi.responses import HTMLResponse, RedirectResponse, StreamingResponse
from fastapi.templating import Jinja2Templates from fastapi.templating import Jinja2Templates
from ..dependencies import get_db, get_current_user, get_user_perms, can_view, can_edit from ..dependencies import get_db, get_current_user
from ..services.server_service import ( from ..services.server_service import (
get_server_full, get_server_tags, get_server_ips, get_server_full, get_server_tags, get_server_ips,
list_servers, update_server, get_reference_data list_servers, update_server, get_reference_data
@ -24,9 +24,6 @@ async def servers_list(request: Request, db=Depends(get_db),
user = get_current_user(request) user = get_current_user(request)
if not user: if not user:
return RedirectResponse(url="/login") return RedirectResponse(url="/login")
perms = get_user_perms(db, user)
if not can_view(perms, "servers"):
return RedirectResponse(url="/dashboard")
filters = {"domain": domain, "env": env, "tier": tier, "etat": etat, "os": os, "owner": owner, "search": search} filters = {"domain": domain, "env": env, "tier": tier, "etat": etat, "os": os, "owner": owner, "search": search}
servers, total = list_servers(db, filters, page, sort=sort, sort_dir=sort_dir) servers, total = list_servers(db, filters, page, sort=sort, sort_dir=sort_dir)
@ -50,9 +47,6 @@ async def servers_export_csv(request: Request, db=Depends(get_db),
user = get_current_user(request) user = get_current_user(request)
if not user: if not user:
return RedirectResponse(url="/login") return RedirectResponse(url="/login")
perms = get_user_perms(db, user)
if not can_view(perms, "servers"):
return RedirectResponse(url="/dashboard")
import io, csv import io, csv
filters = {"domain": domain, "env": env, "tier": tier, "etat": etat, "os": os, "owner": owner, "search": search} filters = {"domain": domain, "env": env, "tier": tier, "etat": etat, "os": os, "owner": owner, "search": search}
servers, total = list_servers(db, filters, page=1, per_page=99999, sort="hostname", sort_dir="asc") servers, total = list_servers(db, filters, page=1, per_page=99999, sort="hostname", sort_dir="asc")
@ -78,10 +72,7 @@ async def servers_export_csv(request: Request, db=Depends(get_db),
async def server_detail(request: Request, server_id: int, db=Depends(get_db)): async def server_detail(request: Request, server_id: int, db=Depends(get_db)):
user = get_current_user(request) user = get_current_user(request)
if not user: if not user:
return HTMLResponse("<p>Non autorise</p>", status_code=401) return HTMLResponse("<p>Non autorise</p>")
perms = get_user_perms(db, user)
if not can_view(perms, "servers"):
return HTMLResponse("<p>Acces interdit</p>", status_code=403)
s = get_server_full(db, server_id) s = get_server_full(db, server_id)
if not s: if not s:
return HTMLResponse("<p>Serveur non trouve</p>") return HTMLResponse("<p>Serveur non trouve</p>")
@ -96,17 +87,23 @@ async def server_detail(request: Request, server_id: int, db=Depends(get_db)):
async def server_edit(request: Request, server_id: int, db=Depends(get_db)): async def server_edit(request: Request, server_id: int, db=Depends(get_db)):
user = get_current_user(request) user = get_current_user(request)
if not user: if not user:
return HTMLResponse("<p>Non autorise</p>", status_code=401) return HTMLResponse("<p>Non autorise</p>")
perms = get_user_perms(db, user)
if not can_edit(perms, "servers"):
return HTMLResponse("<p>Acces interdit</p>", status_code=403)
s = get_server_full(db, server_id) s = get_server_full(db, server_id)
if not s: if not s:
return HTMLResponse("<p>Serveur non trouve</p>") return HTMLResponse("<p>Serveur non trouve</p>")
domains, envs = get_reference_data(db) domains, envs = get_reference_data(db)
ips = get_server_ips(db, server_id) 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", { 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],
}) })
@ -118,15 +115,12 @@ async def server_update(request: Request, server_id: int, db=Depends(get_db),
referent_nom: str = Form(None), mode_operatoire: str = Form(None), referent_nom: str = Form(None), mode_operatoire: str = Form(None),
commentaire: str = Form(None), commentaire: str = Form(None),
ip_reelle: str = Form(None), ip_connexion: 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)): pref_patch_jour: str = Form(None), pref_patch_heure: str = Form(None)):
user = get_current_user(request) user = get_current_user(request)
if not user: if not user:
return HTMLResponse("<p>Non autorise</p>", status_code=401) return HTMLResponse("<p>Non autorise</p>")
perms = get_user_perms(db, user)
if not can_edit(perms, "servers"):
return HTMLResponse("<p>Acces interdit</p>", status_code=403)
data = { data = {
"domain_code": domain_code, "env_code": env_code, "zone": zone, "domain_code": domain_code, "env_code": env_code, "zone": zone,
@ -134,7 +128,7 @@ async def server_update(request: Request, server_id: int, db=Depends(get_db),
"responsable_nom": responsable_nom, "referent_nom": referent_nom, "responsable_nom": responsable_nom, "referent_nom": referent_nom,
"mode_operatoire": mode_operatoire, "commentaire": commentaire, "mode_operatoire": mode_operatoire, "commentaire": commentaire,
"ip_reelle": ip_reelle, "ip_connexion": ip_connexion, "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, "pref_patch_jour": pref_patch_jour, "pref_patch_heure": pref_patch_heure,
} }
update_server(db, server_id, data, user.get("sub")) update_server(db, server_id, data, user.get("sub"))
@ -154,9 +148,6 @@ async def servers_bulk(request: Request, db=Depends(get_db),
user = get_current_user(request) user = get_current_user(request)
if not user: if not user:
return RedirectResponse(url="/login") return RedirectResponse(url="/login")
perms = get_user_perms(db, user)
if not can_edit(perms, "servers"):
return RedirectResponse(url="/servers", status_code=303)
if not server_ids or not bulk_field or not bulk_value: if not server_ids or not bulk_field or not bulk_value:
return RedirectResponse(url="/servers", status_code=303) return RedirectResponse(url="/servers", status_code=303)
@ -207,10 +198,7 @@ async def servers_bulk(request: Request, db=Depends(get_db),
async def server_sync_qualys(request: Request, server_id: int, db=Depends(get_db)): async def server_sync_qualys(request: Request, server_id: int, db=Depends(get_db)):
user = get_current_user(request) user = get_current_user(request)
if not user: if not user:
return HTMLResponse("<p>Non autorise</p>", status_code=401) return HTMLResponse("<p>Non autorise</p>")
perms = get_user_perms(db, user)
if not can_edit(perms, "servers"):
return HTMLResponse("<p>Acces interdit</p>", status_code=403)
result = sync_server_qualys(db, server_id) result = sync_server_qualys(db, server_id)
s = get_server_full(db, server_id) s = get_server_full(db, server_id)
tags = get_server_tags(db, s.qid) if s else [] tags = get_server_tags(db, s.qid) if s else []

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

@ -1,6 +1,7 @@
"""Service QuickWin — gestion des campagnes + exclusions par serveur""" """Service QuickWin — gestion des campagnes + exclusions par serveur"""
import json import json
from sqlalchemy import text 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) # Exclusions generales par defaut (reboot packages + middleware/apps)
DEFAULT_GENERAL_EXCLUDES = ( DEFAULT_GENERAL_EXCLUDES = (
@ -118,7 +119,7 @@ def get_run(db, run_id):
def get_run_entries(db, run_id): def get_run_entries(db, run_id):
return db.execute(text(""" return db.execute(text("""
SELECT qe.*, s.hostname, s.os_family, s.machine_type, SELECT qe.*, s.hostname, s.fqdn, s.os_family, s.machine_type,
d.name as domaine, e.name as environnement d.name as domaine, e.name as environnement
FROM quickwin_entries qe FROM quickwin_entries qe
JOIN servers s ON qe.server_id = s.id JOIN servers s ON qe.server_id = s.id
@ -169,6 +170,218 @@ def delete_run(db, run_id):
db.commit() 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, def update_entry_status(db, entry_id, status, patch_output="", packages_count=0,
packages="", reboot_required=False, notes=""): packages="", reboot_required=False, notes=""):
db.execute(text(""" db.execute(text("""
@ -207,21 +420,192 @@ def can_start_prod(db, run_id):
def get_run_stats(db, run_id): def get_run_stats(db, run_id):
return db.execute(text(""" return db.execute(text("""
SELECT SELECT
COUNT(*) as total, COUNT(*) FILTER (WHERE status != 'excluded') as total,
COUNT(*) FILTER (WHERE branch = 'hprod') as hprod_total, COUNT(*) FILTER (WHERE branch = 'hprod' AND status != 'excluded') as hprod_total,
COUNT(*) FILTER (WHERE branch = 'prod') as prod_total, COUNT(*) FILTER (WHERE branch = 'prod' AND status != 'excluded') as prod_total,
COUNT(*) FILTER (WHERE status = 'patched') as patched, COUNT(*) FILTER (WHERE status = 'patched') as patched,
COUNT(*) FILTER (WHERE status = 'failed') as failed, COUNT(*) FILTER (WHERE status = 'failed') as failed,
COUNT(*) FILTER (WHERE status = 'pending') as pending, 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 = 'excluded') as excluded,
COUNT(*) FILTER (WHERE status = 'skipped') as skipped, COUNT(*) FILTER (WHERE status = 'skipped') as skipped,
COUNT(*) FILTER (WHERE branch = 'hprod' AND status = 'patched') as hprod_patched, 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 branch = 'prod' AND status = 'patched') as prod_patched,
COUNT(*) FILTER (WHERE reboot_required) as reboot_count COUNT(*) FILTER (WHERE reboot_required AND status != 'excluded') as reboot_count
FROM quickwin_entries WHERE run_id = :rid FROM quickwin_entries WHERE run_id = :rid
"""), {"rid": run_id}).fetchone() """), {"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): def inject_yum_history(db, data):
"""Injecte l'historique yum dans quickwin_server_config. """Injecte l'historique yum dans quickwin_server_config.
data = [{"server": "hostname", "yum_commands": [...]}]""" data = [{"server": "hostname", "yum_commands": [...]}]"""
@ -252,3 +636,153 @@ def inject_yum_history(db, data):
inserted += 1 inserted += 1
db.commit() db.commit()
return updated, inserted 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

@ -208,7 +208,7 @@ def update_server(db, server_id, data, username):
params = {"id": server_id} params = {"id": server_id}
direct_fields = ["tier", "etat", "patch_os_owner", "responsable_nom", direct_fields = ["tier", "etat", "patch_os_owner", "responsable_nom",
"referent_nom", "mode_operatoire", "commentaire", "ssh_method", "referent_nom", "mode_operatoire", "commentaire", "ssh_method",
"pref_patch_jour", "pref_patch_heure"] "domain_ltd", "pref_patch_jour", "pref_patch_heure"]
changed = [] changed = []
for field in direct_fields: for field in direct_fields:
if data.get(field) is not None: if data.get(field) is not None:

View File

@ -61,7 +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/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.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 %}<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 %}bg-cyber-border/30 text-cyber-accent{% else %}text-gray-400{% endif %}">QuickWin</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.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 %}<a href="/audit" class="block px-3 py-2 rounded-md text-sm hover:bg-cyber-border/30 {% if request.url.path == '/audit' %}bg-cyber-border/30 text-cyber-accent{% else %}text-gray-400{% endif %}">Audit</a>{% endif %}
{% if p.audit in ('edit', 'admin') %}<a href="/audit/specific" class="block px-3 py-2 rounded-md text-sm hover:bg-cyber-border/30 {% if 'specific' in request.url.path %}bg-cyber-border/30 text-cyber-accent{% else %}text-gray-400{% endif %} pl-6 text-xs">Spécifique</a>{% endif %} {% if p.audit 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 %}
@ -70,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.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.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="/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> </nav>
</aside> </aside>
<main class="flex-1 flex flex-col overflow-hidden"> <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 %} {% for e in envs %}<option value="{{ e.code }}" {% if e.name == s.environnement %}selected{% endif %}>{{ e.name }}</option>{% endfor %}
</select> </select>
</div> </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> <div>
<label class="text-xs text-gray-500">Zone</label> <label class="text-xs text-gray-500">Zone</label>
<select name="zone" class="w-full"> <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> </select>
</div> </div>
<div> <div>
@ -70,18 +77,18 @@
</div> </div>
<div class="grid grid-cols-2 gap-2"> <div class="grid grid-cols-2 gap-2">
<div> <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"> <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 %} {% for j in ['indifferent','lundi','mardi','mercredi','jeudi'] %}<option value="{{ j }}" {% if j == s.pref_patch_jour %}selected{% endif %}>{{ j }}</option>{% endfor %}
</select> </select>
</div> </div>
<div> <div>
<label class="text-xs text-gray-500">Heure préférée</label> <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ôt le matin" class="w-full"> <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> </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> <textarea name="mode_operatoire" rows="3" class="w-full">{{ s.mode_operatoire or '' }}</textarea>
</div> </div>
<div> <div>

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

@ -1,6 +1,18 @@
{% extends "base.html" %} {% extends "base.html" %}
{% block title %}QuickWin #{{ run.id }}{% endblock %} {% 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) -%} {% 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 '' }} ?hp_page={{ hp }}&p_page={{ pp }}&per_page={{ per_page }}&search={{ filters.search or '' }}&status={{ filters.status or '' }}&domain={{ filters.domain or '' }}
{%- endmacro %} {%- endmacro %}
@ -10,18 +22,11 @@
<div> <div>
<a href="/quickwin" class="text-xs text-gray-500 hover:text-gray-300">&larr; Retour campagnes</a> <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> <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 '?' }} &mdash; pas de reboot n&eacute;cessaire</p> <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>
<div class="flex gap-2 items-center"> <div class="flex gap-2 items-center">
{% if run.status == 'draft' %} <a href="/quickwin/{{ run.id }}/correspondance" class="btn-sm" style="background:#1e3a5f;color:#a78bfa;padding:4px 14px;text-decoration:none">Correspondance</a>
<span class="badge badge-gray" style="padding:4px 12px">Brouillon</span> <a href="/quickwin/{{ run.id }}/logs" class="btn-sm" style="background:#1e3a5f;color:#94a3b8;padding:4px 14px;text-decoration:none">Logs</a>
{% elif run.status == 'hprod_done' %}
<span class="badge badge-blue" style="padding:4px 12px">H-Prod termin&eacute;</span>
{% elif run.status == 'completed' %}
<span class="badge badge-green" style="padding:4px 12px">Termin&eacute;</span>
{% else %}
<span class="badge badge-yellow" style="padding:4px 12px">{{ run.status }}</span>
{% endif %}
<form method="post" action="/quickwin/{{ run.id }}/delete" onsubmit="return confirm('Supprimer cette campagne ?')"> <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> <button class="btn-sm btn-danger" style="padding:4px 12px">Supprimer</button>
</form> </form>
@ -32,7 +37,42 @@
<div style="background:#1a5a2e;color:#8f8;padding:8px 16px;border-radius:6px;margin-bottom:12px;font-size:0.85rem">{{ msg }}</div> <div style="background:#1a5a2e;color:#8f8;padding:8px 16px;border-radius:6px;margin-bottom:12px;font-size:0.85rem">{{ msg }}</div>
{% endif %} {% endif %}
<!-- Stats --> <!-- 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 style="display:grid;grid-template-columns:repeat(6,1fr);gap:12px;margin-bottom:20px">
<div class="card p-3 text-center"> <div class="card p-3 text-center">
<div class="text-2xl font-bold" style="color:#fff">{{ stats.total }}</div> <div class="text-2xl font-bold" style="color:#fff">{{ stats.total }}</div>
@ -60,67 +100,353 @@
</div> </div>
</div> </div>
<!-- Filtres --> <!-- ========== 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"> <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"> <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"> <select name="status" onchange="this.form.submit()" style="width:140px">
<option value="">Tous statuts</option> <option value="">Tous statuts</option>
<option value="pending" {% if filters.status == 'pending' %}selected{% endif %}>En attente</option> <option value="pending" {% if filters.status == 'pending' %}selected{% endif %}>En attente</option>
<option value="in_progress" {% if filters.status == 'in_progress' %}selected{% endif %}>En cours</option>
<option value="patched" {% if filters.status == 'patched' %}selected{% endif %}>Patch&eacute;</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="failed" {% if filters.status == 'failed' %}selected{% endif %}>KO</option>
<option value="excluded" {% if filters.status == 'excluded' %}selected{% endif %}>Exclu</option> <option value="excluded" {% if filters.status == 'excluded' %}selected{% endif %}>Exclu</option>
<option value="skipped" {% if filters.status == 'skipped' %}selected{% endif %}>Ignor&eacute;</option> <option value="skipped" {% if filters.status == 'skipped' %}selected{% endif %}>Ignor&eacute;</option>
</select> </select>
<select name="domain" onchange="this.form.submit()" style="width:160px"> {% endif %}
<option value="">Tous domaines</option> <select name="per_page" onchange="this.form.submit()" style="width:130px">
{% set all_entries_list = entries %} <option value="">Par page</option>
{% set doms = all_entries_list|map(attribute='domaine')|select('string')|unique|sort %} {% for n in [14, 25, 50, 100] %}<option value="{{ n }}" {% if per_page == n %}selected{% endif %}>{{ n }}</option>{% endfor %}
{% for d in doms %}<option value="{{ d }}" {% if filters.domain == d %}selected{% endif %}>{{ d }}</option>{% endfor %}
</select>
<select name="per_page" onchange="this.form.submit()" style="width:150px">
<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> </select>
<button type="submit" class="btn-primary" style="padding:4px 14px;font-size:0.8rem">Filtrer</button> <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> <a href="/quickwin/{{ run.id }}" class="text-xs text-gray-500 hover:text-gray-300">Reset</a>
</form> </form>
<!-- Regle hprod first -->
{% if not prod_ok %} {% if not prod_ok %}
<div class="card mb-4" style="border-left:3px solid #ff3366;padding:12px 16px"> <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.pending }} serveur(s) hprod en attente. Terminer le hprod avant de lancer le prod.</p> <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> </div>
{% endif %} {% endif %}
<!-- H-PROD --> <!-- ========== 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="card mb-4">
<div class="p-3 flex items-center justify-between" style="border-bottom:1px solid #1e3a5f"> <div class="p-3 flex items-center justify-between" style="border-bottom:1px solid #1e3a5f">
<h2 class="text-sm font-bold" style="color:#00d4ff">HORS-PRODUCTION ({{ hprod_total }})</h2> <h2 class="text-sm font-bold" style="color:{{ branch_color }}">{{ branch_label }} ({{ total_count }})</h2>
<div class="flex gap-1 items-center"> <div class="flex gap-1 items-center">
<span class="badge badge-green">{{ hprod|selectattr('status','eq','patched')|list|length }} OK</span> <span class="badge badge-green">{{ rows|selectattr('status','eq','patched')|list|length }} OK</span>
<span class="badge badge-red">{{ hprod|selectattr('status','eq','failed')|list|length }} KO</span> <span class="badge badge-red">{{ rows|selectattr('status','eq','failed')|list|length }} KO</span>
<span class="badge badge-gray">{{ hprod|selectattr('status','eq','pending')|list|length }} en attente</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> </div>
<div class="table-wrap"> <div class="table-wrap">
<table class="table-cyber w-full"> <table class="table-cyber w-full">
<thead><tr> <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">Serveur</th>
<th class="px-2 py-2">Domaine</th> <th class="px-2 py-2">Domaine</th>
<th class="px-2 py-2">Env</th> <th class="px-2 py-2">Env</th>
<th class="px-2 py-2">Statut</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 g&eacute;n.</th>
<th class="px-2 py-2">Exclusions sp&eacute;c.</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">Packages</th>
<th class="px-2 py-2">Date patch</th> <th class="px-2 py-2">Date patch</th>
<th class="px-2 py-2">Reboot</th> <th class="px-2 py-2">Reboot</th>
<th class="px-2 py-2">Notes</th> <th class="px-2 py-2">Notes</th>
{% if run.status == 'patching' %}
<th class="px-2 py-2">Action</th>
{% endif %}
</tr></thead> </tr></thead>
<tbody> <tbody>
{% for e in hprod %} {% for e in rows %}
<tr data-id="{{ e.id }}"> <tr data-id="{{ e.id }}">
<td class="px-2 py-2 font-bold" style="color:#00d4ff">{{ e.hostname }}</td> {% 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.domaine or '?' }}</td>
<td class="px-2 py-2 text-gray-400">{{ e.environnement or '?' }}</td> <td class="px-2 py-2 text-gray-400">{{ e.environnement or '?' }}</td>
<td class="px-2 py-2"> <td class="px-2 py-2">
@ -131,6 +457,27 @@
{% elif e.status == 'skipped' %}<span class="badge badge-gray">Ignor&eacute;</span> {% elif e.status == 'skipped' %}<span class="badge badge-gray">Ignor&eacute;</span>
{% else %}<span class="badge badge-gray">En attente</span>{% endif %} {% else %}<span class="badge badge-gray">En attente</span>{% endif %}
</td> </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 }}"> <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> <span class="editable" data-id="{{ e.id }}" data-field="general_excludes">{{ e.general_excludes or '—' }}</span>
</td> </td>
@ -143,91 +490,53 @@
<td class="px-2 py-2 text-xs text-gray-500"> <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> <span class="editable" data-id="{{ e.id }}" data-field="notes">{{ e.notes or '—' }}</span>
</td> </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> </tr>
{% endfor %} {% endfor %}
{% if not hprod %}<tr><td colspan="10" class="px-2 py-6 text-center text-gray-500">Aucun serveur hors-production{% if filters.search or filters.status or filters.domain %} (filtre actif){% endif %}</td></tr>{% endif %} {% 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> </tbody>
</table> </table>
</div> </div>
<!-- Pagination H-PROD --> {% if total_pages > 1 %}
{% if hp_total_pages > 1 %}
<div class="flex justify-between items-center p-3 text-sm text-gray-500" style="border-top:1px solid #1e3a5f"> <div class="flex justify-between items-center p-3 text-sm text-gray-500" style="border-top:1px solid #1e3a5f">
<span>Page {{ hp_page }} / {{ hp_total_pages }} &mdash; {{ hprod_total }} serveur(s)</span> <span>Page {{ page_num }} / {{ total_pages }} &mdash; {{ total_count }} serveur(s)</span>
<div class="flex gap-2"> <div class="flex gap-2">
{% if hp_page > 1 %}<a href="{{ qs(hp=hp_page - 1, pp=p_page) }}" class="btn-sm bg-cyber-border text-gray-300">Pr&eacute;c&eacute;dent</a>{% endif %} {% if branch_key == 'hp' %}
{% if hp_page < hp_total_pages %}<a href="{{ qs(hp=hp_page + 1, pp=p_page) }}" class="btn-sm bg-cyber-border text-gray-300">Suivant</a>{% endif %} {% 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>
</div> </div>
{% endif %} {% endif %}
</div> </div>
{% endmacro %}
<!-- H-PROD -->
{{ entry_table(hprod, "HORS-PRODUCTION", "#00d4ff", "hp", hp_page, hp_total_pages, hprod_total) }}
<!-- PROD --> <!-- PROD -->
{% if prod_ok %} {% if prod_ok %}
<div class="card mb-4"> {{ entry_table(prod, "PRODUCTION", "#ffcc00", "pr", p_page, p_total_pages, prod_total) }}
<div class="p-3 flex items-center justify-between" style="border-bottom:1px solid #1e3a5f">
<h2 class="text-sm font-bold" style="color:#ffcc00">PRODUCTION ({{ prod_total }})</h2>
<div class="flex gap-1 items-center">
<span class="badge badge-green">{{ prod|selectattr('status','eq','patched')|list|length }} OK</span>
<span class="badge badge-red">{{ prod|selectattr('status','eq','failed')|list|length }} KO</span>
<span class="badge badge-gray">{{ prod|selectattr('status','eq','pending')|list|length }} en attente</span>
</div>
</div>
<div class="table-wrap">
<table class="table-cyber w-full">
<thead><tr>
<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>
<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>
</tr></thead>
<tbody>
{% for e in prod %}
<tr data-id="{{ e.id }}">
<td class="px-2 py-2 font-bold" style="color:#ffcc00">{{ 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>
{% else %}<span class="badge badge-gray">En attente</span>{% endif %}
</td>
<td class="px-2 py-2 text-xs" style="color:#ffcc00;max-width:150px;overflow:hidden;text-overflow:ellipsis;white-space:nowrap">
<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">
<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>
</tr>
{% endfor %}
{% if not prod %}<tr><td colspan="10" class="px-2 py-6 text-center text-gray-500">Aucun serveur production{% if filters.search or filters.status or filters.domain %} (filtre actif){% endif %}</td></tr>{% endif %}
</tbody>
</table>
</div>
<!-- Pagination PROD -->
{% if p_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 {{ p_page }} / {{ p_total_pages }} &mdash; {{ prod_total }} serveur(s)</span>
<div class="flex gap-2">
{% if p_page > 1 %}<a href="{{ qs(hp=hp_page, pp=p_page - 1) }}" class="btn-sm bg-cyber-border text-gray-300">Pr&eacute;c&eacute;dent</a>{% endif %}
{% if p_page < p_total_pages %}<a href="{{ qs(hp=hp_page, pp=p_page + 1) }}" class="btn-sm bg-cyber-border text-gray-300">Suivant</a>{% endif %}
</div>
</div>
{% endif %}
</div>
{% endif %} {% endif %}
{% if run.notes %} {% if run.notes %}
@ -237,13 +546,81 @@
</div> </div>
{% endif %} {% 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> <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 => { document.querySelectorAll('.editable').forEach(el => {
el.style.cursor = 'pointer'; el.style.cursor = 'pointer';
el.addEventListener('dblclick', function() { el.addEventListener('dblclick', function() {
const field = this.dataset.field; const field = this.dataset.field;
const id = this.dataset.id; const id = this.dataset.id;
const current = this.textContent.trim() === '—' ? '' : this.textContent.trim(); const current = this.textContent.trim() === '\u2014' ? '' : this.textContent.trim();
const input = document.createElement('input'); const input = document.createElement('input');
input.value = current; 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%'; input.style.cssText = 'background:#0a0e17;border:1px solid #00d4ff;color:#fff;padding:2px 6px;border-radius:4px;font-size:0.75rem;width:100%';
@ -253,7 +630,7 @@ document.querySelectorAll('.editable').forEach(el => {
input.select(); input.select();
const save = () => { const save = () => {
const val = input.value.trim(); const val = input.value.trim();
this.textContent = val || ''; this.textContent = val || '\u2014';
fetch('/api/quickwin/entry/update', { fetch('/api/quickwin/entry/update', {
method: 'POST', method: 'POST',
headers: {'Content-Type': 'application/json'}, headers: {'Content-Type': 'application/json'},
@ -263,9 +640,251 @@ document.querySelectorAll('.editable').forEach(el => {
input.addEventListener('blur', save); input.addEventListener('blur', save);
input.addEventListener('keydown', e => { input.addEventListener('keydown', e => {
if (e.key === 'Enter') { e.preventDefault(); input.blur(); } if (e.key === 'Enter') { e.preventDefault(); input.blur(); }
if (e.key === 'Escape') { this.textContent = current || ''; } 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> </script>
{% endblock %} {% 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 %}