- /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>
180 lines
9.6 KiB
HTML
180 lines
9.6 KiB
HTML
{% extends 'base.html' %}
|
|
{% block title %}Validations post-patching{% endblock %}
|
|
{% block content %}
|
|
<div class="flex justify-between items-center mb-4">
|
|
<div>
|
|
<h2 class="text-xl font-bold text-cyber-accent">Validations post-patching</h2>
|
|
<p class="text-xs text-gray-500 mt-1">Serveurs patchés en attente de validation par les responsables applicatifs.</p>
|
|
</div>
|
|
<a href="/patching/correspondance" class="btn-sm bg-cyber-border text-cyber-accent px-4 py-2">Correspondance</a>
|
|
</div>
|
|
|
|
<!-- KPIs -->
|
|
<div class="flex gap-2 mb-4">
|
|
<a href="?status=en_attente" class="card p-3 text-center hover:border-cyber-accent" style="flex:1">
|
|
<div class="text-2xl font-bold text-cyber-yellow">{{ stats.en_attente }}</div>
|
|
<div class="text-xs text-gray-500">En attente</div>
|
|
</a>
|
|
<a href="?status=validated_ok" class="card p-3 text-center hover:border-cyber-accent" style="flex:1">
|
|
<div class="text-2xl font-bold text-cyber-green">{{ stats.validated_ok }}</div>
|
|
<div class="text-xs text-gray-500">Validés OK</div>
|
|
</a>
|
|
<a href="?status=validated_ko" class="card p-3 text-center hover:border-cyber-accent" style="flex:1">
|
|
<div class="text-2xl font-bold text-cyber-red">{{ stats.validated_ko }}</div>
|
|
<div class="text-xs text-gray-500">KO</div>
|
|
</a>
|
|
<a href="?status=forced" class="card p-3 text-center hover:border-cyber-accent" style="flex:1">
|
|
<div class="text-2xl font-bold text-cyber-blue">{{ stats.forced }}</div>
|
|
<div class="text-xs text-gray-500">Forcés</div>
|
|
</a>
|
|
</div>
|
|
|
|
<!-- Filtres -->
|
|
<div class="card p-3 mb-4">
|
|
<form method="GET" class="flex gap-2 items-center flex-wrap">
|
|
<select name="status" class="text-xs py-1 px-2">
|
|
<option value="en_attente" {% if status == 'en_attente' %}selected{% endif %}>En attente</option>
|
|
<option value="validated_ok" {% if status == 'validated_ok' %}selected{% endif %}>Validés OK</option>
|
|
<option value="validated_ko" {% if status == 'validated_ko' %}selected{% endif %}>Validés KO</option>
|
|
<option value="forced" {% if status == 'forced' %}selected{% endif %}>Forcés</option>
|
|
</select>
|
|
<select name="env" class="text-xs py-1 px-2">
|
|
<option value="">Tous environnements</option>
|
|
{% for e in envs %}<option value="{{ e }}" {% if env == e %}selected{% endif %}>{{ e }}</option>{% endfor %}
|
|
</select>
|
|
{% if campaign_id %}<input type="hidden" name="campaign_id" value="{{ campaign_id }}">{% endif %}
|
|
<button type="submit" class="btn-primary px-3 py-1 text-xs">Filtrer</button>
|
|
<a href="/patching/validations" class="text-xs text-gray-500 hover:text-cyber-accent">Reset</a>
|
|
{% if campaign_id %}<span class="text-xs text-cyber-accent">Campagne #{{ campaign_id }}</span>{% endif %}
|
|
</form>
|
|
</div>
|
|
|
|
<!-- Bulk bar -->
|
|
<div id="bulk-bar" class="card p-3 mb-2 flex gap-2 items-center flex-wrap" style="display:none">
|
|
<span class="text-xs text-gray-400"><b id="bulk-count">0</b> sélectionné(s)</span>
|
|
<button onclick="openValidateModal('validated_ok')" class="btn-sm bg-cyber-green text-black">Marquer OK</button>
|
|
<button onclick="openValidateModal('validated_ko')" class="btn-sm bg-red-900/40 text-cyber-red">Marquer KO</button>
|
|
{% if can_force %}<button onclick="openValidateModal('forced')" class="btn-sm bg-cyber-yellow text-black">Forcer</button>{% endif %}
|
|
</div>
|
|
|
|
<!-- Tableau -->
|
|
<div class="card overflow-x-auto">
|
|
<table class="w-full table-cyber text-xs">
|
|
<thead><tr>
|
|
<th class="p-2 w-8"><input type="checkbox" id="check-all" onchange="toggleAll(this)"></th>
|
|
<th class="p-2 text-left">Hostname</th>
|
|
<th class="p-2 text-left">Application</th>
|
|
<th class="p-2">Env</th>
|
|
<th class="p-2">Domaine</th>
|
|
<th class="p-2">Patched</th>
|
|
<th class="p-2">Jours</th>
|
|
<th class="p-2">Statut</th>
|
|
<th class="p-2">Validé par</th>
|
|
<th class="p-2">Action</th>
|
|
</tr></thead>
|
|
<tbody>
|
|
{% for v in validations %}
|
|
<tr class="border-t border-cyber-border/30">
|
|
<td class="p-2 text-center"><input type="checkbox" class="val-check" value="{{ v.id }}" onchange="updateBulk()"></td>
|
|
<td class="p-2 font-mono text-cyber-accent">{{ v.hostname }}</td>
|
|
<td class="p-2 text-xs text-gray-300">{{ (v.application_name or '-')[:30] }}</td>
|
|
<td class="p-2 text-center">{{ v.env_name or '-' }}</td>
|
|
<td class="p-2 text-center text-gray-400">{{ v.domain_name or '-' }}</td>
|
|
<td class="p-2 text-center text-gray-400">{% if v.patch_date %}{{ v.patch_date.strftime('%Y-%m-%d %H:%M') }}{% endif %}</td>
|
|
<td class="p-2 text-center {% if v.days_pending and v.days_pending > 7 %}text-cyber-red font-bold{% endif %}">{{ v.days_pending|int if v.days_pending else '-' }}</td>
|
|
<td class="p-2 text-center">
|
|
{% if v.status == 'en_attente' %}<span class="badge badge-yellow">En attente</span>
|
|
{% elif v.status == 'validated_ok' %}<span class="badge badge-green">✓ OK</span>
|
|
{% elif v.status == 'validated_ko' %}<span class="badge badge-red">✗ KO</span>
|
|
{% elif v.status == 'forced' %}<span class="badge badge-yellow" title="{{ v.forced_reason }}">Forcé</span>
|
|
{% endif %}
|
|
</td>
|
|
<td class="p-2 text-xs text-gray-300">
|
|
{% if v.validated_by_name %}{{ v.validated_by_name }}{% if v.validated_at %}<div class="text-gray-500" style="font-size:10px">{{ v.validated_at.strftime('%Y-%m-%d') }}</div>{% endif %}
|
|
{% else %}—{% endif %}
|
|
</td>
|
|
<td class="p-2 text-center">
|
|
<a href="/patching/validations/history/{{ v.server_id }}" class="text-xs text-cyber-accent hover:underline">Historique</a>
|
|
</td>
|
|
</tr>
|
|
{% endfor %}
|
|
{% if not validations %}
|
|
<tr><td colspan="10" class="p-6 text-center text-gray-500">Aucune validation dans ce filtre</td></tr>
|
|
{% endif %}
|
|
</tbody>
|
|
</table>
|
|
</div>
|
|
|
|
<!-- Modal validation -->
|
|
<div id="validate-modal" style="display:none;position:fixed;inset:0;background:rgba(0,0,0,0.7);z-index:9999;justify-content:center;align-items:center">
|
|
<div class="card p-5" style="width:420px;max-width:95vw">
|
|
<h3 class="text-sm font-bold text-cyber-accent mb-3" id="validate-title">Valider</h3>
|
|
<div id="validator-zone">
|
|
<label class="text-xs text-gray-500 block mb-1">Validé par (contact iTop obligatoire)</label>
|
|
<select id="val-contact-id" class="w-full text-xs">
|
|
<option value="">-- Sélectionner --</option>
|
|
{% for c in contacts %}<option value="{{ c.id }}">{{ c.name }} ({{ c.team or c.role }})</option>{% endfor %}
|
|
</select>
|
|
</div>
|
|
<div id="forced-zone" style="display:none" class="mt-3">
|
|
<label class="text-xs text-gray-500 block mb-1">Raison du forçage (obligatoire)</label>
|
|
<textarea id="val-reason" rows="3" class="w-full text-xs"></textarea>
|
|
</div>
|
|
<div class="mt-3">
|
|
<label class="text-xs text-gray-500 block mb-1">Notes (optionnel)</label>
|
|
<textarea id="val-notes" rows="2" class="w-full text-xs"></textarea>
|
|
</div>
|
|
<div class="flex gap-2 mt-4 justify-end">
|
|
<button onclick="document.getElementById('validate-modal').style.display='none'" class="btn-sm bg-cyber-border text-gray-300">Annuler</button>
|
|
<button onclick="submitValidate()" class="btn-primary px-3 py-1 text-xs">Enregistrer</button>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<script>
|
|
let _valStatus = null;
|
|
function toggleAll(cb) {
|
|
document.querySelectorAll('.val-check').forEach(c => c.checked = cb.checked);
|
|
updateBulk();
|
|
}
|
|
function updateBulk() {
|
|
const ids = Array.from(document.querySelectorAll('.val-check:checked')).map(c => parseInt(c.value));
|
|
const bar = document.getElementById('bulk-bar');
|
|
bar.style.display = ids.length > 0 ? 'flex' : 'none';
|
|
document.getElementById('bulk-count').textContent = ids.length;
|
|
window._selectedValIds = ids;
|
|
}
|
|
function openValidateModal(status) {
|
|
_valStatus = status;
|
|
const titles = {'validated_ok':'Marquer OK', 'validated_ko':'Marquer KO', 'forced':'Forcer'};
|
|
document.getElementById('validate-title').textContent = titles[status] + ' — ' + (window._selectedValIds || []).length + ' serveur(s)';
|
|
document.getElementById('forced-zone').style.display = (status === 'forced') ? 'block' : 'none';
|
|
document.getElementById('validator-zone').style.display = (status === 'forced') ? 'none' : 'block';
|
|
document.getElementById('val-reason').value = '';
|
|
document.getElementById('val-notes').value = '';
|
|
document.getElementById('validate-modal').style.display = 'flex';
|
|
}
|
|
function submitValidate() {
|
|
const ids = window._selectedValIds || [];
|
|
if (!ids.length) return alert('Aucun sélectionné');
|
|
const payload = {
|
|
validation_ids: ids,
|
|
status: _valStatus,
|
|
contact_id: document.getElementById('val-contact-id').value || null,
|
|
forced_reason: document.getElementById('val-reason').value,
|
|
notes: document.getElementById('val-notes').value,
|
|
};
|
|
fetch('/patching/validations/mark', {
|
|
method: 'POST', credentials: 'same-origin',
|
|
headers: {'Content-Type': 'application/json'},
|
|
body: JSON.stringify(payload)
|
|
})
|
|
.then(r => r.json())
|
|
.then(d => {
|
|
if (d.ok) { alert(d.updated + ' marqué(s)'); location.reload(); }
|
|
else alert('Erreur: ' + (d.msg || ''));
|
|
});
|
|
}
|
|
</script>
|
|
{% endblock %}
|