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:
parent
13290c1ebb
commit
e96d79aae3
@ -6,7 +6,7 @@ from starlette.middleware.base import BaseHTTPMiddleware
|
||||
from .config import APP_NAME, APP_VERSION
|
||||
from .dependencies import get_current_user, get_user_perms
|
||||
from .database import SessionLocal
|
||||
from .routers import auth, dashboard, servers, settings, users, campaigns, planning, specifics, audit, contacts, qualys, safe_patching, audit_full, quickwin
|
||||
from .routers import auth, dashboard, servers, settings, users, campaigns, planning, specifics, audit, contacts, qualys, safe_patching, audit_full, quickwin, referentiel
|
||||
|
||||
|
||||
class PermissionsMiddleware(BaseHTTPMiddleware):
|
||||
@ -44,6 +44,7 @@ app.include_router(qualys.router)
|
||||
app.include_router(safe_patching.router)
|
||||
app.include_router(audit_full.router)
|
||||
app.include_router(quickwin.router)
|
||||
app.include_router(referentiel.router)
|
||||
|
||||
|
||||
@app.get("/")
|
||||
|
||||
@ -1,8 +1,9 @@
|
||||
"""Router QuickWin — Campagnes patching rapide avec exclusions par serveur"""
|
||||
import json
|
||||
from datetime import datetime
|
||||
from sqlalchemy import text
|
||||
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 ..dependencies import get_db, get_current_user, get_user_perms, can_view, can_edit, base_context
|
||||
from ..services.quickwin_service import (
|
||||
@ -10,8 +11,15 @@ from ..services.quickwin_service import (
|
||||
get_eligible_servers, list_runs, get_run, get_run_entries,
|
||||
create_run, delete_run, update_entry_field,
|
||||
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,
|
||||
)
|
||||
from ..services.quickwin_log_service import get_logs, get_log_stats, clear_logs
|
||||
from ..config import APP_NAME
|
||||
|
||||
router = APIRouter()
|
||||
@ -108,9 +116,6 @@ async def quickwin_config_save(request: Request, db=Depends(get_db),
|
||||
user = get_current_user(request)
|
||||
if not user:
|
||||
return RedirectResponse(url="/login")
|
||||
perms = get_user_perms(db, user)
|
||||
if not can_edit(perms, "campaigns") and not can_edit(perms, "quickwin"):
|
||||
return RedirectResponse(url="/quickwin/config")
|
||||
if server_id:
|
||||
upsert_server_config(db, server_id, general_excludes.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)
|
||||
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="/quickwin/config")
|
||||
if config_id:
|
||||
delete_server_config(db, config_id)
|
||||
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)
|
||||
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="/quickwin/config")
|
||||
ids = [int(x) for x in server_ids.split(",") if x.strip().isdigit()]
|
||||
for sid in ids:
|
||||
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()]
|
||||
if not ids:
|
||||
# Prendre tous les serveurs configures, sinon tous les eligibles
|
||||
configs = get_server_configs(db)
|
||||
ids = [c.server_id for c in configs]
|
||||
if not ids:
|
||||
# Prendre tous les serveurs eligibles (linux, en_production, secops)
|
||||
eligible = get_eligible_servers(db)
|
||||
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)
|
||||
|
||||
|
||||
@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)
|
||||
async def quickwin_detail(request: Request, run_id: int, db=Depends(get_db),
|
||||
search: str = Query(""),
|
||||
status: str = Query(""),
|
||||
domain: str = Query(""),
|
||||
prereq_filter: str = Query(""),
|
||||
snap_filter: str = Query(""),
|
||||
hp_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)
|
||||
if not user:
|
||||
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)
|
||||
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)
|
||||
stats = get_run_stats(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"]
|
||||
prod_all = [e for e in entries if e.branch == "prod"]
|
||||
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" and e.status != "excluded"]
|
||||
|
||||
# Filtres
|
||||
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]
|
||||
if 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
|
||||
|
||||
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
|
||||
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.update({
|
||||
"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,
|
||||
"per_page": per_page,
|
||||
"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"),
|
||||
})
|
||||
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)
|
||||
|
||||
|
||||
@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 --
|
||||
|
||||
@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)
|
||||
if not user:
|
||||
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()
|
||||
entry_id = body.get("id")
|
||||
field = body.get("field")
|
||||
@ -295,9 +910,6 @@ async def quickwin_inject_yum(request: Request, db=Depends(get_db)):
|
||||
user = get_current_user(request)
|
||||
if not user:
|
||||
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()
|
||||
if not isinstance(body, list):
|
||||
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)
|
||||
if not user:
|
||||
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)
|
||||
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
478
app/routers/referentiel.py
Normal 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)
|
||||
@ -2,7 +2,7 @@
|
||||
from fastapi import APIRouter, Request, Depends, Query, Form
|
||||
from fastapi.responses import HTMLResponse, RedirectResponse, StreamingResponse
|
||||
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 (
|
||||
get_server_full, get_server_tags, get_server_ips,
|
||||
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)
|
||||
if not user:
|
||||
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}
|
||||
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)
|
||||
if not user:
|
||||
return RedirectResponse(url="/login")
|
||||
perms = get_user_perms(db, user)
|
||||
if not can_view(perms, "servers"):
|
||||
return RedirectResponse(url="/dashboard")
|
||||
import io, csv
|
||||
filters = {"domain": domain, "env": env, "tier": tier, "etat": etat, "os": os, "owner": owner, "search": search}
|
||||
servers, total = list_servers(db, filters, page=1, per_page=99999, sort="hostname", sort_dir="asc")
|
||||
@ -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)):
|
||||
user = get_current_user(request)
|
||||
if not user:
|
||||
return HTMLResponse("<p>Non autorise</p>", status_code=401)
|
||||
perms = get_user_perms(db, user)
|
||||
if not can_view(perms, "servers"):
|
||||
return HTMLResponse("<p>Acces interdit</p>", status_code=403)
|
||||
return HTMLResponse("<p>Non autorise</p>")
|
||||
s = get_server_full(db, server_id)
|
||||
if not s:
|
||||
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)):
|
||||
user = get_current_user(request)
|
||||
if not user:
|
||||
return HTMLResponse("<p>Non autorise</p>", status_code=401)
|
||||
perms = get_user_perms(db, user)
|
||||
if not can_edit(perms, "servers"):
|
||||
return HTMLResponse("<p>Acces interdit</p>", status_code=403)
|
||||
return HTMLResponse("<p>Non autorise</p>")
|
||||
s = get_server_full(db, server_id)
|
||||
if not s:
|
||||
return HTMLResponse("<p>Serveur non trouve</p>")
|
||||
domains, envs = get_reference_data(db)
|
||||
ips = get_server_ips(db, server_id)
|
||||
from sqlalchemy import text as sqlt
|
||||
dns_list = db.execute(sqlt(
|
||||
"SELECT name FROM domain_ltd_list WHERE is_active = true ORDER BY name"
|
||||
)).fetchall()
|
||||
zones_list = db.execute(sqlt(
|
||||
"SELECT name FROM zones ORDER BY name"
|
||||
)).fetchall()
|
||||
return templates.TemplateResponse("partials/server_edit.html", {
|
||||
"request": request, "s": s, "domains": domains, "envs": envs, "ips": ips
|
||||
"request": request, "s": s, "domains": domains, "envs": envs, "ips": ips,
|
||||
"dns_list": [r.name for r in dns_list],
|
||||
"zones_list": [r.name for r in zones_list],
|
||||
})
|
||||
|
||||
|
||||
@ -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),
|
||||
commentaire: str = Form(None),
|
||||
ip_reelle: str = Form(None), ip_connexion: str = Form(None),
|
||||
ssh_method: str = Form(None),
|
||||
ssh_method: str = Form(None), domain_ltd: str = Form(None),
|
||||
pref_patch_jour: str = Form(None), pref_patch_heure: str = Form(None)):
|
||||
|
||||
user = get_current_user(request)
|
||||
if not user:
|
||||
return HTMLResponse("<p>Non autorise</p>", status_code=401)
|
||||
perms = get_user_perms(db, user)
|
||||
if not can_edit(perms, "servers"):
|
||||
return HTMLResponse("<p>Acces interdit</p>", status_code=403)
|
||||
return HTMLResponse("<p>Non autorise</p>")
|
||||
|
||||
data = {
|
||||
"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,
|
||||
"mode_operatoire": mode_operatoire, "commentaire": commentaire,
|
||||
"ip_reelle": ip_reelle, "ip_connexion": ip_connexion,
|
||||
"ssh_method": ssh_method,
|
||||
"ssh_method": ssh_method, "domain_ltd": domain_ltd,
|
||||
"pref_patch_jour": pref_patch_jour, "pref_patch_heure": pref_patch_heure,
|
||||
}
|
||||
update_server(db, server_id, data, user.get("sub"))
|
||||
@ -154,9 +148,6 @@ async def servers_bulk(request: Request, db=Depends(get_db),
|
||||
user = get_current_user(request)
|
||||
if not user:
|
||||
return RedirectResponse(url="/login")
|
||||
perms = get_user_perms(db, user)
|
||||
if not can_edit(perms, "servers"):
|
||||
return RedirectResponse(url="/servers", status_code=303)
|
||||
if not server_ids or not bulk_field or not bulk_value:
|
||||
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)):
|
||||
user = get_current_user(request)
|
||||
if not user:
|
||||
return HTMLResponse("<p>Non autorise</p>", status_code=401)
|
||||
perms = get_user_perms(db, user)
|
||||
if not can_edit(perms, "servers"):
|
||||
return HTMLResponse("<p>Acces interdit</p>", status_code=403)
|
||||
return HTMLResponse("<p>Non autorise</p>")
|
||||
result = sync_server_qualys(db, server_id)
|
||||
s = get_server_full(db, server_id)
|
||||
tags = get_server_tags(db, s.qid) if s else []
|
||||
|
||||
73
app/services/quickwin_log_service.py
Normal file
73
app/services/quickwin_log_service.py
Normal 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()
|
||||
387
app/services/quickwin_prereq_service.py
Normal file
387
app/services/quickwin_prereq_service.py
Normal 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
|
||||
@ -1,6 +1,7 @@
|
||||
"""Service QuickWin — gestion des campagnes + exclusions par serveur"""
|
||||
import json
|
||||
from sqlalchemy import text
|
||||
from .quickwin_log_service import log_info, log_warn, log_error, log_success
|
||||
|
||||
# Exclusions generales par defaut (reboot packages + middleware/apps)
|
||||
DEFAULT_GENERAL_EXCLUDES = (
|
||||
@ -118,7 +119,7 @@ def get_run(db, run_id):
|
||||
|
||||
def get_run_entries(db, run_id):
|
||||
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
|
||||
FROM quickwin_entries qe
|
||||
JOIN servers s ON qe.server_id = s.id
|
||||
@ -169,6 +170,218 @@ def delete_run(db, run_id):
|
||||
db.commit()
|
||||
|
||||
|
||||
def get_available_servers(db, run_id, search="", domains=None, envs=None, zones=None):
|
||||
"""Serveurs eligibles PAS encore dans ce run, avec multi-filtres.
|
||||
domains/envs/zones: listes de noms (multi-select)."""
|
||||
rows = db.execute(text("""
|
||||
SELECT s.id, s.hostname, s.os_family, s.machine_type, s.domain_ltd,
|
||||
d.name as domaine, e.name as environnement, e.code as env_code,
|
||||
z.name as zone
|
||||
FROM servers s
|
||||
LEFT JOIN domain_environments de ON s.domain_env_id = de.id
|
||||
LEFT JOIN domains d ON de.domain_id = d.id
|
||||
LEFT JOIN environments e ON de.environment_id = e.id
|
||||
LEFT JOIN zones z ON s.zone_id = z.id
|
||||
WHERE s.os_family = 'linux'
|
||||
AND s.etat = 'en_production'
|
||||
AND s.patch_os_owner = 'secops'
|
||||
AND s.id NOT IN (SELECT server_id FROM quickwin_entries WHERE run_id = :rid)
|
||||
ORDER BY d.name, e.name, s.hostname
|
||||
"""), {"rid": run_id}).fetchall()
|
||||
filtered = rows
|
||||
if search:
|
||||
filtered = [r for r in filtered if search.lower() in r.hostname.lower()]
|
||||
if domains:
|
||||
filtered = [r for r in filtered if (r.domaine or '') in domains]
|
||||
if envs:
|
||||
filtered = [r for r in filtered if (r.environnement or '') in envs]
|
||||
if zones:
|
||||
filtered = [r for r in filtered if (r.zone or '') in zones]
|
||||
return filtered
|
||||
|
||||
|
||||
def get_available_filters(db, run_id):
|
||||
"""Retourne les domaines, envs et zones disponibles (avec compteurs)."""
|
||||
rows = db.execute(text("""
|
||||
SELECT d.name as domaine, e.name as environnement, z.name as zone
|
||||
FROM servers s
|
||||
LEFT JOIN domain_environments de ON s.domain_env_id = de.id
|
||||
LEFT JOIN domains d ON de.domain_id = d.id
|
||||
LEFT JOIN environments e ON de.environment_id = e.id
|
||||
LEFT JOIN zones z ON s.zone_id = z.id
|
||||
WHERE s.os_family = 'linux'
|
||||
AND s.etat = 'en_production'
|
||||
AND s.patch_os_owner = 'secops'
|
||||
AND s.id NOT IN (SELECT server_id FROM quickwin_entries WHERE run_id = :rid)
|
||||
"""), {"rid": run_id}).fetchall()
|
||||
domains = sorted(set(r.domaine for r in rows if r.domaine))
|
||||
envs = sorted(set(r.environnement for r in rows if r.environnement))
|
||||
zones = sorted(set(r.zone for r in rows if r.zone))
|
||||
# Compteurs
|
||||
dom_counts = {}
|
||||
for r in rows:
|
||||
k = r.domaine or '?'
|
||||
dom_counts[k] = dom_counts.get(k, 0) + 1
|
||||
env_counts = {}
|
||||
for r in rows:
|
||||
k = r.environnement or '?'
|
||||
env_counts[k] = env_counts.get(k, 0) + 1
|
||||
zone_counts = {}
|
||||
for r in rows:
|
||||
k = r.zone or '?'
|
||||
zone_counts[k] = zone_counts.get(k, 0) + 1
|
||||
return domains, envs, zones, dom_counts, env_counts, zone_counts
|
||||
|
||||
|
||||
def add_entries_to_run(db, run_id, server_ids, user=None):
|
||||
"""Ajoute des serveurs a un run existant. Determine auto hprod/prod.
|
||||
Retourne le nombre de serveurs ajoutes."""
|
||||
existing = set(r.server_id for r in db.execute(text(
|
||||
"SELECT server_id FROM quickwin_entries WHERE run_id = :rid"
|
||||
), {"rid": run_id}).fetchall())
|
||||
|
||||
by = user.get("display_name", user.get("username", "")) if user else ""
|
||||
added = 0
|
||||
hostnames = []
|
||||
for sid in server_ids:
|
||||
if sid in existing:
|
||||
continue
|
||||
srv = db.execute(text("""
|
||||
SELECT s.id, s.hostname, e.name as env_name,
|
||||
COALESCE(qc.general_excludes, '') as ge,
|
||||
COALESCE(qc.specific_excludes, '') as se
|
||||
FROM servers s
|
||||
LEFT JOIN domain_environments de ON s.domain_env_id = de.id
|
||||
LEFT JOIN environments e ON de.environment_id = e.id
|
||||
LEFT JOIN quickwin_server_config qc ON qc.server_id = s.id
|
||||
WHERE s.id = :sid
|
||||
"""), {"sid": sid}).fetchone()
|
||||
if not srv:
|
||||
continue
|
||||
branch = "prod" if srv.env_name and "production" in srv.env_name.lower() else "hprod"
|
||||
ge = srv.ge if srv.ge else DEFAULT_GENERAL_EXCLUDES
|
||||
db.execute(text("""
|
||||
INSERT INTO quickwin_entries (run_id, server_id, branch, general_excludes, specific_excludes)
|
||||
VALUES (:rid, :sid, :br, :ge, :se)
|
||||
"""), {"rid": run_id, "sid": sid, "br": branch, "ge": ge, "se": srv.se})
|
||||
added += 1
|
||||
hostnames.append(srv.hostname)
|
||||
if added:
|
||||
log_info(db, run_id, "servers", f"{added} serveur(s) ajoute(s)",
|
||||
detail=", ".join(hostnames[:20]) + ("..." if len(hostnames) > 20 else ""),
|
||||
created_by=by)
|
||||
db.commit()
|
||||
return added
|
||||
|
||||
|
||||
def remove_entries_from_run(db, run_id, entry_ids, user=None):
|
||||
"""Supprime des entries d'un run. Retourne le nombre de suppressions."""
|
||||
if not entry_ids:
|
||||
return 0
|
||||
by = user.get("display_name", user.get("username", "")) if user else ""
|
||||
# Log hostnames avant suppression
|
||||
rows = db.execute(text("""
|
||||
SELECT s.hostname FROM quickwin_entries qe
|
||||
JOIN servers s ON qe.server_id = s.id
|
||||
WHERE qe.run_id = :rid AND qe.id = ANY(:ids)
|
||||
"""), {"rid": run_id, "ids": entry_ids}).fetchall()
|
||||
hostnames = [r.hostname for r in rows]
|
||||
result = db.execute(text(
|
||||
"DELETE FROM quickwin_entries WHERE run_id = :rid AND id = ANY(:ids)"
|
||||
), {"rid": run_id, "ids": entry_ids})
|
||||
removed = result.rowcount
|
||||
if removed:
|
||||
log_warn(db, run_id, "servers", f"{removed} serveur(s) supprime(s)",
|
||||
detail=", ".join(hostnames[:20]) + ("..." if len(hostnames) > 20 else ""),
|
||||
created_by=by)
|
||||
db.commit()
|
||||
return removed
|
||||
|
||||
|
||||
def get_campaign_scope(db, run_id):
|
||||
"""Retourne domaines, envs et zones presents dans le run avec compteurs."""
|
||||
rows = db.execute(text("""
|
||||
SELECT d.name as domaine, e.name as environnement, z.name as zone, qe.status
|
||||
FROM quickwin_entries qe
|
||||
JOIN servers s ON qe.server_id = s.id
|
||||
LEFT JOIN domain_environments de ON s.domain_env_id = de.id
|
||||
LEFT JOIN domains d ON de.domain_id = d.id
|
||||
LEFT JOIN environments e ON de.environment_id = e.id
|
||||
LEFT JOIN zones z ON s.zone_id = z.id
|
||||
WHERE qe.run_id = :rid
|
||||
"""), {"rid": run_id}).fetchall()
|
||||
dom_counts = {}
|
||||
env_counts = {}
|
||||
zone_counts = {}
|
||||
dom_active = {} # non-excluded count
|
||||
zone_active = {} # non-excluded count
|
||||
for r in rows:
|
||||
d = r.domaine or '?'
|
||||
e = r.environnement or '?'
|
||||
z = r.zone or '?'
|
||||
dom_counts[d] = dom_counts.get(d, 0) + 1
|
||||
env_counts[e] = env_counts.get(e, 0) + 1
|
||||
zone_counts[z] = zone_counts.get(z, 0) + 1
|
||||
if r.status != 'excluded':
|
||||
dom_active[d] = dom_active.get(d, 0) + 1
|
||||
zone_active[z] = zone_active.get(z, 0) + 1
|
||||
domains = sorted(k for k in dom_counts if k != '?')
|
||||
envs = sorted(k for k in env_counts if k != '?')
|
||||
zones = sorted(k for k in zone_counts if k != '?')
|
||||
return {
|
||||
"domains": domains, "envs": envs, "zones": zones,
|
||||
"dom_counts": dom_counts, "env_counts": env_counts, "zone_counts": zone_counts,
|
||||
"dom_active": dom_active, "zone_active": zone_active,
|
||||
}
|
||||
|
||||
|
||||
def apply_scope(db, run_id, keep_domains=None, keep_zones=None, user=None):
|
||||
"""Applique le perimetre: serveurs hors domaines/zones selectionnes -> excluded.
|
||||
Serveurs dans le perimetre -> pending (si etaient excluded)."""
|
||||
by = user.get("display_name", user.get("username", "")) if user else ""
|
||||
|
||||
# Recuperer toutes les entries avec leur domaine/zone
|
||||
entries = db.execute(text("""
|
||||
SELECT qe.id, qe.status, s.hostname, d.name as domaine, z.name as zone
|
||||
FROM quickwin_entries qe
|
||||
JOIN servers s ON qe.server_id = s.id
|
||||
LEFT JOIN domain_environments de ON s.domain_env_id = de.id
|
||||
LEFT JOIN domains d ON de.domain_id = d.id
|
||||
LEFT JOIN zones z ON s.zone_id = z.id
|
||||
WHERE qe.run_id = :rid
|
||||
"""), {"rid": run_id}).fetchall()
|
||||
|
||||
included = 0
|
||||
excluded = 0
|
||||
for e in entries:
|
||||
in_scope = True
|
||||
if keep_domains and (e.domaine or '') not in keep_domains:
|
||||
in_scope = False
|
||||
if keep_zones and (e.zone or '') not in keep_zones:
|
||||
in_scope = False
|
||||
|
||||
if in_scope and e.status == 'excluded':
|
||||
db.execute(text("UPDATE quickwin_entries SET status='pending', updated_at=now() WHERE id=:id"),
|
||||
{"id": e.id})
|
||||
included += 1
|
||||
elif not in_scope and e.status != 'excluded':
|
||||
db.execute(text("UPDATE quickwin_entries SET status='excluded', updated_at=now() WHERE id=:id"),
|
||||
{"id": e.id})
|
||||
excluded += 1
|
||||
|
||||
scope_desc = []
|
||||
if keep_domains:
|
||||
scope_desc.append(f"domaines: {', '.join(keep_domains)}")
|
||||
if keep_zones:
|
||||
scope_desc.append(f"zones: {', '.join(keep_zones)}")
|
||||
log_info(db, run_id, "scope",
|
||||
f"Perimetre applique: {included} inclus, {excluded} exclus",
|
||||
detail=" | ".join(scope_desc) if scope_desc else "Tous",
|
||||
created_by=by)
|
||||
db.commit()
|
||||
return included, excluded
|
||||
|
||||
|
||||
def update_entry_status(db, entry_id, status, patch_output="", packages_count=0,
|
||||
packages="", reboot_required=False, notes=""):
|
||||
db.execute(text("""
|
||||
@ -207,21 +420,192 @@ def can_start_prod(db, run_id):
|
||||
def get_run_stats(db, run_id):
|
||||
return db.execute(text("""
|
||||
SELECT
|
||||
COUNT(*) as total,
|
||||
COUNT(*) FILTER (WHERE branch = 'hprod') as hprod_total,
|
||||
COUNT(*) FILTER (WHERE branch = 'prod') as prod_total,
|
||||
COUNT(*) FILTER (WHERE status != 'excluded') as total,
|
||||
COUNT(*) FILTER (WHERE branch = 'hprod' AND status != 'excluded') as hprod_total,
|
||||
COUNT(*) FILTER (WHERE branch = 'prod' AND status != 'excluded') as prod_total,
|
||||
COUNT(*) FILTER (WHERE status = 'patched') as patched,
|
||||
COUNT(*) FILTER (WHERE status = 'failed') as failed,
|
||||
COUNT(*) FILTER (WHERE status = 'pending') as pending,
|
||||
COUNT(*) FILTER (WHERE status = 'pending' AND branch = 'hprod') as hprod_pending,
|
||||
COUNT(*) FILTER (WHERE status = 'excluded') as excluded,
|
||||
COUNT(*) FILTER (WHERE status = 'skipped') as skipped,
|
||||
COUNT(*) FILTER (WHERE branch = 'hprod' AND status = 'patched') as hprod_patched,
|
||||
COUNT(*) FILTER (WHERE branch = 'prod' AND status = 'patched') as prod_patched,
|
||||
COUNT(*) FILTER (WHERE reboot_required) as reboot_count
|
||||
COUNT(*) FILTER (WHERE reboot_required AND status != 'excluded') as reboot_count
|
||||
FROM quickwin_entries WHERE run_id = :rid
|
||||
"""), {"rid": run_id}).fetchone()
|
||||
|
||||
|
||||
def advance_run_status(db, run_id, target_status, user=None):
|
||||
"""Avance le statut du run vers l'etape suivante"""
|
||||
VALID_TRANSITIONS = {
|
||||
"draft": "prereq",
|
||||
"prereq": "snapshot",
|
||||
"snapshot": "patching",
|
||||
"patching": "result",
|
||||
"result": "completed",
|
||||
}
|
||||
run = db.execute(text("SELECT status FROM quickwin_runs WHERE id = :id"),
|
||||
{"id": run_id}).fetchone()
|
||||
if not run:
|
||||
return False
|
||||
by = user.get("display_name", user.get("username", "")) if user else ""
|
||||
if target_status == "draft":
|
||||
db.execute(text("UPDATE quickwin_runs SET status = :st, updated_at = now() WHERE id = :id"),
|
||||
{"st": target_status, "id": run_id})
|
||||
log_info(db, run_id, "workflow", f"Retour a l'etape brouillon", created_by=by)
|
||||
db.commit()
|
||||
return True
|
||||
expected = VALID_TRANSITIONS.get(run.status)
|
||||
if expected != target_status:
|
||||
log_warn(db, run_id, "workflow",
|
||||
f"Transition refusee: {run.status} -> {target_status}", created_by=by)
|
||||
db.commit()
|
||||
return False
|
||||
db.execute(text("UPDATE quickwin_runs SET status = :st, updated_at = now() WHERE id = :id"),
|
||||
{"st": target_status, "id": run_id})
|
||||
log_info(db, run_id, "workflow", f"Passage a l'etape: {target_status}", created_by=by)
|
||||
db.commit()
|
||||
return True
|
||||
|
||||
|
||||
def get_step_stats(db, run_id, branch=None):
|
||||
"""Stats par etape pour un run (optionnel: filtre par branch)"""
|
||||
where = "run_id = :rid"
|
||||
params = {"rid": run_id}
|
||||
if branch:
|
||||
where += " AND branch = :br"
|
||||
params["br"] = branch
|
||||
return db.execute(text(f"""
|
||||
SELECT
|
||||
COUNT(*) as total,
|
||||
COUNT(*) FILTER (WHERE status NOT IN ('excluded','skipped')) as active,
|
||||
COUNT(*) FILTER (WHERE prereq_ok = true) as prereq_ok,
|
||||
COUNT(*) FILTER (WHERE prereq_ok = false) as prereq_ko,
|
||||
COUNT(*) FILTER (WHERE prereq_ok IS NULL AND status NOT IN ('excluded','skipped')) as prereq_pending,
|
||||
COUNT(*) FILTER (WHERE snap_done = true) as snap_ok,
|
||||
COUNT(*) FILTER (WHERE snap_done = false AND status NOT IN ('excluded','skipped')) as snap_pending,
|
||||
COUNT(*) FILTER (WHERE status = 'patched') as patched,
|
||||
COUNT(*) FILTER (WHERE status = 'failed') as failed,
|
||||
COUNT(*) FILTER (WHERE status = 'pending') as pending,
|
||||
COUNT(*) FILTER (WHERE reboot_required = true) as reboot_count
|
||||
FROM quickwin_entries WHERE {where}
|
||||
"""), params).fetchone()
|
||||
|
||||
|
||||
def check_prereqs(db, run_id, branch, user=None):
|
||||
"""Lance les verifications prerequis pour tous les serveurs d'une branche.
|
||||
Etapes par serveur: DNS resolution, SSH, espace disque, satellite."""
|
||||
from .quickwin_prereq_service import check_server_prereqs
|
||||
|
||||
entries = db.execute(text("""
|
||||
SELECT qe.id, s.hostname, s.domain_ltd, s.ssh_method,
|
||||
e.code as env_code
|
||||
FROM quickwin_entries qe
|
||||
JOIN servers s ON qe.server_id = s.id
|
||||
LEFT JOIN domain_environments de ON s.domain_env_id = de.id
|
||||
LEFT JOIN environments e ON de.environment_id = e.id
|
||||
WHERE qe.run_id = :rid AND qe.branch = :br
|
||||
AND qe.status NOT IN ('excluded','skipped')
|
||||
ORDER BY s.hostname
|
||||
"""), {"rid": run_id, "br": branch}).fetchall()
|
||||
|
||||
by = user.get("display_name", user.get("username", "")) if user else ""
|
||||
log_info(db, run_id, "prereq",
|
||||
f"Lancement check prerequis {branch} ({len(entries)} serveurs)", created_by=by)
|
||||
|
||||
results = []
|
||||
ok_count = 0
|
||||
for e in entries:
|
||||
try:
|
||||
r = check_server_prereqs(
|
||||
hostname=e.hostname,
|
||||
db=db,
|
||||
domain_ltd=e.domain_ltd,
|
||||
env_code=e.env_code,
|
||||
ssh_method=e.ssh_method or "ssh_key",
|
||||
)
|
||||
except Exception as ex:
|
||||
r = {"dns_ok": False, "ssh_ok": False, "satellite_ok": False,
|
||||
"disk_ok": False, "detail": str(ex), "skip": True}
|
||||
|
||||
prereq_ok = (r.get("dns_ok", False) and r.get("ssh_ok", False)
|
||||
and r.get("satellite_ok", False) and r.get("disk_ok", True))
|
||||
db.execute(text("""
|
||||
UPDATE quickwin_entries SET
|
||||
prereq_ok = :ok, prereq_ssh = :ssh, prereq_satellite = :sat, prereq_disk = :disk,
|
||||
prereq_detail = :detail, prereq_date = now(), updated_at = now()
|
||||
WHERE id = :id
|
||||
"""), {
|
||||
"id": e.id, "ok": prereq_ok,
|
||||
"ssh": r.get("ssh_ok", False), "sat": r.get("satellite_ok", False),
|
||||
"disk": r.get("disk_ok", True), "detail": r.get("detail", ""),
|
||||
})
|
||||
# Log par serveur
|
||||
detail_str = r.get("detail", "")
|
||||
if prereq_ok:
|
||||
ok_count += 1
|
||||
log_success(db, run_id, "prereq", f"OK: {e.hostname}",
|
||||
detail=detail_str, entry_id=e.id, hostname=e.hostname)
|
||||
else:
|
||||
log_error(db, run_id, "prereq", f"KO: {e.hostname}",
|
||||
detail=detail_str, entry_id=e.id, hostname=e.hostname)
|
||||
results.append({"hostname": e.hostname, "ok": prereq_ok, "detail": detail_str})
|
||||
|
||||
ko_count = len(results) - ok_count
|
||||
log_info(db, run_id, "prereq",
|
||||
f"Fin check {branch}: {ok_count} OK, {ko_count} KO sur {len(results)}", created_by=by)
|
||||
db.commit()
|
||||
return results
|
||||
|
||||
|
||||
def mark_snapshot(db, entry_id, done=True):
|
||||
"""Marque un serveur comme snapshot fait/pas fait"""
|
||||
db.execute(text("""
|
||||
UPDATE quickwin_entries SET snap_done = :done,
|
||||
snap_date = CASE WHEN :done THEN now() ELSE NULL END,
|
||||
updated_at = now() WHERE id = :id
|
||||
"""), {"id": entry_id, "done": done})
|
||||
db.commit()
|
||||
|
||||
|
||||
def mark_all_snapshots(db, run_id, branch, done=True):
|
||||
"""Marque tous les serveurs d'une branche comme snapshot fait"""
|
||||
db.execute(text("""
|
||||
UPDATE quickwin_entries SET snap_done = :done,
|
||||
snap_date = CASE WHEN :done THEN now() ELSE NULL END,
|
||||
updated_at = now()
|
||||
WHERE run_id = :rid AND branch = :br AND status NOT IN ('excluded','skipped')
|
||||
"""), {"rid": run_id, "br": branch, "done": done})
|
||||
db.commit()
|
||||
|
||||
|
||||
def build_yum_commands(db, run_id, branch):
|
||||
"""Construit les commandes yum pour chaque serveur d'une branche.
|
||||
Inclut tous les serveurs actifs (non excluded/skipped) avec snap fait."""
|
||||
entries = db.execute(text("""
|
||||
SELECT qe.id, s.hostname, qe.general_excludes, qe.specific_excludes
|
||||
FROM quickwin_entries qe
|
||||
JOIN servers s ON qe.server_id = s.id
|
||||
WHERE qe.run_id = :rid AND qe.branch = :br
|
||||
AND qe.status NOT IN ('excluded','skipped')
|
||||
AND qe.snap_done = true
|
||||
ORDER BY s.hostname
|
||||
"""), {"rid": run_id, "br": branch}).fetchall()
|
||||
|
||||
commands = []
|
||||
for e in entries:
|
||||
excludes = (e.general_excludes or "") + " " + (e.specific_excludes or "")
|
||||
parts = [p.strip() for p in excludes.split() if p.strip()]
|
||||
exclude_args = " ".join(f"--exclude={p}" for p in parts)
|
||||
cmd = f"yum update -y {exclude_args}".strip()
|
||||
db.execute(text("UPDATE quickwin_entries SET patch_command = :cmd WHERE id = :id"),
|
||||
{"cmd": cmd, "id": e.id})
|
||||
commands.append({"id": e.id, "hostname": e.hostname, "command": cmd})
|
||||
db.commit()
|
||||
return commands
|
||||
|
||||
|
||||
def inject_yum_history(db, data):
|
||||
"""Injecte l'historique yum dans quickwin_server_config.
|
||||
data = [{"server": "hostname", "yum_commands": [...]}]"""
|
||||
@ -252,3 +636,153 @@ def inject_yum_history(db, data):
|
||||
inserted += 1
|
||||
db.commit()
|
||||
return updated, inserted
|
||||
|
||||
|
||||
# ========== CORRESPONDANCE HPROD ↔ PROD ==========
|
||||
|
||||
def compute_correspondance(db, run_id, user=None):
|
||||
"""Auto-apparie chaque serveur hprod avec son homologue prod (2e lettre → p).
|
||||
Retourne (matched, unmatched, anomalies)."""
|
||||
by = user.get("display_name", user.get("username", "")) if user else ""
|
||||
|
||||
hprod_rows = db.execute(text("""
|
||||
SELECT qe.id, LOWER(s.hostname) as hostname
|
||||
FROM quickwin_entries qe JOIN servers s ON qe.server_id = s.id
|
||||
WHERE qe.run_id = :rid AND qe.branch = 'hprod' AND qe.status != 'excluded'
|
||||
"""), {"rid": run_id}).fetchall()
|
||||
|
||||
prod_rows = db.execute(text("""
|
||||
SELECT qe.id, LOWER(s.hostname) as hostname
|
||||
FROM quickwin_entries qe JOIN servers s ON qe.server_id = s.id
|
||||
WHERE qe.run_id = :rid AND qe.branch = 'prod' AND qe.status != 'excluded'
|
||||
"""), {"rid": run_id}).fetchall()
|
||||
|
||||
prod_by_host = {r.hostname: r.id for r in prod_rows}
|
||||
matched = 0
|
||||
unmatched = 0
|
||||
anomalies = 0
|
||||
skipped = 0
|
||||
|
||||
# Existing pairs — ne pas toucher
|
||||
existing = {r.id for r in db.execute(text("""
|
||||
SELECT id FROM quickwin_entries
|
||||
WHERE run_id = :rid AND branch = 'hprod' AND prod_pair_entry_id IS NOT NULL
|
||||
"""), {"rid": run_id}).fetchall()}
|
||||
|
||||
for h in hprod_rows:
|
||||
if h.id in existing:
|
||||
skipped += 1
|
||||
continue
|
||||
if len(h.hostname) < 2:
|
||||
unmatched += 1
|
||||
continue
|
||||
candidate = h.hostname[0] + 'p' + h.hostname[2:]
|
||||
if candidate == h.hostname:
|
||||
anomalies += 1
|
||||
if candidate in prod_by_host:
|
||||
db.execute(text("""
|
||||
UPDATE quickwin_entries SET prod_pair_entry_id = :pid WHERE id = :hid
|
||||
"""), {"pid": prod_by_host[candidate], "hid": h.id})
|
||||
matched += 1
|
||||
else:
|
||||
unmatched += 1
|
||||
|
||||
log_info(db, run_id, "correspondance",
|
||||
f"Auto-appariement: {matched} nouveaux, {skipped} conservés, {unmatched} sans homologue, {anomalies} anomalies",
|
||||
created_by=by)
|
||||
db.commit()
|
||||
return matched, unmatched, anomalies
|
||||
|
||||
|
||||
def get_correspondance(db, run_id, search=None, pair_filter=None, env_filter=None, domain_filter=None):
|
||||
"""Retourne la liste des hprod avec leur homologue prod (ou NULL)."""
|
||||
rows = db.execute(text("""
|
||||
SELECT hp.id as hprod_id, sh.hostname as hprod_hostname,
|
||||
dh.name as hprod_domaine, eh.name as hprod_env,
|
||||
SUBSTRING(LOWER(sh.hostname), 2, 1) as letter2,
|
||||
hp.prod_pair_entry_id,
|
||||
pp.id as prod_id, sp.hostname as prod_hostname,
|
||||
dp.name as prod_domaine
|
||||
FROM quickwin_entries hp
|
||||
JOIN servers sh ON hp.server_id = sh.id
|
||||
LEFT JOIN domain_environments deh ON sh.domain_env_id = deh.id
|
||||
LEFT JOIN domains dh ON deh.domain_id = dh.id
|
||||
LEFT JOIN environments eh ON deh.environment_id = eh.id
|
||||
LEFT JOIN quickwin_entries pp ON hp.prod_pair_entry_id = pp.id
|
||||
LEFT JOIN servers sp ON pp.server_id = sp.id
|
||||
LEFT JOIN domain_environments dep ON sp.domain_env_id = dep.id
|
||||
LEFT JOIN domains dp ON dep.domain_id = dp.id
|
||||
WHERE hp.run_id = :rid AND hp.branch = 'hprod' AND hp.status != 'excluded'
|
||||
ORDER BY sh.hostname
|
||||
"""), {"rid": run_id}).fetchall()
|
||||
|
||||
result = []
|
||||
for r in rows:
|
||||
candidate = ""
|
||||
if len(r.hprod_hostname) >= 2:
|
||||
candidate = r.hprod_hostname[0] + 'p' + r.hprod_hostname[2:]
|
||||
is_anomaly = (r.letter2 == 'p')
|
||||
is_matched = r.prod_pair_entry_id is not None
|
||||
|
||||
if pair_filter == "matched" and not is_matched:
|
||||
continue
|
||||
if pair_filter == "unmatched" and is_matched:
|
||||
continue
|
||||
if pair_filter == "anomaly" and not is_anomaly:
|
||||
continue
|
||||
if env_filter:
|
||||
env_map = {"preprod": "i", "recette": "r", "dev": "d", "test": "vt"}
|
||||
allowed_letters = env_map.get(env_filter, "")
|
||||
if r.letter2 not in allowed_letters:
|
||||
continue
|
||||
if domain_filter and (r.hprod_domaine or '') != domain_filter:
|
||||
continue
|
||||
if search and search.lower() not in r.hprod_hostname.lower():
|
||||
if not (r.prod_hostname and search.lower() in r.prod_hostname.lower()):
|
||||
continue
|
||||
|
||||
result.append({
|
||||
"hprod_id": r.hprod_id,
|
||||
"hprod_hostname": r.hprod_hostname,
|
||||
"hprod_domaine": r.hprod_domaine or "",
|
||||
"hprod_env": r.hprod_env or "",
|
||||
"letter2": r.letter2,
|
||||
"candidate": candidate,
|
||||
"is_anomaly": is_anomaly,
|
||||
"prod_id": r.prod_id,
|
||||
"prod_hostname": r.prod_hostname or "",
|
||||
"prod_domaine": r.prod_domaine or "",
|
||||
"is_matched": is_matched,
|
||||
})
|
||||
return result
|
||||
|
||||
|
||||
def get_available_prod_entries(db, run_id):
|
||||
"""Retourne toutes les entries prod (un prod peut etre apparie a plusieurs hprod)."""
|
||||
return db.execute(text("""
|
||||
SELECT qe.id, s.hostname, d.name as domaine
|
||||
FROM quickwin_entries qe
|
||||
JOIN servers s ON qe.server_id = s.id
|
||||
LEFT JOIN domain_environments de ON s.domain_env_id = de.id
|
||||
LEFT JOIN domains d ON de.domain_id = d.id
|
||||
WHERE qe.run_id = :rid AND qe.branch = 'prod' AND qe.status != 'excluded'
|
||||
ORDER BY s.hostname
|
||||
"""), {"rid": run_id}).fetchall()
|
||||
|
||||
|
||||
def set_prod_pair(db, hprod_entry_id, prod_entry_id):
|
||||
"""Associe manuellement un hprod à un prod (ou NULL pour dissocier)."""
|
||||
pid = prod_entry_id if prod_entry_id else None
|
||||
db.execute(text("""
|
||||
UPDATE quickwin_entries SET prod_pair_entry_id = :pid, updated_at = now() WHERE id = :hid
|
||||
"""), {"pid": pid, "hid": hprod_entry_id})
|
||||
db.commit()
|
||||
|
||||
|
||||
def clear_all_pairs(db, run_id):
|
||||
"""Supprime tous les appariements d'un run."""
|
||||
db.execute(text("""
|
||||
UPDATE quickwin_entries SET prod_pair_entry_id = NULL, updated_at = now()
|
||||
WHERE run_id = :rid AND branch = 'hprod'
|
||||
"""), {"rid": run_id})
|
||||
db.commit()
|
||||
|
||||
144
app/services/quickwin_snapshot_service.py
Normal file
144
app/services/quickwin_snapshot_service.py
Normal 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}"}
|
||||
@ -208,7 +208,7 @@ def update_server(db, server_id, data, username):
|
||||
params = {"id": server_id}
|
||||
direct_fields = ["tier", "etat", "patch_os_owner", "responsable_nom",
|
||||
"referent_nom", "mode_operatoire", "commentaire", "ssh_method",
|
||||
"pref_patch_jour", "pref_patch_heure"]
|
||||
"domain_ltd", "pref_patch_jour", "pref_patch_heure"]
|
||||
changed = []
|
||||
for field in direct_fields:
|
||||
if data.get(field) is not None:
|
||||
|
||||
@ -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/agents" class="block px-3 py-2 rounded-md text-sm hover:bg-cyber-border/30 {% if 'agents' in request.url.path %}bg-cyber-border/30 text-cyber-accent{% else %}text-gray-400{% endif %} pl-6 text-xs">Agents</a>{% endif %}
|
||||
{% if p.campaigns %}<a href="/safe-patching" class="block px-3 py-2 rounded-md text-sm hover:bg-cyber-border/30 {% if 'safe-patching' in request.url.path %}bg-cyber-border/30 text-cyber-accent{% else %}text-gray-400{% endif %}">Safe Patching</a>{% endif %}
|
||||
{% if p.campaigns or p.quickwin %}<a href="/quickwin" class="block px-3 py-2 rounded-md text-sm hover:bg-cyber-border/30 {% if 'quickwin' in request.url.path and 'safe' not in request.url.path %}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.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 %}
|
||||
@ -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.users %}<a href="/users" class="block px-3 py-2 rounded-md text-sm hover:bg-cyber-border/30 {% if 'users' in request.url.path %}bg-cyber-border/30 text-cyber-accent{% else %}text-gray-400{% endif %}">Utilisateurs</a>{% endif %}
|
||||
{% if p.settings %}<a href="/settings" class="block px-3 py-2 rounded-md text-sm hover:bg-cyber-border/30 {% if 'settings' in request.url.path %}bg-cyber-border/30 text-cyber-accent{% else %}text-gray-400{% endif %}">Settings</a>{% endif %}
|
||||
{% if p.settings %}<a href="/referentiel" class="block px-3 py-2 rounded-md text-sm hover:bg-cyber-border/30 {% if 'referentiel' in request.url.path %}bg-cyber-border/30 text-cyber-accent{% else %}text-gray-400{% endif %} pl-6 text-xs">Référentiel</a>{% endif %}
|
||||
</nav>
|
||||
</aside>
|
||||
<main class="flex-1 flex flex-col overflow-hidden">
|
||||
|
||||
@ -36,10 +36,17 @@
|
||||
{% for e in envs %}<option value="{{ e.code }}" {% if e.name == s.environnement %}selected{% endif %}>{{ e.name }}</option>{% endfor %}
|
||||
</select>
|
||||
</div>
|
||||
<div>
|
||||
<label class="text-xs text-gray-500">Domaine DNS (domain_ltd)</label>
|
||||
<select name="domain_ltd" class="w-full">
|
||||
<option value="">-- Aucun --</option>
|
||||
{% for d in dns_list %}<option value="{{ d }}" {% if d == s.domain_ltd %}selected{% endif %}>{{ d }}</option>{% endfor %}
|
||||
</select>
|
||||
</div>
|
||||
<div>
|
||||
<label class="text-xs text-gray-500">Zone</label>
|
||||
<select name="zone" class="w-full">
|
||||
{% for z in ['LAN','DMZ','EMV'] %}<option value="{{ z }}" {% if z == s.zone %}selected{% endif %}>{{ z }}</option>{% endfor %}
|
||||
{% for z in zones_list %}<option value="{{ z }}" {% if z == s.zone %}selected{% endif %}>{{ z }}</option>{% endfor %}
|
||||
</select>
|
||||
</div>
|
||||
<div>
|
||||
@ -70,18 +77,18 @@
|
||||
</div>
|
||||
<div class="grid grid-cols-2 gap-2">
|
||||
<div>
|
||||
<label class="text-xs text-gray-500">Jour préféré patching</label>
|
||||
<label class="text-xs text-gray-500">Jour préféré patching</label>
|
||||
<select name="pref_patch_jour" class="w-full">
|
||||
{% for j in ['indifferent','lundi','mardi','mercredi','jeudi'] %}<option value="{{ j }}" {% if j == s.pref_patch_jour %}selected{% endif %}>{{ j }}</option>{% endfor %}
|
||||
</select>
|
||||
</div>
|
||||
<div>
|
||||
<label class="text-xs text-gray-500">Heure préférée</label>
|
||||
<input type="text" name="pref_patch_heure" value="{{ s.pref_patch_heure or '' }}" placeholder="ex: 14h00, tôt le matin" class="w-full">
|
||||
<label class="text-xs text-gray-500">Heure préférée</label>
|
||||
<input type="text" name="pref_patch_heure" value="{{ s.pref_patch_heure or '' }}" placeholder="ex: 14h00, tôt le matin" class="w-full">
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<label class="text-xs text-gray-500">Mode opératoire</label>
|
||||
<label class="text-xs text-gray-500">Mode opératoire</label>
|
||||
<textarea name="mode_operatoire" rows="3" class="w-full">{{ s.mode_operatoire or '' }}</textarea>
|
||||
</div>
|
||||
<div>
|
||||
|
||||
258
app/templates/quickwin_correspondance.html
Normal file
258
app/templates/quickwin_correspondance.html
Normal 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">← Retour campagne</a>
|
||||
<h1 class="text-xl font-bold" style="color:#a78bfa">Correspondance H-Prod ↔ Prod</h1>
|
||||
<p class="text-xs text-gray-500">{{ run.label }} — 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é : {{ am }} apparié(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 été supprimé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é(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é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é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é-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é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é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électionné(s)</span>
|
||||
<button class="btn-sm" style="background:#ff336622;color:#ff3366;padding:3px 12px" onclick="bulkClear()">Dissocier la sélection</button>
|
||||
<span style="color:#1e3a5f">|</span>
|
||||
<span class="text-xs text-gray-400">Associer la sélection à :</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é</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é 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é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">←</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">…</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">→</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 %}
|
||||
@ -1,6 +1,18 @@
|
||||
{% extends "base.html" %}
|
||||
{% block title %}QuickWin #{{ run.id }}{% endblock %}
|
||||
|
||||
{% set STEPS = [
|
||||
("draft", "Brouillon", "#94a3b8"),
|
||||
("prereq", "Pr\u00e9requis", "#00d4ff"),
|
||||
("snapshot", "Snapshot", "#a78bfa"),
|
||||
("patching", "Patching", "#ffcc00"),
|
||||
("result", "R\u00e9sultats", "#00ff88"),
|
||||
("completed", "Termin\u00e9", "#10b981"),
|
||||
] %}
|
||||
{% set current_step_idx = namespace(val=0) %}
|
||||
{% for s in STEPS %}{% if s[0] == run.status %}{% set current_step_idx.val = loop.index0 %}{% endif %}{% endfor %}
|
||||
{% set can_modify = run.status in ('draft', 'prereq') %}
|
||||
|
||||
{% macro qs(hp=hp_page, pp=p_page) -%}
|
||||
?hp_page={{ hp }}&p_page={{ pp }}&per_page={{ per_page }}&search={{ filters.search or '' }}&status={{ filters.status or '' }}&domain={{ filters.domain or '' }}
|
||||
{%- endmacro %}
|
||||
@ -10,18 +22,11 @@
|
||||
<div>
|
||||
<a href="/quickwin" class="text-xs text-gray-500 hover:text-gray-300">← Retour campagnes</a>
|
||||
<h1 class="text-xl font-bold" style="color:#00d4ff">{{ run.label }}</h1>
|
||||
<p class="text-xs text-gray-500">S{{ '%02d'|format(run.week_number) }} {{ run.year }} — Créé par {{ run.created_by_name or '?' }} — pas de reboot nécessaire</p>
|
||||
<p class="text-xs text-gray-500">S{{ '%02d'|format(run.week_number) }} {{ run.year }} — Créé par {{ run.created_by_name or '?' }}</p>
|
||||
</div>
|
||||
<div class="flex gap-2 items-center">
|
||||
{% if run.status == 'draft' %}
|
||||
<span class="badge badge-gray" style="padding:4px 12px">Brouillon</span>
|
||||
{% elif run.status == 'hprod_done' %}
|
||||
<span class="badge badge-blue" style="padding:4px 12px">H-Prod terminé</span>
|
||||
{% elif run.status == 'completed' %}
|
||||
<span class="badge badge-green" style="padding:4px 12px">Terminé</span>
|
||||
{% else %}
|
||||
<span class="badge badge-yellow" style="padding:4px 12px">{{ run.status }}</span>
|
||||
{% endif %}
|
||||
<a href="/quickwin/{{ run.id }}/correspondance" class="btn-sm" style="background:#1e3a5f;color:#a78bfa;padding:4px 14px;text-decoration:none">Correspondance</a>
|
||||
<a href="/quickwin/{{ run.id }}/logs" class="btn-sm" style="background:#1e3a5f;color:#94a3b8;padding:4px 14px;text-decoration:none">Logs</a>
|
||||
<form method="post" action="/quickwin/{{ run.id }}/delete" onsubmit="return confirm('Supprimer cette campagne ?')">
|
||||
<button class="btn-sm btn-danger" style="padding:4px 12px">Supprimer</button>
|
||||
</form>
|
||||
@ -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>
|
||||
{% 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 %}✓{% 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">← Étape précé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">Étape suivante : {{ STEPS[current_step_idx.val + 1][1] }} →</button>
|
||||
</form>
|
||||
{% else %}<div></div>{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- KPIs -->
|
||||
<div style="display:grid;grid-template-columns:repeat(6,1fr);gap:12px;margin-bottom:20px">
|
||||
<div class="card p-3 text-center">
|
||||
<div class="text-2xl font-bold" style="color:#fff">{{ stats.total }}</div>
|
||||
@ -60,67 +100,353 @@
|
||||
</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érimètre de la campagne
|
||||
</h3>
|
||||
<p class="text-xs text-gray-500 mt-1">Cochez les domaines et zones à inclure. Les serveurs hors périmètre seront marqués « Exclu ».</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érimè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érification des prérequis</h3>
|
||||
<p class="text-xs text-gray-400 mb-3">Vérifie : résolution DNS, SSH (PSMP/Key), Satellite/YUM, espace disque (<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ê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 → recherche VM → snapshot automatique. Les serveurs physiques sont ignorés (vé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êter</button>
|
||||
<span class="text-xs text-gray-500" style="margin-left:8px">Ordre vCenter : H-Prod = Senlis → Nanterre → DR | Prod = Nanterre → Senlis → 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é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ê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ê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écution du patching</h3>
|
||||
<p class="text-xs text-gray-400 mb-3">Étape 1 : Générer les commandes. Étape 2 : Vérifier. Étape 3 : Exé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éné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éné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é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ê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é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é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ée</h3>
|
||||
<p class="text-xs text-gray-400 mb-3">{{ stats.patched }} patché(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élécharger le rapport</a>
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
<!-- Filtres table (contextuels selon l'etape) -->
|
||||
<form method="GET" class="card mb-4" style="padding:10px 16px;display:flex;gap:12px;align-items:center;flex-wrap:wrap">
|
||||
<input type="text" name="search" value="{{ filters.search or '' }}" placeholder="Recherche serveur..." style="width:200px">
|
||||
<select name="domain" onchange="this.form.submit()" style="width:160px">
|
||||
<option value="">Tous domaines</option>
|
||||
{% set doms = entries|map(attribute='domaine')|select('string')|unique|sort %}
|
||||
{% for d in doms %}<option value="{{ d }}" {% if filters.domain == d %}selected{% endif %}>{{ d }}</option>{% endfor %}
|
||||
</select>
|
||||
{% if run.status in ('prereq','snapshot','patching','result','completed') %}
|
||||
<select name="prereq_filter" onchange="this.form.submit()" style="width:140px">
|
||||
<option value="">Prereq: tous</option>
|
||||
<option value="ok" {% if filters.prereq == 'ok' %}selected{% endif %}>Prereq OK</option>
|
||||
<option value="ko" {% if filters.prereq == 'ko' %}selected{% endif %}>Prereq KO</option>
|
||||
<option value="pending" {% if filters.prereq == 'pending' %}selected{% endif %}>Non vérifié</option>
|
||||
</select>
|
||||
{% endif %}
|
||||
{% if run.status in ('snapshot','patching','result','completed') %}
|
||||
<select name="snap_filter" onchange="this.form.submit()" style="width:130px">
|
||||
<option value="">Snap: tous</option>
|
||||
<option value="ok" {% if filters.snap == 'ok' %}selected{% endif %}>Snap fait</option>
|
||||
<option value="pending" {% if filters.snap == 'pending' %}selected{% endif %}>Snap en attente</option>
|
||||
</select>
|
||||
{% endif %}
|
||||
{% if run.status in ('patching','result','completed') %}
|
||||
<select name="status" onchange="this.form.submit()" style="width:140px">
|
||||
<option value="">Tous statuts</option>
|
||||
<option value="pending" {% if filters.status == 'pending' %}selected{% endif %}>En attente</option>
|
||||
<option value="in_progress" {% if filters.status == 'in_progress' %}selected{% endif %}>En cours</option>
|
||||
<option value="patched" {% if filters.status == 'patched' %}selected{% endif %}>Patché</option>
|
||||
<option value="failed" {% if filters.status == 'failed' %}selected{% endif %}>KO</option>
|
||||
<option value="excluded" {% if filters.status == 'excluded' %}selected{% endif %}>Exclu</option>
|
||||
<option value="skipped" {% if filters.status == 'skipped' %}selected{% endif %}>Ignoré</option>
|
||||
</select>
|
||||
<select name="domain" onchange="this.form.submit()" style="width:160px">
|
||||
<option value="">Tous domaines</option>
|
||||
{% set all_entries_list = entries %}
|
||||
{% set doms = all_entries_list|map(attribute='domaine')|select('string')|unique|sort %}
|
||||
{% for d in doms %}<option value="{{ d }}" {% if filters.domain == d %}selected{% endif %}>{{ d }}</option>{% endfor %}
|
||||
</select>
|
||||
<select name="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 %}
|
||||
{% endif %}
|
||||
<select name="per_page" onchange="this.form.submit()" style="width:130px">
|
||||
<option value="">Par page</option>
|
||||
{% for n in [14, 25, 50, 100] %}<option value="{{ n }}" {% if per_page == n %}selected{% endif %}>{{ n }}</option>{% endfor %}
|
||||
</select>
|
||||
<button type="submit" class="btn-primary" style="padding:4px 14px;font-size:0.8rem">Filtrer</button>
|
||||
<a href="/quickwin/{{ run.id }}" class="text-xs text-gray-500 hover:text-gray-300">Reset</a>
|
||||
</form>
|
||||
|
||||
<!-- Regle hprod first -->
|
||||
{% if not prod_ok %}
|
||||
<div class="card mb-4" style="border-left:3px solid #ff3366;padding:12px 16px">
|
||||
<p style="color:#ff3366;font-size:0.85rem;font-weight:600">Hors-production d'abord : {{ stats.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>
|
||||
{% 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="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">
|
||||
<span class="badge badge-green">{{ hprod|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-gray">{{ hprod|selectattr('status','eq','pending')|list|length }} en attente</span>
|
||||
<span class="badge badge-green">{{ rows|selectattr('status','eq','patched')|list|length }} OK</span>
|
||||
<span class="badge badge-red">{{ rows|selectattr('status','eq','failed')|list|length }} KO</span>
|
||||
<span class="badge badge-gray">{{ rows|selectattr('status','eq','pending')|list|length }} en attente</span>
|
||||
{% if can_modify %}
|
||||
<button class="btn-sm" style="background:#ff336622;color:#ff3366;padding:2px 10px;font-size:0.7rem;margin-left:8px"
|
||||
onclick="removeSelected('{{ branch_key }}')">Supprimer sélection</button>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
<div class="table-wrap">
|
||||
<table class="table-cyber w-full">
|
||||
<thead><tr>
|
||||
{% if can_modify %}<th class="px-1 py-2" style="width:28px"><input type="checkbox" class="rm-check-all" data-branch="{{ branch_key }}" title="Tout"></th>{% endif %}
|
||||
<th class="px-2 py-2">Serveur</th>
|
||||
<th class="px-2 py-2">Domaine</th>
|
||||
<th class="px-2 py-2">Env</th>
|
||||
<th class="px-2 py-2">Statut</th>
|
||||
{% if run.status in ('prereq','snapshot','patching','result','completed') %}
|
||||
<th class="px-2 py-2">Prereq</th>
|
||||
{% endif %}
|
||||
{% if run.status in ('snapshot','patching','result','completed') %}
|
||||
<th class="px-2 py-2">Snap</th>
|
||||
{% endif %}
|
||||
<th class="px-2 py-2">Exclusions gén.</th>
|
||||
<th class="px-2 py-2">Exclusions spéc.</th>
|
||||
<th class="px-2 py-2">Packages</th>
|
||||
<th class="px-2 py-2">Date patch</th>
|
||||
<th class="px-2 py-2">Reboot</th>
|
||||
<th class="px-2 py-2">Notes</th>
|
||||
{% if run.status == 'patching' %}
|
||||
<th class="px-2 py-2">Action</th>
|
||||
{% endif %}
|
||||
</tr></thead>
|
||||
<tbody>
|
||||
{% for e in hprod %}
|
||||
{% for e in rows %}
|
||||
<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.environnement or '?' }}</td>
|
||||
<td class="px-2 py-2">
|
||||
@ -131,6 +457,27 @@
|
||||
{% elif e.status == 'skipped' %}<span class="badge badge-gray">Ignoré</span>
|
||||
{% else %}<span class="badge badge-gray">En attente</span>{% endif %}
|
||||
</td>
|
||||
{% if run.status in ('prereq','snapshot','patching','result','completed') %}
|
||||
<td class="px-2 py-2 text-center">
|
||||
{% if e.prereq_ok == true %}<span style="color:#00ff88" title="{{ e.prereq_detail }}">✓</span>
|
||||
{% elif e.prereq_ok == false %}<span style="color:#ff3366" title="{{ e.prereq_detail }}">✗</span>
|
||||
{% else %}<span style="color:#4a5568">—</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">✓</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">—</span>{% endif %}
|
||||
{% endif %}
|
||||
</td>
|
||||
{% endif %}
|
||||
<td class="px-2 py-2 text-xs" style="color:#ffcc00;max-width:150px;overflow:hidden;text-overflow:ellipsis;white-space:nowrap" title="{{ e.general_excludes }}">
|
||||
<span class="editable" data-id="{{ e.id }}" data-field="general_excludes">{{ e.general_excludes or '—' }}</span>
|
||||
</td>
|
||||
@ -143,91 +490,53 @@
|
||||
<td class="px-2 py-2 text-xs text-gray-500">
|
||||
<span class="editable" data-id="{{ e.id }}" data-field="notes">{{ e.notes or '—' }}</span>
|
||||
</td>
|
||||
{% if run.status == 'patching' %}
|
||||
<td class="px-2 py-2">
|
||||
{% if e.status == 'pending' and e.prereq_ok and e.snap_done %}
|
||||
<div class="flex gap-1">
|
||||
<form method="post" action="/quickwin/{{ run.id }}/mark-patched" style="display:inline">
|
||||
<input type="hidden" name="entry_id" value="{{ e.id }}">
|
||||
<input type="hidden" name="patch_status" value="patched">
|
||||
<button class="btn-sm" style="background:#00ff8822;color:#00ff88;font-size:0.6rem">OK</button>
|
||||
</form>
|
||||
<form method="post" action="/quickwin/{{ run.id }}/mark-patched" style="display:inline">
|
||||
<input type="hidden" name="entry_id" value="{{ e.id }}">
|
||||
<input type="hidden" name="patch_status" value="failed">
|
||||
<button class="btn-sm" style="background:#ff336622;color:#ff3366;font-size:0.6rem">KO</button>
|
||||
</form>
|
||||
</div>
|
||||
{% endif %}
|
||||
</td>
|
||||
{% endif %}
|
||||
</tr>
|
||||
{% endfor %}
|
||||
{% if not 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>
|
||||
</table>
|
||||
</div>
|
||||
<!-- Pagination H-PROD -->
|
||||
{% if hp_total_pages > 1 %}
|
||||
{% if total_pages > 1 %}
|
||||
<div class="flex justify-between items-center p-3 text-sm text-gray-500" style="border-top:1px solid #1e3a5f">
|
||||
<span>Page {{ hp_page }} / {{ hp_total_pages }} — {{ hprod_total }} serveur(s)</span>
|
||||
<span>Page {{ page_num }} / {{ total_pages }} — {{ total_count }} serveur(s)</span>
|
||||
<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écédent</a>{% endif %}
|
||||
{% 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 branch_key == 'hp' %}
|
||||
{% if page_num > 1 %}<a href="{{ qs(hp=page_num - 1, pp=p_page) }}" class="btn-sm bg-cyber-border text-gray-300">Pré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éc.</a>{% endif %}
|
||||
{% if page_num < total_pages %}<a href="{{ qs(hp=hp_page, pp=page_num + 1) }}" class="btn-sm bg-cyber-border text-gray-300">Suiv.</a>{% endif %}
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
{% endmacro %}
|
||||
|
||||
<!-- H-PROD -->
|
||||
{{ entry_table(hprod, "HORS-PRODUCTION", "#00d4ff", "hp", hp_page, hp_total_pages, hprod_total) }}
|
||||
|
||||
<!-- PROD -->
|
||||
{% if prod_ok %}
|
||||
<div class="card mb-4">
|
||||
<div class="p-3 flex items-center justify-between" style="border-bottom:1px solid #1e3a5f">
|
||||
<h2 class="text-sm font-bold" style="color:#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én.</th>
|
||||
<th class="px-2 py-2">Exclusions spé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é</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 }} — {{ 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écé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>
|
||||
{{ entry_table(prod, "PRODUCTION", "#ffcc00", "pr", p_page, p_total_pages, prod_total) }}
|
||||
{% endif %}
|
||||
|
||||
{% if run.notes %}
|
||||
@ -237,13 +546,81 @@
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
<!-- Hidden remove form -->
|
||||
<form method="post" action="/quickwin/{{ run.id }}/remove-entries" id="remove-form" style="display:none">
|
||||
<input type="hidden" name="entry_ids" id="remove-entry-ids" value="">
|
||||
</form>
|
||||
|
||||
<script>
|
||||
/* ---- Scope selector: collect checked domains/zones into hidden fields ---- */
|
||||
function prepareScopeForm() {
|
||||
const doms = [...document.querySelectorAll('.scope-dom:checked')].map(c => c.value);
|
||||
const zones = [...document.querySelectorAll('.scope-zone:checked')].map(c => c.value);
|
||||
if (!doms.length && !zones.length) {
|
||||
alert('Sélectionnez au moins un domaine ou une zone');
|
||||
return false;
|
||||
}
|
||||
document.getElementById('h-scope-domains').value = doms.join(',');
|
||||
document.getElementById('h-scope-zones').value = zones.join(',');
|
||||
return true;
|
||||
}
|
||||
/* ---- Scope: live preview of selection ---- */
|
||||
document.querySelectorAll('.scope-dom, .scope-zone').forEach(cb => {
|
||||
cb.addEventListener('change', function() {
|
||||
const dc = document.querySelectorAll('.scope-dom:checked').length;
|
||||
const dt = document.querySelectorAll('.scope-dom').length;
|
||||
const zc = document.querySelectorAll('.scope-zone:checked').length;
|
||||
const zt = document.querySelectorAll('.scope-zone').length;
|
||||
const el = document.getElementById('scope-preview');
|
||||
if (el) el.textContent = dc + '/' + dt + ' domaines, ' + zc + '/' + zt + ' zones';
|
||||
});
|
||||
});
|
||||
|
||||
/* ---- Add panel: select-all + prepare form ---- */
|
||||
const addCheckAll = document.getElementById('add-check-all');
|
||||
if (addCheckAll) {
|
||||
addCheckAll.addEventListener('change', function() {
|
||||
document.querySelectorAll('.add-check').forEach(cb => cb.checked = this.checked);
|
||||
updateAddCount();
|
||||
});
|
||||
document.querySelectorAll('.add-check').forEach(cb => {
|
||||
cb.addEventListener('change', updateAddCount);
|
||||
});
|
||||
}
|
||||
function updateAddCount() {
|
||||
const cnt = document.querySelectorAll('.add-check:checked').length;
|
||||
const el = document.getElementById('add-count');
|
||||
if (el) el.textContent = cnt;
|
||||
}
|
||||
function prepareAddForm() {
|
||||
const ids = [...document.querySelectorAll('.add-check:checked')].map(cb => cb.value);
|
||||
if (!ids.length) { alert('Aucun serveur s\u00e9lectionn\u00e9'); return false; }
|
||||
document.getElementById('add-server-ids').value = ids.join(',');
|
||||
return true;
|
||||
}
|
||||
|
||||
/* ---- Remove: select-all per branch + submit ---- */
|
||||
document.querySelectorAll('.rm-check-all').forEach(masterCb => {
|
||||
masterCb.addEventListener('change', function() {
|
||||
const branch = this.dataset.branch;
|
||||
document.querySelectorAll('.rm-' + branch).forEach(cb => cb.checked = this.checked);
|
||||
});
|
||||
});
|
||||
function removeSelected(branch) {
|
||||
const ids = [...document.querySelectorAll('.rm-' + branch + ':checked')].map(cb => cb.value);
|
||||
if (!ids.length) { alert('Aucun serveur s\u00e9lectionn\u00e9'); return; }
|
||||
if (!confirm('Supprimer ' + ids.length + ' serveur(s) de la campagne ?')) return;
|
||||
document.getElementById('remove-entry-ids').value = ids.join(',');
|
||||
document.getElementById('remove-form').submit();
|
||||
}
|
||||
|
||||
/* ---- Inline edit ---- */
|
||||
document.querySelectorAll('.editable').forEach(el => {
|
||||
el.style.cursor = 'pointer';
|
||||
el.addEventListener('dblclick', function() {
|
||||
const field = this.dataset.field;
|
||||
const id = this.dataset.id;
|
||||
const current = this.textContent.trim() === '—' ? '' : this.textContent.trim();
|
||||
const current = this.textContent.trim() === '\u2014' ? '' : this.textContent.trim();
|
||||
const input = document.createElement('input');
|
||||
input.value = current;
|
||||
input.style.cssText = 'background:#0a0e17;border:1px solid #00d4ff;color:#fff;padding:2px 6px;border-radius:4px;font-size:0.75rem;width:100%';
|
||||
@ -253,7 +630,7 @@ document.querySelectorAll('.editable').forEach(el => {
|
||||
input.select();
|
||||
const save = () => {
|
||||
const val = input.value.trim();
|
||||
this.textContent = val || '—';
|
||||
this.textContent = val || '\u2014';
|
||||
fetch('/api/quickwin/entry/update', {
|
||||
method: 'POST',
|
||||
headers: {'Content-Type': 'application/json'},
|
||||
@ -263,9 +640,251 @@ document.querySelectorAll('.editable').forEach(el => {
|
||||
input.addEventListener('blur', save);
|
||||
input.addEventListener('keydown', e => {
|
||||
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> — <span style="color:#ff3366">' + koCount + ' KO</span>';
|
||||
progress.textContent = d.idx + '/' + d.total;
|
||||
} else if (d.type === 'done') {
|
||||
addLine(log, '\n>>> Termin\u00e9 : ' + d.ok + ' OK, ' + d.ko + ' KO sur ' + d.total, '#00d4ff');
|
||||
stopPrereqStream();
|
||||
progress.textContent = 'Termin\u00e9';
|
||||
}
|
||||
};
|
||||
prereqSource.onerror = function() {
|
||||
addLine(log, '>>> Connexion interrompue', '#ff3366');
|
||||
stopPrereqStream();
|
||||
};
|
||||
}
|
||||
function stopPrereqStream() {
|
||||
if (prereqSource) { prereqSource.close(); prereqSource = null; }
|
||||
const btnStop = document.getElementById('btn-stop');
|
||||
if (btnStop) btnStop.style.display = 'none';
|
||||
const btnStop2 = document.getElementById('btn-stop-prereq-prod');
|
||||
if (btnStop2) btnStop2.style.display = 'none';
|
||||
const btnHprod = document.getElementById('btn-check-hprod');
|
||||
if (btnHprod) btnHprod.disabled = false;
|
||||
const btnProd = document.getElementById('btn-check-prod');
|
||||
if (btnProd) btnProd.disabled = false;
|
||||
const btnProdP = document.getElementById('btn-check-prod-p');
|
||||
if (btnProdP) btnProdP.disabled = false;
|
||||
}
|
||||
function addLine(container, text, color) {
|
||||
const el = document.createElement('div');
|
||||
el.style.color = color || '#ccc';
|
||||
el.style.whiteSpace = 'pre-wrap';
|
||||
el.style.wordBreak = 'break-all';
|
||||
el.textContent = text;
|
||||
container.appendChild(el);
|
||||
container.scrollTop = container.scrollHeight;
|
||||
}
|
||||
|
||||
/* ---- SSE Snapshot Terminal ---- */
|
||||
let snapSource = null;
|
||||
function startSnapshotStream(branch) {
|
||||
const terminal = document.getElementById('snap-terminal');
|
||||
const log = document.getElementById('snap-log');
|
||||
const progress = document.getElementById('snap-progress');
|
||||
const stats = document.getElementById('snap-stats');
|
||||
const btnStop = document.getElementById('btn-snap-stop');
|
||||
|
||||
terminal.style.display = 'block';
|
||||
log.innerHTML = '';
|
||||
const btnStop2 = document.getElementById('btn-snap-stop-prod');
|
||||
if (btnStop) btnStop.style.display = 'inline-block';
|
||||
if (btnStop2) btnStop2.style.display = 'inline-block';
|
||||
const btnSnapHprod = document.getElementById('btn-snap-hprod');
|
||||
if (btnSnapHprod) btnSnapHprod.disabled = true;
|
||||
const btnProd = document.getElementById('btn-snap-prod');
|
||||
if (btnProd) btnProd.disabled = true;
|
||||
const btnProdP = document.getElementById('btn-snap-prod-p');
|
||||
if (btnProdP) btnProdP.disabled = true;
|
||||
|
||||
let okCount = 0, koCount = 0;
|
||||
|
||||
snapSource = new EventSource('/quickwin/{{ run.id }}/snapshot-stream?branch=' + branch);
|
||||
snapSource.onmessage = function(ev) {
|
||||
const d = JSON.parse(ev.data);
|
||||
if (d.type === 'start') {
|
||||
addLine(log, '>>> Snapshots ' + d.branch + ' : ' + d.vms + ' VMs, ' + d.physical + ' physique(s) ignor\u00e9(s)', '#a78bfa');
|
||||
if (d.physical > 0) {
|
||||
addLine(log, '\u26a0 ' + d.physical + ' serveur(s) physique(s) : pas de snapshot VM. V\u00e9rifier les backups Commvault.', '#ffcc00');
|
||||
}
|
||||
} else if (d.type === 'progress') {
|
||||
progress.textContent = d.idx + '/' + d.total + ' \u2014 ' + d.hostname + '...';
|
||||
} else if (d.type === 'result') {
|
||||
const color = d.ok ? '#00ff88' : '#ff3366';
|
||||
const icon = d.ok ? '\u2713' : '\u2717';
|
||||
let line = icon + ' ' + d.hostname;
|
||||
if (d.vcenter) line += ' [' + d.vcenter + ']';
|
||||
line += ' \u2014 ' + d.detail;
|
||||
addLine(log, line, color);
|
||||
if (d.ok) okCount++; else koCount++;
|
||||
stats.innerHTML = '<span style="color:#00ff88">' + okCount + ' OK</span> \u2014 <span style="color:#ff3366">' + koCount + ' KO</span>';
|
||||
progress.textContent = d.idx + '/' + d.total;
|
||||
} else if (d.type === 'done') {
|
||||
let summary = '\n>>> Termin\u00e9 : ' + d.ok + ' OK, ' + d.ko + ' KO sur ' + d.total + ' VMs';
|
||||
if (d.physical > 0) summary += ' (' + d.physical + ' physiques ignor\u00e9s)';
|
||||
addLine(log, summary, '#a78bfa');
|
||||
stopSnapshotStream();
|
||||
progress.textContent = 'Termin\u00e9';
|
||||
}
|
||||
};
|
||||
snapSource.onerror = function() {
|
||||
addLine(log, '>>> Connexion interrompue', '#ff3366');
|
||||
stopSnapshotStream();
|
||||
};
|
||||
}
|
||||
function stopSnapshotStream() {
|
||||
if (snapSource) { snapSource.close(); snapSource = null; }
|
||||
const btnStop = document.getElementById('btn-snap-stop');
|
||||
if (btnStop) btnStop.style.display = 'none';
|
||||
const btnStop2 = document.getElementById('btn-snap-stop-prod');
|
||||
if (btnStop2) btnStop2.style.display = 'none';
|
||||
const btnSnapHprod = document.getElementById('btn-snap-hprod');
|
||||
if (btnSnapHprod) btnSnapHprod.disabled = false;
|
||||
const btnProd = document.getElementById('btn-snap-prod');
|
||||
if (btnProd) btnProd.disabled = false;
|
||||
const btnProdP = document.getElementById('btn-snap-prod-p');
|
||||
if (btnProdP) btnProdP.disabled = false;
|
||||
}
|
||||
|
||||
/* ---- Patching: load commands + execute via SSE ---- */
|
||||
let patchBranch = 'hprod';
|
||||
function loadCommands(branch) {
|
||||
patchBranch = branch;
|
||||
fetch('/api/quickwin/{{ run.id }}/commands/' + branch)
|
||||
.then(r => r.json())
|
||||
.then(cmds => {
|
||||
const panel = document.getElementById('cmd-panel');
|
||||
const tbody = document.getElementById('cmd-tbody');
|
||||
const title = document.getElementById('cmd-title');
|
||||
title.textContent = cmds.length + ' commande(s) ' + (branch === 'hprod' ? 'H-Prod' : 'Prod');
|
||||
tbody.innerHTML = '';
|
||||
if (!cmds.length) {
|
||||
tbody.innerHTML = '<tr><td colspan="2" class="px-2 py-4 text-center text-gray-500">Aucune commande. G\u00e9n\u00e9rez d\'abord les commandes.</td></tr>';
|
||||
}
|
||||
cmds.forEach(c => {
|
||||
const tr = document.createElement('tr');
|
||||
tr.innerHTML = '<td class="px-2 py-1 font-bold" style="color:#00d4ff">' + c.hostname + '</td>'
|
||||
+ '<td class="px-2 py-1" style="color:#ffcc00;font-family:monospace;word-break:break-all">' + c.command + '</td>';
|
||||
tbody.appendChild(tr);
|
||||
});
|
||||
panel.style.display = 'block';
|
||||
});
|
||||
}
|
||||
function confirmExec() {
|
||||
if (!confirm('ATTENTION : Ceci va ex\u00e9cuter yum update sur tous les serveurs ' + patchBranch.toUpperCase() + '.\n\nConfirmer l\'ex\u00e9cution ?')) return;
|
||||
startPatchStream(patchBranch);
|
||||
}
|
||||
let patchSource = null;
|
||||
function startPatchStream(branch) {
|
||||
const terminal = document.getElementById('patch-terminal');
|
||||
const log = document.getElementById('patch-log');
|
||||
const progress = document.getElementById('patch-progress');
|
||||
const stats = document.getElementById('patch-stats');
|
||||
const btnStop = document.getElementById('btn-patch-stop');
|
||||
const btnExec = document.getElementById('btn-exec-patch');
|
||||
|
||||
terminal.style.display = 'block';
|
||||
log.innerHTML = '';
|
||||
btnStop.style.display = 'inline-block';
|
||||
btnExec.disabled = true;
|
||||
|
||||
let okCount = 0, koCount = 0;
|
||||
|
||||
patchSource = new EventSource('/quickwin/{{ run.id }}/patch-stream?branch=' + branch);
|
||||
patchSource.onmessage = function(ev) {
|
||||
const d = JSON.parse(ev.data);
|
||||
if (d.type === 'start') {
|
||||
addLine(log, '>>> Patching ' + d.branch + ' (' + d.total + ' serveurs)', '#ffcc00');
|
||||
} else if (d.type === 'progress') {
|
||||
const st = d.status === 'connecting' ? 'Connexion SSH...' : 'Ex\u00e9cution yum...';
|
||||
progress.textContent = d.idx + '/' + d.total + ' \u2014 ' + d.hostname + ' \u2014 ' + st;
|
||||
if (d.status === 'connecting') {
|
||||
addLine(log, '\n[' + d.idx + '/' + d.total + '] ' + d.hostname + ' \u2014 connexion...', '#94a3b8');
|
||||
} else {
|
||||
addLine(log, ' \u2192 ' + d.command, '#ffcc00');
|
||||
}
|
||||
} else if (d.type === 'result') {
|
||||
const color = d.ok ? '#00ff88' : '#ff3366';
|
||||
const icon = d.ok ? '\u2713' : '\u2717';
|
||||
let line = ' ' + icon + ' ' + d.hostname;
|
||||
if (d.packages) line += ' (' + d.packages + ' paquets)';
|
||||
if (d.reboot) line += ' [REBOOT REQUIS]';
|
||||
if (d.exit_code !== undefined && d.exit_code !== 0) line += ' (exit ' + d.exit_code + ')';
|
||||
addLine(log, line, color);
|
||||
if (d.detail) {
|
||||
const color2 = d.ok ? '#6b7280' : '#ff6688';
|
||||
const lines = d.detail.split('\n').filter(l => l.trim());
|
||||
lines.forEach(l => addLine(log, ' \u2502 ' + l.trim(), color2));
|
||||
}
|
||||
if (d.ok) okCount++; else koCount++;
|
||||
stats.innerHTML = '<span style="color:#00ff88">' + okCount + ' OK</span> \u2014 <span style="color:#ff3366">' + koCount + ' KO</span>';
|
||||
progress.textContent = d.idx + '/' + d.total;
|
||||
} else if (d.type === 'done') {
|
||||
addLine(log, '\n>>> Termin\u00e9 : ' + d.ok + ' OK, ' + d.ko + ' KO sur ' + d.total, '#ffcc00');
|
||||
stopPatchStream();
|
||||
progress.textContent = 'Termin\u00e9';
|
||||
}
|
||||
};
|
||||
patchSource.onerror = function() {
|
||||
addLine(log, '>>> Connexion interrompue', '#ff3366');
|
||||
stopPatchStream();
|
||||
};
|
||||
}
|
||||
function stopPatchStream() {
|
||||
if (patchSource) { patchSource.close(); patchSource = null; }
|
||||
document.getElementById('btn-patch-stop').style.display = 'none';
|
||||
document.getElementById('btn-exec-patch').disabled = false;
|
||||
}
|
||||
|
||||
/* ---- Auto-load commands if redirected with show_cmds ---- */
|
||||
(function() {
|
||||
const params = new URLSearchParams(window.location.search);
|
||||
const showBranch = params.get('show_cmds');
|
||||
if (showBranch) loadCommands(showBranch);
|
||||
})();
|
||||
</script>
|
||||
{% endblock %}
|
||||
|
||||
107
app/templates/quickwin_logs.html
Normal file
107
app/templates/quickwin_logs.html
Normal 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">← Retour campagne</a>
|
||||
<h1 class="text-xl font-bold" style="color:#00d4ff">Logs — {{ run.label }}</h1>
|
||||
<p class="text-xs text-gray-500">S{{ '%02d'|format(run.week_number) }} {{ run.year }} — {{ total_logs }} entré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 é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">É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 %}
|
||||
408
app/templates/referentiel.html
Normal file
408
app/templates/referentiel.html
Normal file
@ -0,0 +1,408 @@
|
||||
{% extends "base.html" %}
|
||||
{% block title %}Référentiel{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<div class="flex items-center justify-between mb-4">
|
||||
<div>
|
||||
<h1 class="text-xl font-bold" style="color:#00d4ff">Référentiel</h1>
|
||||
<p class="text-xs text-gray-500">Gestion centralisé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ément ajouté avec succè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ément mis à 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ément supprimé.
|
||||
</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é(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 × environnement existe déjà.
|
||||
</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éfaut</label>
|
||||
<input type="text" name="default_excludes" style="width:200px">
|
||||
</div>
|
||||
<div>
|
||||
<label class="text-xs text-gray-500">Fenê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éférent</th>
|
||||
<th class="px-2 py-2">Email ré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éférent</label>
|
||||
<input type="text" name="referent_nom" style="width:140px">
|
||||
</div>
|
||||
<div>
|
||||
<label class="text-xs text-gray-500">Email ré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é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 %}
|
||||
Loading…
Reference in New Issue
Block a user