Ameliorer la vue des dependances inventaire
This commit is contained in:
parent
1d25338d26
commit
18513a3bf0
2 changed files with 236 additions and 63 deletions
|
|
@ -19,6 +19,7 @@
|
|||
- Retrait des groupes génériques obsolètes `serveurs_bases_donnees` et `serveurs_supervision` au profit des groupes de services précis.
|
||||
- Consolidation des groupes Icinga en `serveurs_icinga` afin de représenter une seule capacité de supervision serveur sur `mon-01`.
|
||||
- Validation des prérequis actifs avant déploiement d'un groupe, déploiement d'un hôte ou sauvegarde active via l'interface d'inventaire.
|
||||
- Refonte visuelle de `make inventaire-ui` pour afficher les dépendances causales, les prérequis manquants et l'état déployable ou bloqué des hôtes.
|
||||
|
||||
## 2026-06-21
|
||||
|
||||
|
|
|
|||
|
|
@ -207,36 +207,85 @@ HTML = r"""<!doctype html>
|
|||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<title>Set-OPS - Inventaire</title>
|
||||
<style>
|
||||
:root { color-scheme: light; --fond:#f7f7f4; --texte:#202124; --muted:#63665f; --ligne:#d8d9d2; --accent:#276749; --accent2:#1f4e79; --danger:#9b2c2c; }
|
||||
:root {
|
||||
color-scheme: light;
|
||||
--fond:#f4f5f2;
|
||||
--surface:#ffffff;
|
||||
--surface2:#f9faf7;
|
||||
--texte:#202124;
|
||||
--muted:#62675f;
|
||||
--ligne:#d8dad2;
|
||||
--vert:#2f6f4e;
|
||||
--vert-fond:#e8f3ec;
|
||||
--bleu:#245f8f;
|
||||
--bleu-fond:#e7f0f8;
|
||||
--ambre:#8a5b13;
|
||||
--ambre-fond:#fff3d8;
|
||||
--rouge:#9b2c2c;
|
||||
--rouge-fond:#fae7e7;
|
||||
}
|
||||
* { box-sizing: border-box; }
|
||||
body { margin: 0; font-family: system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif; background: var(--fond); color: var(--texte); }
|
||||
header { display: flex; align-items: center; justify-content: space-between; gap: 16px; padding: 18px 24px; border-bottom: 1px solid var(--ligne); background: #fff; position: sticky; top: 0; z-index: 3; }
|
||||
h1 { margin: 0; font-size: 20px; font-weight: 700; letter-spacing: 0; }
|
||||
main { padding: 18px 24px 32px; }
|
||||
.barre { display: flex; gap: 10px; align-items: center; flex-wrap: wrap; }
|
||||
button { border: 1px solid var(--ligne); background: #fff; color: var(--texte); padding: 8px 11px; border-radius: 6px; cursor: pointer; font-weight: 650; }
|
||||
button.primaire { background: var(--accent); color: #fff; border-color: var(--accent); }
|
||||
button.danger { color: var(--danger); }
|
||||
button:disabled { opacity: .55; cursor: not-allowed; }
|
||||
header { display: flex; align-items: center; justify-content: space-between; gap: 18px; padding: 16px 22px; border-bottom: 1px solid var(--ligne); background: var(--surface); position: sticky; top: 0; z-index: 4; }
|
||||
h1 { margin: 0; font-size: 20px; font-weight: 750; letter-spacing: 0; }
|
||||
h2 { margin: 0 0 10px; font-size: 15px; font-weight: 750; letter-spacing: 0; }
|
||||
h3 { margin: 0; font-size: 15px; font-weight: 750; letter-spacing: 0; }
|
||||
main { padding: 18px 22px 28px; display: grid; grid-template-columns: minmax(310px, 380px) minmax(0, 1fr); gap: 18px; align-items: start; }
|
||||
button { border: 1px solid var(--ligne); background: var(--surface); color: var(--texte); padding: 8px 11px; border-radius: 6px; cursor: pointer; font-weight: 650; }
|
||||
button.primaire { background: var(--vert); color: #fff; border-color: var(--vert); }
|
||||
button.danger { color: var(--rouge); }
|
||||
input, select { width: 100%; border: 1px solid var(--ligne); border-radius: 5px; padding: 7px 8px; background: #fff; color: var(--texte); font: inherit; }
|
||||
table { width: 100%; border-collapse: collapse; background: #fff; border: 1px solid var(--ligne); }
|
||||
th, td { border-bottom: 1px solid var(--ligne); padding: 8px; text-align: left; vertical-align: top; }
|
||||
th { font-size: 12px; text-transform: uppercase; color: var(--muted); background: #fbfbf8; position: sticky; top: 65px; z-index: 2; }
|
||||
td.nom { min-width: 170px; }
|
||||
td.ip { min-width: 150px; }
|
||||
td.vmid { width: 100px; }
|
||||
td.etat { width: 115px; }
|
||||
td.groupes { min-width: 360px; }
|
||||
.groupes-liste { display: grid; grid-template-columns: repeat(auto-fit, minmax(150px, 1fr)); gap: 5px 10px; }
|
||||
label { display: inline-flex; align-items: center; gap: 6px; color: var(--texte); }
|
||||
label input { width: auto; }
|
||||
.barre { display: flex; gap: 10px; align-items: center; flex-wrap: wrap; justify-content: flex-end; }
|
||||
.meta { color: var(--muted); font-size: 13px; }
|
||||
.message { min-height: 22px; color: var(--accent2); font-size: 14px; }
|
||||
.erreur { color: var(--danger); }
|
||||
@media (max-width: 900px) {
|
||||
.message { min-height: 22px; color: var(--bleu); font-size: 14px; margin-bottom: 10px; }
|
||||
.erreur { color: var(--rouge); }
|
||||
.resume { display: flex; gap: 8px; flex-wrap: wrap; margin-top: 7px; }
|
||||
.compteur { display: inline-flex; align-items: center; gap: 6px; border: 1px solid var(--ligne); border-radius: 6px; padding: 3px 8px; background: var(--surface2); color: var(--muted); font-size: 12px; }
|
||||
.zone { background: var(--surface); border: 1px solid var(--ligne); border-radius: 8px; padding: 14px; }
|
||||
.dependances { position: sticky; top: 84px; max-height: calc(100vh - 110px); overflow: auto; }
|
||||
.liste-dependances { display: grid; gap: 8px; }
|
||||
.dependance { border: 1px solid var(--ligne); border-radius: 7px; padding: 10px; background: var(--surface2); }
|
||||
.dependance.ok { border-color: #b8d9c4; background: var(--vert-fond); }
|
||||
.dependance.attente { border-color: #e6cd94; background: var(--ambre-fond); }
|
||||
.dependance.bloque { border-color: #e7b6b6; background: var(--rouge-fond); }
|
||||
.dep-titre { display: flex; align-items: center; justify-content: space-between; gap: 8px; margin-bottom: 5px; }
|
||||
.dep-nom { font-weight: 750; overflow-wrap: anywhere; }
|
||||
.dep-texte { color: var(--muted); font-size: 12px; line-height: 1.35; }
|
||||
.badge { display: inline-flex; align-items: center; border-radius: 999px; padding: 2px 7px; font-size: 11px; font-weight: 750; white-space: nowrap; }
|
||||
.badge.ok { color: var(--vert); background: var(--vert-fond); }
|
||||
.badge.attente { color: var(--ambre); background: var(--ambre-fond); }
|
||||
.badge.bloque { color: var(--rouge); background: var(--rouge-fond); }
|
||||
.badge.neutre { color: var(--bleu); background: var(--bleu-fond); }
|
||||
.hotes { display: grid; gap: 12px; }
|
||||
.hote { background: var(--surface); border: 1px solid var(--ligne); border-radius: 8px; padding: 12px; }
|
||||
.hote.bloque { border-color: #e7b6b6; }
|
||||
.hote-entete { display: grid; grid-template-columns: minmax(160px, 1.2fr) 120px 160px 130px 100px auto; gap: 8px; align-items: end; }
|
||||
.champ span { display: block; color: var(--muted); font-size: 11px; font-weight: 700; text-transform: uppercase; margin-bottom: 4px; }
|
||||
.actions-hote { display: flex; justify-content: flex-end; }
|
||||
.groupes-section { margin-top: 12px; display: grid; gap: 8px; }
|
||||
.groupes-liste { display: grid; grid-template-columns: repeat(auto-fit, minmax(235px, 1fr)); gap: 7px; }
|
||||
.groupe { border: 1px solid var(--ligne); border-radius: 7px; padding: 8px; background: var(--surface2); min-height: 72px; align-items: flex-start; }
|
||||
.groupe.selectionne { border-color: #a7c9de; background: var(--bleu-fond); }
|
||||
.groupe.ok { border-color: #b8d9c4; background: var(--vert-fond); }
|
||||
.groupe.bloque { border-color: #e7b6b6; background: var(--rouge-fond); }
|
||||
.groupe.attente { border-color: #e6cd94; background: var(--ambre-fond); }
|
||||
.groupe-ligne { display: flex; align-items: center; gap: 7px; font-weight: 700; overflow-wrap: anywhere; }
|
||||
.groupe-detail { color: var(--muted); font-size: 12px; line-height: 1.3; margin: 5px 0 0 24px; }
|
||||
.vide { color: var(--muted); padding: 16px; border: 1px dashed var(--ligne); border-radius: 8px; background: var(--surface2); }
|
||||
@media (max-width: 1120px) {
|
||||
main { grid-template-columns: 1fr; }
|
||||
.dependances { position: static; max-height: none; }
|
||||
.hote-entete { grid-template-columns: repeat(2, minmax(0, 1fr)); }
|
||||
.actions-hote { justify-content: flex-start; }
|
||||
}
|
||||
@media (max-width: 680px) {
|
||||
header { align-items: flex-start; flex-direction: column; }
|
||||
main { padding: 12px; overflow-x: auto; }
|
||||
th { top: 115px; }
|
||||
main { padding: 12px; }
|
||||
.barre { justify-content: flex-start; }
|
||||
.hote-entete { grid-template-columns: 1fr; }
|
||||
.groupes-liste { grid-template-columns: 1fr; }
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
|
|
@ -245,6 +294,7 @@ HTML = r"""<!doctype html>
|
|||
<div>
|
||||
<h1>Inventaire Set-OPS</h1>
|
||||
<div class="meta" id="inventaire"></div>
|
||||
<div class="resume" id="resume"></div>
|
||||
</div>
|
||||
<div class="barre">
|
||||
<button type="button" onclick="ajouterHote()">Ajouter</button>
|
||||
|
|
@ -253,21 +303,14 @@ HTML = r"""<!doctype html>
|
|||
</div>
|
||||
</header>
|
||||
<main>
|
||||
<div class="message" id="message"></div>
|
||||
<table>
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Hôte</th>
|
||||
<th>État</th>
|
||||
<th>Adresse IP</th>
|
||||
<th>Utilisateur</th>
|
||||
<th>VMID</th>
|
||||
<th>Groupes</th>
|
||||
<th></th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody id="lignes"></tbody>
|
||||
</table>
|
||||
<aside class="zone dependances">
|
||||
<h2>Dépendances</h2>
|
||||
<div class="liste-dependances" id="dependances"></div>
|
||||
</aside>
|
||||
<section>
|
||||
<div class="message" id="message"></div>
|
||||
<div class="hotes" id="hotes"></div>
|
||||
</section>
|
||||
</main>
|
||||
<script>
|
||||
let groupes = [];
|
||||
|
|
@ -301,48 +344,177 @@ HTML = r"""<!doctype html>
|
|||
dessiner();
|
||||
}
|
||||
|
||||
function definir(index, champ, valeur) {
|
||||
function definir(index, champ, valeur, rafraichir=false) {
|
||||
hotes[index][champ] = valeur;
|
||||
if (rafraichir) dessiner();
|
||||
}
|
||||
|
||||
function basculerGroupe(index, groupe, actif) {
|
||||
const ensemble = new Set(hotes[index].groupes || []);
|
||||
if (actif) ensemble.add(groupe); else ensemble.delete(groupe);
|
||||
hotes[index].groupes = Array.from(ensemble).sort();
|
||||
dessiner();
|
||||
}
|
||||
|
||||
function groupesActifs() {
|
||||
const actifs = new Set();
|
||||
hotes.forEach(hote => {
|
||||
if (hote.etat !== 'actif') return;
|
||||
(hote.groupes || []).forEach(groupe => actifs.add(groupe));
|
||||
});
|
||||
return actifs;
|
||||
}
|
||||
|
||||
function groupesPlanifies() {
|
||||
const planifies = new Set();
|
||||
hotes.forEach(hote => (hote.groupes || []).forEach(groupe => planifies.add(groupe)));
|
||||
return planifies;
|
||||
}
|
||||
|
||||
function statutDependance(groupe) {
|
||||
const requis = ((dependances[groupe] || {}).requiert_groupes_actifs || []);
|
||||
if (!requis.length) return 'neutre';
|
||||
const actifs = groupesActifs();
|
||||
const planifies = groupesPlanifies();
|
||||
const manquants = requis.filter(item => !actifs.has(item));
|
||||
if (!manquants.length) return 'ok';
|
||||
if (manquants.every(item => planifies.has(item))) return 'attente';
|
||||
return 'bloque';
|
||||
}
|
||||
|
||||
function manquantsPourGroupe(groupe) {
|
||||
const actifs = groupesActifs();
|
||||
return ((dependances[groupe] || {}).requiert_groupes_actifs || []).filter(item => !actifs.has(item));
|
||||
}
|
||||
|
||||
function hoteBloque(hote) {
|
||||
if (hote.etat !== 'actif') return false;
|
||||
return (hote.groupes || []).some(groupe => manquantsPourGroupe(groupe).length);
|
||||
}
|
||||
|
||||
function libelleStatut(statut) {
|
||||
return {ok:'actif', attente:'planifié', bloque:'bloqué', neutre:'libre'}[statut] || statut;
|
||||
}
|
||||
|
||||
function dessiner() {
|
||||
const tbody = document.getElementById('lignes');
|
||||
tbody.innerHTML = '';
|
||||
hotes.forEach((hote, index) => {
|
||||
const tr = document.createElement('tr');
|
||||
tr.innerHTML = `
|
||||
<td class="nom"><input value="${echapper(hote.nom || '')}" oninput="definir(${index}, 'nom', this.value)"></td>
|
||||
<td class="etat"><select onchange="definir(${index}, 'etat', this.value)">
|
||||
<option value="actif" ${hote.etat === 'actif' ? 'selected' : ''}>actif</option>
|
||||
<option value="planifie" ${hote.etat !== 'actif' ? 'selected' : ''}>planifié</option>
|
||||
</select></td>
|
||||
<td class="ip"><input value="${echapper(hote.adresse_ip || '')}" oninput="definir(${index}, 'adresse_ip', this.value)"></td>
|
||||
<td><input value="${echapper(hote.utilisateur_ansible || '')}" oninput="definir(${index}, 'utilisateur_ansible', this.value)"></td>
|
||||
<td class="vmid"><input value="${echapper(String(hote.vmid || ''))}" oninput="definir(${index}, 'vmid', this.value)"></td>
|
||||
<td class="groupes"><div class="groupes-liste">${groupes.map(groupe => `
|
||||
<label title="${echapper(titreGroupe(groupe))}"><input type="checkbox" ${(hote.groupes || []).includes(groupe) ? 'checked' : ''} onchange="basculerGroupe(${index}, '${groupe}', this.checked)"> ${groupe}</label>
|
||||
`).join('')}</div></td>
|
||||
<td><button type="button" class="danger" onclick="supprimerHote(${index})">Retirer</button></td>
|
||||
`;
|
||||
tbody.appendChild(tr);
|
||||
});
|
||||
dessinerResume();
|
||||
dessinerDependances();
|
||||
dessinerHotes();
|
||||
}
|
||||
|
||||
function echapper(valeur) {
|
||||
return valeur.replace(/[&<>"']/g, c => ({'&':'&','<':'<','>':'>','"':'"',"'":'''}[c]));
|
||||
function dessinerResume() {
|
||||
const actifs = hotes.filter(hote => hote.etat === 'actif').length;
|
||||
const planifies = hotes.length - actifs;
|
||||
const bloques = hotes.filter(hote => hoteBloque(hote)).length;
|
||||
document.getElementById('resume').innerHTML = `
|
||||
<span class="compteur">${hotes.length} hôtes</span>
|
||||
<span class="compteur">${actifs} actifs</span>
|
||||
<span class="compteur">${planifies} planifiés</span>
|
||||
<span class="compteur">${bloques} bloqués</span>
|
||||
`;
|
||||
}
|
||||
|
||||
function dessinerDependances() {
|
||||
const cible = document.getElementById('dependances');
|
||||
const noms = Object.keys(dependances).sort();
|
||||
if (!noms.length) {
|
||||
cible.innerHTML = '<div class="vide">Aucune dépendance déclarée.</div>';
|
||||
return;
|
||||
}
|
||||
cible.innerHTML = noms.map(groupe => {
|
||||
const config = dependances[groupe] || {};
|
||||
const requis = config.requiert_groupes_actifs || [];
|
||||
const statut = statutDependance(groupe);
|
||||
const manquants = manquantsPourGroupe(groupe);
|
||||
const detail = requis.length ? `requiert ${requis.join(', ')}` : 'aucun prérequis actif';
|
||||
const manque = manquants.length ? `manquant: ${manquants.join(', ')}` : 'prérequis satisfaits';
|
||||
const surveillance = config.surveillance ? `<div class="dep-texte">${echapper(config.surveillance)}</div>` : '';
|
||||
return `
|
||||
<div class="dependance ${statut}" title="${echapper(config.raison || '')}">
|
||||
<div class="dep-titre">
|
||||
<span class="dep-nom">${echapper(groupe)}</span>
|
||||
<span class="badge ${statut}">${libelleStatut(statut)}</span>
|
||||
</div>
|
||||
<div class="dep-texte">${echapper(detail)}</div>
|
||||
<div class="dep-texte">${echapper(manque)}</div>
|
||||
${surveillance}
|
||||
</div>
|
||||
`;
|
||||
}).join('');
|
||||
}
|
||||
|
||||
function dessinerHotes() {
|
||||
const conteneur = document.getElementById('hotes');
|
||||
if (!hotes.length) {
|
||||
conteneur.innerHTML = '<div class="vide">Aucun hôte dans cet inventaire.</div>';
|
||||
return;
|
||||
}
|
||||
conteneur.innerHTML = hotes.map((hote, index) => {
|
||||
const bloque = hoteBloque(hote);
|
||||
const statut = hote.etat === 'actif' ? (bloque ? 'bloque' : 'ok') : 'attente';
|
||||
return `
|
||||
<article class="hote ${bloque ? 'bloque' : ''}">
|
||||
<div class="hote-entete">
|
||||
<div class="champ"><span>Hôte</span><input value="${echapper(hote.nom || '')}" oninput="definir(${index}, 'nom', this.value)"></div>
|
||||
<div class="champ"><span>État</span><select onchange="definir(${index}, 'etat', this.value, true)">
|
||||
<option value="actif" ${hote.etat === 'actif' ? 'selected' : ''}>actif</option>
|
||||
<option value="planifie" ${hote.etat !== 'actif' ? 'selected' : ''}>planifié</option>
|
||||
</select></div>
|
||||
<div class="champ"><span>Adresse IP</span><input value="${echapper(hote.adresse_ip || '')}" oninput="definir(${index}, 'adresse_ip', this.value)"></div>
|
||||
<div class="champ"><span>Utilisateur</span><input value="${echapper(hote.utilisateur_ansible || '')}" oninput="definir(${index}, 'utilisateur_ansible', this.value)"></div>
|
||||
<div class="champ"><span>VMID</span><input value="${echapper(String(hote.vmid || ''))}" oninput="definir(${index}, 'vmid', this.value)"></div>
|
||||
<div class="actions-hote"><button type="button" class="danger" onclick="supprimerHote(${index})">Retirer</button></div>
|
||||
</div>
|
||||
<div class="groupes-section">
|
||||
<div><span class="badge ${statut}">${hote.etat === 'actif' ? (bloque ? 'prérequis manquant' : 'déployable') : 'planifié'}</span></div>
|
||||
<div class="groupes-liste">${groupes.map(groupe => renduGroupe(index, hote, groupe)).join('')}</div>
|
||||
</div>
|
||||
</article>
|
||||
`;
|
||||
}).join('');
|
||||
}
|
||||
|
||||
function renduGroupe(index, hote, groupe) {
|
||||
const selectionne = (hote.groupes || []).includes(groupe);
|
||||
const manquants = manquantsPourGroupe(groupe);
|
||||
const config = dependances[groupe] || {};
|
||||
let statut = selectionne ? 'selectionne' : '';
|
||||
if (selectionne && hote.etat === 'actif' && manquants.length) statut = 'bloque';
|
||||
else if (selectionne && hote.etat === 'actif') statut = 'ok';
|
||||
else if (selectionne && hote.etat !== 'actif' && (config.requiert_groupes_actifs || []).length) statut = 'attente';
|
||||
const detail = detailGroupe(groupe, hote);
|
||||
return `
|
||||
<label class="groupe ${statut}" title="${echapper(titreGroupe(groupe))}">
|
||||
<span class="groupe-ligne">
|
||||
<input type="checkbox" ${selectionne ? 'checked' : ''} onchange="basculerGroupe(${index}, '${groupe}', this.checked)">
|
||||
<span>${echapper(groupe)}</span>
|
||||
</span>
|
||||
<span class="groupe-detail">${echapper(detail)}</span>
|
||||
</label>
|
||||
`;
|
||||
}
|
||||
|
||||
function detailGroupe(groupe, hote) {
|
||||
const requis = ((dependances[groupe] || {}).requiert_groupes_actifs || []);
|
||||
if (!requis.length) return 'aucun prérequis actif';
|
||||
const manquants = manquantsPourGroupe(groupe);
|
||||
if (!manquants.length) return `prérequis actifs: ${requis.join(', ')}`;
|
||||
if (hote.etat === 'actif') return `manquant: ${manquants.join(', ')}`;
|
||||
return `requerra: ${requis.join(', ')}`;
|
||||
}
|
||||
|
||||
function titreGroupe(groupe) {
|
||||
const config = dependances[groupe] || {};
|
||||
const requis = config.requiert_groupes_actifs || [];
|
||||
if (!requis.length) return 'Aucun prérequis actif déclaré.';
|
||||
return `Prérequis actif: ${requis.join(', ')}`;
|
||||
const lignes = [];
|
||||
lignes.push(requis.length ? `Prérequis actif: ${requis.join(', ')}` : 'Aucun prérequis actif déclaré.');
|
||||
if (config.raison) lignes.push(config.raison);
|
||||
if (config.surveillance) lignes.push(config.surveillance);
|
||||
return lignes.join('\n');
|
||||
}
|
||||
|
||||
function echapper(valeur) {
|
||||
return String(valeur).replace(/[&<>"']/g, c => ({'&':'&','<':'<','>':'>','"':'"',"'":'''}[c]));
|
||||
}
|
||||
|
||||
async function sauvegarder() {
|
||||
|
|
|
|||
Reference in a new issue