From a7874aec11c50b202a79c1dfba31856bf1fc17b6 Mon Sep 17 00:00:00 2001 From: Admin MPCZ Date: Tue, 5 May 2026 14:05:59 +0200 Subject: [PATCH] feat(settings/clusters M2): UI CRUD server_clusters - groupes + ordre redemarrage + strategie sequential/parallel + panneau detail serveurs rattaches --- app/routers/settings.py | 108 ++++++++++++++++++++++++++++++++ app/templates/settings.html | 121 ++++++++++++++++++++++++++++++++++++ 2 files changed, 229 insertions(+) diff --git a/app/routers/settings.py b/app/routers/settings.py index ba110aa..e55376a 100644 --- a/app/routers/settings.py +++ b/app/routers/settings.py @@ -139,6 +139,14 @@ def _build_context(db, user, saved=None): FROM teams_channels ORDER BY is_default DESC, name """)).fetchall() + server_clusters = db.execute(text(""" + SELECT c.id, c.name, c.description, c.reboot_strategy, c.is_active, c.created_at, + COUNT(s.id) AS server_count + FROM server_clusters c + LEFT JOIN servers s ON s.cluster_id = c.id + GROUP BY c.id, c.name, c.description, c.reboot_strategy, c.is_active, c.created_at + ORDER BY c.name + """)).fetchall() # Filtrer les sections visibles selon le role visible = {s: s in SECTION_ACCESS and role in SECTION_ACCESS[s]["visible"] for s in SECTIONS} @@ -151,6 +159,7 @@ def _build_context(db, user, saved=None): "q_tags": q_tags, "q_assets": q_assets, "q_linked": q_linked, "vcenters": vcenters, "saved": saved, "teams_channels": teams_channels, + "server_clusters": server_clusters, "visible": visible, "editable": editable, } @@ -346,6 +355,105 @@ async def teams_channel_test(request: Request, tc_id: int, db=Depends(get_db)): return JSONResponse({"ok": False, "msg": f"Erreur: {e}"}, status_code=500) +# --- Server clusters CRUD --- + +@router.post("/settings/server-cluster/add", response_class=HTMLResponse) +async def server_cluster_add(request: Request, db=Depends(get_db), + sc_name: str = Form(...), + sc_description: str = Form(""), + sc_reboot_strategy: str = Form("sequential")): + user = get_current_user(request) + if not user: + return RedirectResponse(url="/login") + perms = get_user_perms(db, user) + if not can_edit(perms, "settings"): + return RedirectResponse(url="/settings") + strat = sc_reboot_strategy if sc_reboot_strategy in ("sequential", "parallel") else "sequential" + try: + db.execute(text(""" + INSERT INTO server_clusters (name, description, reboot_strategy) + VALUES (:n, :d, :s) + """), {"n": sc_name.strip(), "d": (sc_description or None), "s": strat}) + db.commit() + except Exception as e: + db.rollback() + # Probablement violation unique sur name + logger.warning(f"server_cluster_add failed: {e}") + ctx = _build_context(db, user, saved="cluster") + ctx["request"] = request + return templates.TemplateResponse("settings.html", ctx) + + +@router.post("/settings/server-cluster/{sc_id}/edit", response_class=HTMLResponse) +async def server_cluster_edit(request: Request, sc_id: int, db=Depends(get_db), + sc_name: str = Form(...), + sc_description: str = Form(""), + sc_reboot_strategy: str = Form("sequential"), + sc_is_active: 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="/settings") + strat = sc_reboot_strategy if sc_reboot_strategy in ("sequential", "parallel") else "sequential" + db.execute(text(""" + UPDATE server_clusters + SET name=:n, description=:d, reboot_strategy=:s, is_active=:ia + WHERE id=:id + """), {"n": sc_name.strip(), "d": (sc_description or None), "s": strat, + "ia": bool(sc_is_active), "id": sc_id}) + db.commit() + ctx = _build_context(db, user, saved="cluster") + ctx["request"] = request + return templates.TemplateResponse("settings.html", ctx) + + +@router.post("/settings/server-cluster/{sc_id}/delete", response_class=HTMLResponse) +async def server_cluster_delete(request: Request, sc_id: int, db=Depends(get_db)): + user = get_current_user(request) + if not user: + return RedirectResponse(url="/login") + perms = get_user_perms(db, user) + if not can_edit(perms, "settings"): + return RedirectResponse(url="/settings") + # Check qu'aucun serveur n'y est encore rattaché + cnt = db.execute(text("SELECT COUNT(*) FROM servers WHERE cluster_id=:id"), + {"id": sc_id}).scalar() or 0 + if cnt > 0: + # Désactivation seule (ne pas casser les FK et l'historique) + db.execute(text("UPDATE server_clusters SET is_active=false WHERE id=:id"), {"id": sc_id}) + else: + db.execute(text("DELETE FROM server_clusters WHERE id=:id"), {"id": sc_id}) + db.commit() + ctx = _build_context(db, user, saved="cluster") + ctx["request"] = request + return templates.TemplateResponse("settings.html", ctx) + + +@router.get("/settings/server-cluster/{sc_id}/servers") +async def server_cluster_servers(request: Request, sc_id: int, db=Depends(get_db)): + """Liste des serveurs du cluster avec leur cluster_order — JSON pour panneau détail.""" + from fastapi.responses import JSONResponse + user = get_current_user(request) + if not user: + return JSONResponse({"ok": False, "msg": "Non authentifié"}, status_code=401) + perms = get_user_perms(db, user) + if not can_view(perms, "settings"): + return JSONResponse({"ok": False, "msg": "Permission refusée"}, status_code=403) + rows = db.execute(text(""" + SELECT id, hostname, cluster_order, machine_type, etat + FROM servers + WHERE cluster_id = :id + ORDER BY cluster_order NULLS LAST, hostname + """), {"id": sc_id}).fetchall() + return JSONResponse({"ok": True, "count": len(rows), + "servers": [{"id": r.id, "hostname": str(r.hostname), + "order": r.cluster_order, + "machine_type": r.machine_type, + "etat": r.etat} for r in rows]}) + + # --- Secret individuel --- @router.post("/settings/secret/update", response_class=HTMLResponse) diff --git a/app/templates/settings.html b/app/templates/settings.html index 5b04dda..c0d7ae8 100644 --- a/app/templates/settings.html +++ b/app/templates/settings.html @@ -362,6 +362,99 @@ {% endif %} + + {% if visible.vsphere %} +
+ +
+

+ Les clusters permettent de patcher plusieurs serveurs liés (ex : DB master + slaves, + HAProxy + backends) avec un ordre de redémarrage. Le champ + cluster_order sur chaque serveur fixe la + séquence (1 = premier patché). Stratégie sequential = un par un (recommandé) + ou parallel = tous en même temps. +

+ +
+

Clusters enregistrés

+ + + + + + + + {% if editable.vsphere %}{% endif %} + + + {% for sc in server_clusters %} + + + + + + + {% if editable.vsphere %} + + {% endif %} + + {% else %} + + {% endfor %} + +
NomDescriptionStratégie# ServeursActifActions
{{ sc.name }}{{ sc.description or '-' }}{{ sc.reboot_strategy }} + {{ sc.server_count }} + {{ 'Oui' if sc.is_active else 'Non' }} +
+ +
+
Aucun cluster configuré.
+ +
+ + {% if editable.vsphere %} +
+

Ajouter un cluster

+
+
+ + +
+
+ + +
+
+ + +
+
+ +
+ {% endif %} +
+
+ {% endif %} + {% if visible.security %}
@@ -621,6 +714,34 @@ function testLdap() { .catch(function(e){ out.textContent = '✗ ' + e.message; out.className = 'text-xs ml-2 text-cyber-red'; }); } +function showClusterServers(scId, scName) { + var detail = document.getElementById('cluster-servers-detail'); + var title = document.getElementById('cluster-servers-title'); + var list = document.getElementById('cluster-servers-list'); + title.textContent = 'Cluster : ' + scName; + list.innerHTML = 'Chargement…'; + detail.classList.remove('hidden'); + fetch('/settings/server-cluster/' + scId + '/servers', {credentials: 'same-origin'}) + .then(function(r){ return r.json(); }) + .then(function(d){ + if (!d.ok) { list.innerHTML = '' + (d.msg||'Erreur') + ''; return; } + if (!d.servers.length) { list.innerHTML = 'Aucun serveur dans ce cluster.'; return; } + var rows = d.servers.map(function(s){ + var ord = (s.order !== null && s.order !== undefined) ? s.order : '–'; + return '' + ord + '' + + '' + s.hostname + '' + + '' + (s.machine_type || '-') + '' + + '' + (s.etat || '-') + ''; + }).join(''); + list.innerHTML = '' + + '' + + '' + + '' + + '' + rows + '
OrdreHostnameTypeÉtat
'; + }) + .catch(function(e){ list.innerHTML = '' + e.message + ''; }); +} + function testTeams(tcId, tcName) { fetch('/settings/teams-channel/' + tcId + '/test', {method: 'POST', credentials: 'same-origin'}) .then(function(r){ return r.json(); })