- 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>
310 lines
16 KiB
HTML
310 lines
16 KiB
HTML
{% 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 %}
|