- /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>
226 lines
12 KiB
HTML
226 lines
12 KiB
HTML
{% extends 'base.html' %}
|
|
{% block title %}Patching — Config exclusions{% endblock %}
|
|
{% block content %}
|
|
<div class="flex justify-between items-center mb-4">
|
|
<div>
|
|
<h2 class="text-xl font-bold text-cyber-accent">Config exclusions — par serveur</h2>
|
|
<p class="text-xs text-gray-500 mt-1">Exclusions de packages lors du <code>yum update</code>. Stockées dans iTop (champ <code>patch_excludes</code>) et poussées en temps réel.</p>
|
|
</div>
|
|
<div class="flex gap-2">
|
|
<a href="/quickwin/config" class="btn-sm bg-cyber-border text-gray-300 px-3 py-2">Packages reboot (QuickWin)</a>
|
|
</div>
|
|
</div>
|
|
|
|
{% if msg %}
|
|
<div class="mb-3 p-2 rounded text-sm bg-green-900/30 text-cyber-green">{{ msg }}</div>
|
|
{% endif %}
|
|
|
|
<!-- 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-accent">{{ stats.total_servers }}</div><div class="text-xs text-gray-500">Serveurs total</div></div>
|
|
<div class="card p-3 text-center" style="flex:1"><div class="text-2xl font-bold text-cyber-green">{{ stats.with_excludes }}</div><div class="text-xs text-gray-500">Avec exclusions</div></div>
|
|
<div class="card p-3 text-center" style="flex:1"><div class="text-2xl font-bold text-cyber-yellow">{{ stats.total_servers - stats.with_excludes }}</div><div class="text-xs text-gray-500">Sans exclusions</div></div>
|
|
<div class="card p-3 text-center" style="flex:1"><div class="text-2xl font-bold text-cyber-blue">{{ total }}</div><div class="text-xs text-gray-500">Filtrés</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="{{ filters.search }}" placeholder="Hostname..." class="text-xs py-1 px-2" style="width:200px">
|
|
<select name="domain" class="text-xs py-1 px-2" style="width:150px">
|
|
<option value="">Tous domaines</option>
|
|
{% for d in domains %}<option value="{{ d.code }}" {% if filters.domain == d.code %}selected{% endif %}>{{ d.name }}</option>{% endfor %}
|
|
</select>
|
|
<select name="env" class="text-xs py-1 px-2" style="width:130px">
|
|
<option value="">Tous envs</option>
|
|
{% for e in envs %}<option value="{{ e.code }}" {% if filters.env == e.code %}selected{% endif %}>{{ e.name }}</option>{% endfor %}
|
|
</select>
|
|
<select name="zone" class="text-xs py-1 px-2" style="width:100px">
|
|
<option value="">Toutes zones</option>
|
|
{% for z in zones %}<option value="{{ z }}" {% if filters.zone == z %}selected{% endif %}>{{ z }}</option>{% endfor %}
|
|
</select>
|
|
<select name="tier" class="text-xs py-1 px-2" style="width:90px">
|
|
<option value="">Tier</option>
|
|
{% for t in ['tier0','tier1','tier2','tier3'] %}<option value="{{ t }}" {% if filters.tier == t %}selected{% endif %}>{{ t }}</option>{% endfor %}
|
|
</select>
|
|
<select name="os" class="text-xs py-1 px-2" style="width:100px">
|
|
<option value="">Tous OS</option>
|
|
<option value="linux" {% if filters.os == 'linux' %}selected{% endif %}>Linux</option>
|
|
<option value="windows" {% if filters.os == 'windows' %}selected{% endif %}>Windows</option>
|
|
</select>
|
|
<select name="application" class="text-xs py-1 px-2" style="width:220px">
|
|
<option value="">Toutes solutions applicatives</option>
|
|
{% for a in applications %}<option value="{{ a.application_name }}" {% if filters.application == a.application_name %}selected{% endif %}>{{ a.application_name }} ({{ a.c }})</option>{% endfor %}
|
|
</select>
|
|
<select name="has_excludes" class="text-xs py-1 px-2" style="width:140px">
|
|
<option value="">Tous</option>
|
|
<option value="yes" {% if filters.has_excludes == 'yes' %}selected{% endif %}>Avec exclusions</option>
|
|
<option value="no" {% if filters.has_excludes == 'no' %}selected{% endif %}>Sans exclusions</option>
|
|
</select>
|
|
<button type="submit" class="btn-primary px-3 py-1 text-xs">Filtrer</button>
|
|
<a href="/patching/config-exclusions" class="text-xs text-gray-500 hover:text-cyber-accent">Reset</a>
|
|
</form>
|
|
</div>
|
|
|
|
<!-- Bulk actions -->
|
|
<div id="bulk-bar" class="card p-3 mb-2" 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>
|
|
<span class="text-xs text-gray-500 font-bold ml-2">Exclusions :</span>
|
|
<input type="text" id="bulk-pattern" placeholder="pattern (ex: oracle*, nginx*)" class="text-xs py-1 px-2" style="width:220px">
|
|
<button onclick="bulkAction('add')" class="btn-sm bg-cyber-green text-black">Ajouter</button>
|
|
<button onclick="bulkAction('remove')" class="btn-sm bg-red-900/40 text-cyber-red">Retirer</button>
|
|
<button onclick="bulkAction('replace')" class="btn-sm bg-cyber-border text-cyber-accent">Remplacer tout</button>
|
|
</div>
|
|
<div class="flex gap-2 items-center flex-wrap">
|
|
<span class="text-xs text-gray-500 font-bold">Solution applicative :</span>
|
|
<select id="bulk-app" class="text-xs py-1 px-2" style="max-width:260px">
|
|
<option value="">-- 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 à tous les sélectionnés</button>
|
|
</div>
|
|
<div id="bulk-result" class="text-xs text-gray-400 mt-2"></div>
|
|
</div>
|
|
|
|
<!-- Table -->
|
|
<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="text-left p-2">Hostname</th>
|
|
<th class="text-left p-2">Solution applicative</th>
|
|
<th class="p-2">Domaine</th>
|
|
<th class="p-2">Env</th>
|
|
<th class="p-2">Zone</th>
|
|
<th class="p-2">Tier</th>
|
|
<th class="p-2">OS</th>
|
|
<th class="text-left p-2" style="min-width:300px">Exclusions</th>
|
|
<th class="p-2">Action</th>
|
|
</tr></thead>
|
|
<tbody>
|
|
{% for s in servers %}
|
|
<tr id="row-{{ s.id }}" class="border-t border-cyber-border/30">
|
|
<td class="p-2 text-center"><input type="checkbox" class="srv-check" value="{{ s.id }}" onchange="updateBulk()"></td>
|
|
<td class="p-2 font-mono text-cyber-accent">{{ s.hostname }}</td>
|
|
<td class="p-2 text-xs text-gray-300" title="{{ s.application_name or '' }}">{{ (s.application_name or '-')[:40] }}</td>
|
|
<td class="p-2 text-center text-gray-400">{{ s.domain_name or '-' }}</td>
|
|
<td class="p-2 text-center">{{ s.env_name or '-' }}</td>
|
|
<td class="p-2 text-center">{{ s.zone_name or '-' }}</td>
|
|
<td class="p-2 text-center">{{ s.tier or '-' }}</td>
|
|
<td class="p-2 text-center">{{ s.os_family or '-' }}</td>
|
|
<td class="p-2">
|
|
<textarea id="excl-{{ s.id }}" rows="2" class="w-full font-mono text-xs" placeholder="ex: oracle* tomcat* (un ou plusieurs patterns séparés par espace ou retour ligne)" style="resize:vertical;min-height:36px">{{ s.patch_excludes or '' }}</textarea>
|
|
</td>
|
|
<td class="p-2 text-center">
|
|
<button onclick="saveExcl({{ s.id }})" class="btn-sm bg-cyber-border text-cyber-accent">Sauver</button>
|
|
<span id="status-{{ s.id }}" class="text-xs ml-1"></span>
|
|
</td>
|
|
</tr>
|
|
{% endfor %}
|
|
</tbody>
|
|
</table>
|
|
</div>
|
|
|
|
<!-- Pagination -->
|
|
{% if total_pages > 1 %}
|
|
<div class="flex justify-center gap-2 mt-4">
|
|
{% for p in range(1, total_pages + 1) %}
|
|
<a href="?page={{ p }}{% for k,v in filters.items() %}{% if v %}&{{ k }}={{ v }}{% endif %}{% endfor %}"
|
|
class="btn-sm {% if p == page %}bg-cyber-accent text-black{% else %}bg-cyber-border text-gray-300{% endif %} px-2 py-1">{{ p }}</a>
|
|
{% endfor %}
|
|
</div>
|
|
{% endif %}
|
|
|
|
<script>
|
|
function toggleAll(cb) {
|
|
document.querySelectorAll('.srv-check').forEach(c => c.checked = cb.checked);
|
|
updateBulk();
|
|
}
|
|
|
|
function updateBulk() {
|
|
const ids = Array.from(document.querySelectorAll('.srv-check:checked')).map(c => parseInt(c.value));
|
|
const bar = document.getElementById('bulk-bar');
|
|
const count = document.getElementById('bulk-count');
|
|
count.textContent = ids.length;
|
|
bar.style.display = ids.length > 0 ? 'flex' : 'none';
|
|
window._selectedIds = ids;
|
|
}
|
|
|
|
function saveExcl(id) {
|
|
const inp = document.getElementById('excl-' + id);
|
|
const status = document.getElementById('status-' + id);
|
|
status.textContent = '…'; status.className = 'text-xs ml-1 text-gray-400';
|
|
const fd = new FormData();
|
|
fd.append('patch_excludes', inp.value);
|
|
fetch('/patching/config-exclusions/' + id + '/save', {method: 'POST', credentials: 'same-origin', body: fd})
|
|
.then(r => r.json())
|
|
.then(d => {
|
|
if (d.ok) {
|
|
status.textContent = d.itop && d.itop.pushed ? '✓ iTop' : '⚠ local';
|
|
status.className = 'text-xs ml-1 ' + (d.itop && d.itop.pushed ? 'text-cyber-green' : 'text-cyber-yellow');
|
|
status.title = d.itop ? d.itop.msg : '';
|
|
} else {
|
|
status.textContent = '✗';
|
|
status.className = 'text-xs ml-1 text-cyber-red';
|
|
status.title = d.msg || '';
|
|
}
|
|
})
|
|
.catch(e => { status.textContent = '✗'; status.className = 'text-xs ml-1 text-cyber-red'; status.title = e.message; });
|
|
}
|
|
|
|
function bulkChangeApp() {
|
|
const ids = window._selectedIds || [];
|
|
const app = document.getElementById('bulk-app').value;
|
|
const appName = document.getElementById('bulk-app').selectedOptions[0].text;
|
|
if (!ids.length) return alert('Aucun serveur sélectionné');
|
|
if (!confirm('Changer solution applicative vers "' + appName + '" sur ' + ids.length + ' serveur(s) ?')) return;
|
|
const res = document.getElementById('bulk-result');
|
|
res.textContent = 'En cours...'; res.className = 'text-xs text-gray-400 mt-2';
|
|
fetch('/patching/config-exclusions/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 + ' serveurs → ' + 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(), 2000);
|
|
} else {
|
|
res.textContent = '✗ ' + (d.msg || 'Erreur'); res.className = 'text-xs text-cyber-red mt-2';
|
|
}
|
|
})
|
|
.catch(e => { res.textContent = '✗ ' + e.message; res.className = 'text-xs text-cyber-red mt-2'; });
|
|
}
|
|
|
|
function bulkAction(action) {
|
|
const ids = window._selectedIds || [];
|
|
const pattern = document.getElementById('bulk-pattern').value.trim();
|
|
if (!ids.length) return alert('Aucun serveur sélectionné');
|
|
if (action !== 'replace' && !pattern) return alert('Saisir un pattern');
|
|
const label = action === 'add' ? 'Ajouter' : action === 'remove' ? 'Retirer' : 'Remplacer tout par';
|
|
if (!confirm(label + ' "' + pattern + '" sur ' + ids.length + ' serveur(s) ?')) return;
|
|
const res = document.getElementById('bulk-result');
|
|
res.textContent = 'En cours...'; res.className = 'text-xs text-gray-400 ml-2';
|
|
fetch('/patching/config-exclusions/bulk', {
|
|
method: 'POST', credentials: 'same-origin',
|
|
headers: {'Content-Type': 'application/json'},
|
|
body: JSON.stringify({server_ids: ids, pattern: pattern, action: action})
|
|
})
|
|
.then(r => r.json())
|
|
.then(d => {
|
|
if (d.ok) {
|
|
res.innerHTML = '✓ ' + d.updated + ' serveurs mis à jour — 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 ml-2';
|
|
setTimeout(() => location.reload(), 2000);
|
|
} else {
|
|
res.textContent = '✗ ' + (d.msg || 'Erreur'); res.className = 'text-xs text-cyber-red ml-2';
|
|
}
|
|
})
|
|
.catch(e => { res.textContent = '✗ ' + e.message; res.className = 'text-xs text-cyber-red ml-2'; });
|
|
}
|
|
</script>
|
|
{% endblock %}
|