feat(settings/clusters M2): UI CRUD server_clusters - groupes + ordre redemarrage + strategie sequential/parallel + panneau detail serveurs rattaches
This commit is contained in:
parent
075706178e
commit
a7874aec11
@ -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)
|
||||
|
||||
@ -362,6 +362,99 @@
|
||||
</div>
|
||||
{% 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' ? '▼' : '▶'"></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é -->
|
||||
{% if visible.security %}
|
||||
<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'; });
|
||||
}
|
||||
|
||||
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) {
|
||||
fetch('/settings/teams-channel/' + tcId + '/test', {method: 'POST', credentials: 'same-origin'})
|
||||
.then(function(r){ return r.json(); })
|
||||
|
||||
Loading…
Reference in New Issue
Block a user