feat(settings/clusters M2): UI CRUD server_clusters - groupes + ordre redemarrage + strategie sequential/parallel + panneau detail serveurs rattaches

This commit is contained in:
Pierre & Lumière 2026-05-05 14:05:59 +02:00
parent 075706178e
commit a7874aec11
2 changed files with 229 additions and 0 deletions

View File

@ -139,6 +139,14 @@ def _build_context(db, user, saved=None):
FROM teams_channels FROM teams_channels
ORDER BY is_default DESC, name ORDER BY is_default DESC, name
""")).fetchall() """)).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 # Filtrer les sections visibles selon le role
visible = {s: s in SECTION_ACCESS and role in SECTION_ACCESS[s]["visible"] for s in SECTIONS} 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, "q_tags": q_tags, "q_assets": q_assets, "q_linked": q_linked,
"vcenters": vcenters, "saved": saved, "vcenters": vcenters, "saved": saved,
"teams_channels": teams_channels, "teams_channels": teams_channels,
"server_clusters": server_clusters,
"visible": visible, "editable": editable, "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) 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 --- # --- Secret individuel ---
@router.post("/settings/secret/update", response_class=HTMLResponse) @router.post("/settings/secret/update", response_class=HTMLResponse)

View File

@ -362,6 +362,99 @@
</div> </div>
{% endif %} {% endif %}
<!-- Server Clusters -->
{% if visible.vsphere %}
<div class="card overflow-hidden">
<button @click="open = open === 'clusters' ? '' : 'clusters'" class="w-full flex items-center justify-between p-4 hover:bg-cyber-border/20 transition-colors">
<div class="flex items-center gap-3">
<span class="text-cyber-accent font-bold">Groupes de serveurs (clusters)</span>
<span class="badge badge-gray">Patching</span>
<span class="text-xs text-gray-500">{{ server_clusters|length }} défini(s)</span>
</div>
<span class="text-gray-500 text-lg" x-text="open === 'clusters' ? '&#9660;' : '&#9654;'"></span>
</button>
<div x-show="open === 'clusters'" class="border-t border-cyber-border p-4 space-y-4">
<p class="text-xs text-gray-500">
Les clusters permettent de patcher plusieurs serveurs liés (ex : DB master + slaves,
HAProxy + backends) avec un ordre de redémarrage. Le champ
<code class="text-cyber-accent">cluster_order</code> sur chaque serveur fixe la
séquence (1 = premier patché). Stratégie <em>sequential</em> = un par un (recommandé)
ou <em>parallel</em> = tous en même temps.
</p>
<div>
<h4 class="text-xs text-cyber-accent font-bold uppercase mb-2">Clusters enregistrés</h4>
<table class="w-full table-cyber text-sm">
<thead><tr>
<th class="text-left p-2">Nom</th>
<th class="text-left p-2">Description</th>
<th class="p-2">Stratégie</th>
<th class="p-2"># Serveurs</th>
<th class="p-2">Actif</th>
{% if editable.vsphere %}<th class="p-2">Actions</th>{% endif %}
</tr></thead>
<tbody>
{% for sc in server_clusters %}
<tr>
<td class="p-2 font-bold">{{ sc.name }}</td>
<td class="p-2 text-xs text-gray-400">{{ sc.description or '-' }}</td>
<td class="p-2 text-center text-xs">{{ sc.reboot_strategy }}</td>
<td class="p-2 text-center">
<a href="#" onclick="showClusterServers({{ sc.id }}, '{{ sc.name|e }}'); return false;"
class="text-cyber-accent hover:underline">{{ sc.server_count }}</a>
</td>
<td class="p-2 text-center"><span class="badge {% if sc.is_active %}badge-green{% else %}badge-red{% endif %}">{{ 'Oui' if sc.is_active else 'Non' }}</span></td>
{% if editable.vsphere %}
<td class="p-2 text-center whitespace-nowrap">
<form method="POST" action="/settings/server-cluster/{{ sc.id }}/delete" style="display:inline">
<button type="submit" class="btn-sm bg-red-900/30 text-cyber-red text-xs"
onclick="return confirm('{{ sc.server_count }} serveur(s) rattaché(s).\nLe cluster sera désactivé si utilisé, ou supprimé si vide. OK ?');">Suppr</button>
</form>
</td>
{% endif %}
</tr>
{% else %}
<tr><td colspan="6" class="p-3 text-gray-500 text-center">Aucun cluster configuré.</td></tr>
{% endfor %}
</tbody>
</table>
<div id="cluster-servers-detail" class="mt-3 p-3 bg-cyber-border/10 rounded text-xs hidden">
<div class="flex justify-between items-center mb-2">
<span id="cluster-servers-title" class="text-cyber-accent font-bold"></span>
<button onclick="document.getElementById('cluster-servers-detail').classList.add('hidden')"
class="text-gray-500 hover:text-cyber-accent">✕</button>
</div>
<div id="cluster-servers-list"></div>
</div>
</div>
{% if editable.vsphere %}
<form method="POST" action="/settings/server-cluster/add" class="space-y-3 pt-2 border-t border-cyber-border">
<h4 class="text-xs text-cyber-accent font-bold uppercase">Ajouter un cluster</h4>
<div class="grid grid-cols-3 gap-3">
<div>
<label class="text-xs text-gray-500">Nom (unique)</label>
<input type="text" name="sc_name" placeholder="HAproxy FL" class="w-full" required>
</div>
<div>
<label class="text-xs text-gray-500">Description</label>
<input type="text" name="sc_description" placeholder="Cluster HAproxy + backends Flux Libre" class="w-full">
</div>
<div>
<label class="text-xs text-gray-500">Stratégie reboot</label>
<select name="sc_reboot_strategy" class="w-full">
<option value="sequential" selected>Séquentiel (un par un)</option>
<option value="parallel">Parallèle (tous ensemble)</option>
</select>
</div>
</div>
<button type="submit" class="btn-primary px-4 py-2 text-sm">Ajouter</button>
</form>
{% endif %}
</div>
</div>
{% endif %}
<!-- Sécurité --> <!-- Sécurité -->
{% if visible.security %} {% if visible.security %}
<div class="card overflow-hidden"> <div class="card overflow-hidden">
@ -621,6 +714,34 @@ function testLdap() {
.catch(function(e){ out.textContent = '✗ ' + e.message; out.className = 'text-xs ml-2 text-cyber-red'; }); .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 = '<span class="text-gray-500">Chargement…</span>';
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 = '<span class="text-cyber-red">' + (d.msg||'Erreur') + '</span>'; return; }
if (!d.servers.length) { list.innerHTML = '<span class="text-gray-500">Aucun serveur dans ce cluster.</span>'; return; }
var rows = d.servers.map(function(s){
var ord = (s.order !== null && s.order !== undefined) ? s.order : '';
return '<tr><td class="p-1 text-center">' + ord + '</td>'
+ '<td class="p-1 font-mono text-cyber-accent">' + s.hostname + '</td>'
+ '<td class="p-1 text-gray-400">' + (s.machine_type || '-') + '</td>'
+ '<td class="p-1 text-gray-400">' + (s.etat || '-') + '</td></tr>';
}).join('');
list.innerHTML = '<table class="w-full text-xs">'
+ '<thead><tr class="text-cyber-accent border-b border-cyber-border">'
+ '<th class="p-1">Ordre</th><th class="text-left p-1">Hostname</th>'
+ '<th class="text-left p-1">Type</th><th class="text-left p-1">État</th></tr></thead>'
+ '<tbody>' + rows + '</tbody></table>';
})
.catch(function(e){ list.innerHTML = '<span class="text-cyber-red">' + e.message + '</span>'; });
}
function testTeams(tcId, tcName) { function testTeams(tcId, tcName) {
fetch('/settings/teams-channel/' + tcId + '/test', {method: 'POST', credentials: 'same-origin'}) fetch('/settings/teams-channel/' + tcId + '/test', {method: 'POST', credentials: 'same-origin'})
.then(function(r){ return r.json(); }) .then(function(r){ return r.json(); })