patchcenter/app/templates/patching_correspondance.html
Admin MPCZ 677f621c81 Admin applications + correspondance cleanup + tools presentation DSI
- Admin applications: CRUD module (list/add/edit/delete/assign/multi-app)
  avec push iTop bidirectionnel (applications.py + 3 templates)
- Correspondance prod<->hors-prod: migration vers server_correspondance
  globale, suppression ancien code quickwin, ajout filtre environnement
  et solution applicative, colonne environnement dans builder
- Servers page: colonne application_name + equivalent(s) via get_links_bulk,
  filtre application_id, push iTop sur changement application
- Patching: bulk_update_application, bulk_update_excludes, validations
- Fix paramiko sftp.put (remote_path -> positional arg)
- Tools: wiki_to_pdf.py (DokuWiki -> PDF) + generate_ppt.py (PPTX 19 slides
  DSI patching) + contenu source (processus_patching.txt, script_presentation.txt)

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-13 21:11:58 +02:00

310 lines
16 KiB
HTML
Raw Permalink 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-xs" style="max-width:260px">
{% set link = server_links.get(s.id, {}) %}
{% if link and link.as_prod %}
<span class="text-cyber-green" style="font-size:10px">→ non-prod :</span>
{% for l in link.as_prod %}<span class="font-mono text-gray-300" title="{{ l.env_name or '' }}">{{ l.hostname }}{% if not loop.last %}, {% endif %}</span>{% endfor %}
{% elif link and link.as_nonprod %}
<span class="text-cyber-yellow" style="font-size:10px">→ prod :</span>
{% for l in link.as_nonprod %}<span class="font-mono text-gray-300">{{ l.hostname }}{% if not loop.last %}, {% endif %}</span>{% endfor %}
{% else %}
<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 %}