patchcenter/app/templates/patching_correspondance.html
Admin MPCZ a706e240ca Patching: exclusions + correspondance prod<->hors-prod + validations
- /patching/config-exclusions: exclusions iTop par serveur + bulk + push iTop
- /quickwin/config: liste globale reboot packages (au lieu de per-server)
- /patching/correspondance: builder mark PROD/NON-PROD + bulk change env/app
  + auto-detect par nomenclature + exclut stock/obsolete
- /patching/validations: workflow post-patching (en_attente/OK/KO/force)
  validator obligatoire depuis contacts iTop
- /patching/validations/history/{id}: historique par serveur
- Auto creation patch_validation apres status='patched' dans QuickWin
- check_prod_validations: banniere rouge sur quickwin detail si non-prod non valides
- Menu: Correspondance sous Serveurs, Config exclusions+Validations sous Patching
- Colonne Equivalent(s) sur /servers + section Correspondance sur detail

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-12 18:51:30 +02:00

303 lines
15 KiB
HTML
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

{% extends 'base.html' %}
{% block title %}Builder correspondance prod ↔ hors-prod{% endblock %}
{% block content %}
<div class="flex justify-between items-center mb-4">
<div>
<h2 class="text-xl font-bold text-cyber-accent">Builder correspondance Prod ↔ Hors-Prod</h2>
<p class="text-xs text-gray-500 mt-1">Filtrer les serveurs, les désigner comme <b class="text-cyber-green">Prod</b> ou <b class="text-cyber-yellow">Non-Prod</b>, puis générer les liens en masse.</p>
</div>
<div class="flex gap-2">
<a href="/patching/validations" class="btn-sm bg-cyber-border text-cyber-accent px-4 py-2">Validations</a>
</div>
</div>
<!-- KPIs -->
<div class="flex gap-2 mb-4">
<div class="card p-3 text-center" style="flex:1"><div class="text-2xl font-bold text-cyber-blue">{{ stats.total_links }}</div><div class="text-xs text-gray-500">Liens existants (toutes apps)</div></div>
<div class="card p-3 text-center" style="flex:1"><div class="text-2xl font-bold text-cyber-accent">{{ stats.filtered }}</div><div class="text-xs text-gray-500">Serveurs filtrés</div></div>
<div class="card p-3 text-center" style="flex:1"><div class="text-2xl font-bold text-cyber-green" id="selected-prod-count">0</div><div class="text-xs text-gray-500">Marqués PROD</div></div>
<div class="card p-3 text-center" style="flex:1"><div class="text-2xl font-bold text-cyber-yellow" id="selected-nonprod-count">0</div><div class="text-xs text-gray-500">Marqués NON-PROD</div></div>
</div>
<!-- Filtres -->
<div class="card p-3 mb-4">
<form method="GET" class="flex gap-2 items-center flex-wrap">
<input type="text" name="search" value="{{ search }}" placeholder="Hostname..." class="text-xs py-1 px-2" style="width:180px">
<select name="application" class="text-xs py-1 px-2" style="max-width:260px">
<option value="">Toutes applications</option>
{% for a in applications %}<option value="{{ a.application_name }}" {% if application == a.application_name %}selected{% endif %}>{{ a.application_name }}</option>{% endfor %}
</select>
<select name="domain" class="text-xs py-1 px-2" style="width:160px">
<option value="">Tous domaines</option>
{% for d in domains %}<option value="{{ d }}" {% if domain == d %}selected{% endif %}>{{ d }}</option>{% endfor %}
</select>
<select name="env" class="text-xs py-1 px-2" style="width:140px">
<option value="">Tous envs</option>
{% for e in envs %}<option value="{{ e }}" {% if env == e %}selected{% endif %}>{{ e }}</option>{% endfor %}
</select>
<button type="submit" class="btn-primary px-3 py-1 text-xs">Filtrer</button>
<a href="/patching/correspondance" class="text-xs text-gray-500 hover:text-cyber-accent">Reset</a>
</form>
</div>
{% if can_edit %}
<!-- Barre actions bulk -->
<div class="card p-3 mb-2" id="bulk-bar" style="display:none">
<div class="flex gap-2 items-center flex-wrap mb-2">
<span class="text-xs text-gray-400"><b id="bulk-count">0</b> sélectionné(s)</span>
</div>
<!-- Section 1: Normaliser iTop (env + app) -->
<div class="flex gap-2 items-center flex-wrap mb-2" style="border-left:3px solid #00d4ff;padding-left:8px">
<span class="text-xs text-cyber-accent font-bold">Normaliser iTop :</span>
<select id="bulk-env" class="text-xs py-1 px-2" style="width:180px">
<option value="">-- Changer env vers --</option>
{% for e in envs %}<option value="{{ e }}">{{ e }}</option>{% endfor %}
</select>
<button onclick="bulkChangeEnv()" class="btn-sm bg-cyber-blue text-black">Appliquer env</button>
<span class="text-gray-600">|</span>
<select id="bulk-app" class="text-xs py-1 px-2" style="max-width:260px">
<option value="">-- Changer solution app. vers --</option>
<option value="__none__">Aucune (désassocier)</option>
{% for a in all_apps %}<option value="{{ a.id }}">{{ a.nom_court }}</option>{% endfor %}
</select>
<button onclick="bulkChangeApp()" class="btn-sm bg-cyber-blue text-black">Appliquer app.</button>
</div>
<!-- Section 2: Correspondance -->
<div class="flex gap-2 items-center flex-wrap" style="border-left:3px solid #00ff88;padding-left:8px">
<span class="text-xs text-cyber-green font-bold">Marquer pour correspondance :</span>
<button onclick="markSelected('prod')" class="btn-sm bg-cyber-green text-black">Marquer PROD</button>
<button onclick="markSelected('nonprod')" class="btn-sm bg-cyber-yellow text-black">Marquer NON-PROD</button>
<button onclick="markSelected('none')" class="btn-sm bg-cyber-border text-gray-300">Démarquer</button>
</div>
<div id="bulk-result" class="text-xs mt-2"></div>
</div>
<!-- Générer correspondances -->
<div class="card p-4 mb-4" style="border-color:#00d4ff55">
<div class="flex gap-3 items-center">
<div style="flex:1">
<b class="text-cyber-accent">Générer correspondances</b>
<p class="text-xs text-gray-400 mt-1">
<span class="text-cyber-green" id="preview-prod">0 prod</span> ×
<span class="text-cyber-yellow" id="preview-nonprod">0 non-prod</span> =
<b class="text-cyber-accent" id="preview-links">0 liens</b>
</p>
</div>
<button onclick="generateCorrespondances()" class="btn-primary px-4 py-2 text-sm" id="btn-generate" disabled>Créer les correspondances</button>
</div>
<div id="gen-result" class="text-xs mt-2"></div>
</div>
{% endif %}
<!-- Tableau -->
<div class="card overflow-x-auto">
<table class="w-full table-cyber text-xs">
<thead><tr>
{% if can_edit %}<th class="p-2 w-8"><input type="checkbox" onchange="toggleAll(this)"></th>{% endif %}
<th class="p-2 text-left">Hostname</th>
<th class="p-2">Env</th>
<th class="p-2 text-left">Application</th>
<th class="p-2">Domaine</th>
<th class="p-2">Zone</th>
<th class="p-2">Liens existants</th>
{% if can_edit %}<th class="p-2">Rôle</th>{% endif %}
</tr></thead>
<tbody>
{% for s in servers %}
<tr class="border-t border-cyber-border/30" id="row-{{ s.id }}" data-env="{{ s.env_name or '' }}">
{% if can_edit %}<td class="p-2 text-center"><input type="checkbox" class="srv-check" value="{{ s.id }}" onchange="updateBulk()"></td>{% endif %}
<td class="p-2 font-mono text-cyber-accent">{{ s.hostname }}</td>
<td class="p-2 text-center">
{% if s.env_name == 'Production' %}<span class="badge badge-green">{{ s.env_name }}</span>
{% elif s.env_name %}<span class="badge badge-yellow">{{ s.env_name }}</span>
{% else %}<span class="text-gray-600">-</span>{% endif %}
</td>
<td class="p-2 text-xs text-gray-300" title="{{ s.application_name or '' }}">{{ (s.application_name or '-')[:35] }}</td>
<td class="p-2 text-center text-gray-400">{{ s.domain_name or '-' }}</td>
<td class="p-2 text-center text-gray-400">{{ s.zone_name or '-' }}</td>
<td class="p-2 text-center">
{% if s.n_as_prod %}<span class="badge badge-green" style="font-size:9px" title="Liés comme prod">{{ s.n_as_prod }}P</span>{% endif %}
{% if s.n_as_nonprod %}<span class="badge badge-yellow" style="font-size:9px" title="Liés comme non-prod">{{ s.n_as_nonprod }}N</span>{% endif %}
{% if not s.n_as_prod and not s.n_as_nonprod %}<span class="text-gray-600">-</span>{% endif %}
</td>
{% if can_edit %}
<td class="p-2 text-center">
<span id="role-{{ s.id }}" class="badge badge-gray" style="font-size:9px"></span>
</td>
{% endif %}
</tr>
{% endfor %}
{% if not servers %}
<tr><td colspan="8" class="p-6 text-center text-gray-500">Aucun serveur pour ces filtres</td></tr>
{% endif %}
</tbody>
</table>
</div>
{% if can_edit %}
<script>
const markedProd = new Set();
const markedNonProd = new Set();
function toggleAll(cb) {
document.querySelectorAll('.srv-check').forEach(c => c.checked = cb.checked);
updateBulk();
}
function updateBulk() {
const checked = Array.from(document.querySelectorAll('.srv-check:checked')).map(c => parseInt(c.value));
const bar = document.getElementById('bulk-bar');
bar.style.display = checked.length > 0 ? 'block' : 'none';
document.getElementById('bulk-count').textContent = checked.length;
window._selectedIds = checked;
}
function markSelected(role) {
const ids = window._selectedIds || [];
if (!ids.length) return;
// Contrôle de cohérence
const warnings = [];
ids.forEach(id => {
const row = document.getElementById('row-' + id);
const env = row ? (row.dataset.env || '') : '';
if (role === 'prod' && env !== 'Production') {
warnings.push('⚠ ' + row.querySelector('.font-mono').textContent.trim() + ' (env: ' + (env || 'aucun') + ') n\'est pas en Production');
} else if (role === 'nonprod' && env === 'Production') {
warnings.push('⚠ ' + row.querySelector('.font-mono').textContent.trim() + ' est en Production, pas hors-prod');
}
});
if (warnings.length > 0) {
if (!confirm('Incohérence détectée :\n\n' + warnings.join('\n') +
'\n\nVoulez-vous continuer quand même ?\n(Recommandé : corriger d\'abord l\'environnement via "Normaliser iTop")')) return;
}
ids.forEach(id => {
const badge = document.getElementById('role-' + id);
const row = document.getElementById('row-' + id);
if (role === 'prod') {
markedProd.add(id); markedNonProd.delete(id);
badge.className = 'badge badge-green';
badge.textContent = 'PROD';
row.style.background = 'rgba(0, 255, 136, 0.05)';
} else if (role === 'nonprod') {
markedNonProd.add(id); markedProd.delete(id);
badge.className = 'badge badge-yellow';
badge.textContent = 'NON-PROD';
row.style.background = 'rgba(255, 204, 0, 0.05)';
} else {
markedProd.delete(id); markedNonProd.delete(id);
badge.className = 'badge badge-gray'; badge.style.fontSize = '9px';
badge.textContent = '—';
row.style.background = '';
}
});
updateCounters();
// Décocher
document.querySelectorAll('.srv-check').forEach(c => c.checked = false);
document.querySelector('thead input[type=checkbox]').checked = false;
updateBulk();
}
function updateCounters() {
const np = markedProd.size, nn = markedNonProd.size;
document.getElementById('selected-prod-count').textContent = np;
document.getElementById('selected-nonprod-count').textContent = nn;
document.getElementById('preview-prod').textContent = np + ' prod';
document.getElementById('preview-nonprod').textContent = nn + ' non-prod';
document.getElementById('preview-links').textContent = (np * nn) + ' liens';
document.getElementById('btn-generate').disabled = (np === 0 || nn === 0);
}
function bulkChangeEnv() {
const ids = window._selectedIds || [];
const env = document.getElementById('bulk-env').value;
if (!ids.length) return alert('Aucun serveur sélectionné');
if (!env) return alert('Choisir un environnement');
if (!confirm('Changer l\'environnement vers "' + env + '" sur ' + ids.length + ' serveur(s) ?\n(PatchCenter + push iTop)')) return;
const res = document.getElementById('bulk-result');
res.textContent = 'Changement env en cours...'; res.className = 'text-xs mt-2 text-gray-400';
fetch('/patching/correspondance/bulk-env', {
method: 'POST', credentials: 'same-origin',
headers: {'Content-Type': 'application/json'},
body: JSON.stringify({server_ids: ids, env_name: env})
})
.then(r => r.json())
.then(d => {
if (d.ok) {
res.innerHTML = '✓ ' + d.updated + ' → ' + d.env_name + ' — iTop: <b class="text-cyber-green">' + d.itop_pushed + '</b> OK / <b class="text-cyber-red">' + d.itop_errors + '</b> KO';
res.className = 'text-xs mt-2';
setTimeout(() => location.reload(), 1500);
} else {
res.textContent = '✗ ' + (d.msg || 'Erreur'); res.className = 'text-xs mt-2 text-cyber-red';
}
});
}
function bulkChangeApp() {
const ids = window._selectedIds || [];
let app = document.getElementById('bulk-app').value;
if (!ids.length) return alert('Aucun serveur sélectionné');
if (!app) return alert('Choisir une application');
const appText = document.getElementById('bulk-app').selectedOptions[0].text;
if (!confirm('Changer solution applicative vers "' + appText + '" sur ' + ids.length + ' serveur(s) ?\n(PatchCenter + push iTop)')) return;
if (app === '__none__') app = '';
const res = document.getElementById('bulk-result');
res.textContent = 'Changement app en cours...'; res.className = 'text-xs mt-2 text-gray-400';
fetch('/patching/correspondance/bulk-application', {
method: 'POST', credentials: 'same-origin',
headers: {'Content-Type': 'application/json'},
body: JSON.stringify({server_ids: ids, application_id: app})
})
.then(r => r.json())
.then(d => {
if (d.ok) {
res.innerHTML = '✓ ' + d.updated + ' → ' + d.app_name + ' — iTop: <b class="text-cyber-green">' + d.itop_pushed + '</b> OK / <b class="text-cyber-red">' + d.itop_errors + '</b> KO';
res.className = 'text-xs mt-2';
setTimeout(() => location.reload(), 1500);
} else {
res.textContent = '✗ ' + (d.msg || 'Erreur'); res.className = 'text-xs mt-2 text-cyber-red';
}
});
}
function generateCorrespondances() {
if (markedProd.size === 0 || markedNonProd.size === 0) return;
const n = markedProd.size * markedNonProd.size;
if (!confirm('Créer ' + n + ' correspondances ?')) return;
// Récupérer les env labels des non-prod depuis le data-env de chaque row
const envLabels = {};
markedNonProd.forEach(id => {
const row = document.getElementById('row-' + id);
if (row) envLabels[id] = row.dataset.env || '';
});
const res = document.getElementById('gen-result');
res.textContent = 'En cours...'; res.className = 'text-xs mt-2 text-gray-400';
fetch('/patching/correspondance/bulk-create', {
method: 'POST', credentials: 'same-origin',
headers: {'Content-Type': 'application/json'},
body: JSON.stringify({
prod_ids: Array.from(markedProd),
nonprod_ids: Array.from(markedNonProd),
env_labels: envLabels,
})
})
.then(r => r.json())
.then(d => {
if (d.ok) {
res.innerHTML = '✓ ' + d.created + ' liens créés, ' + d.skipped + ' déjà existants';
res.className = 'text-xs mt-2 text-cyber-green';
setTimeout(() => location.reload(), 2000);
} else {
res.textContent = '✗ ' + (d.msg || 'Erreur');
res.className = 'text-xs mt-2 text-cyber-red';
}
})
.catch(e => { res.textContent = '✗ ' + e.message; res.className = 'text-xs mt-2 text-cyber-red'; });
}
</script>
{% endif %}
{% endblock %}