- Split quickwin services: prereq, snapshot, log services - Add referentiel router and template - QuickWin detail: prereq/snapshot terminal divs for production - Server edit partial updates - QuickWin correspondance and logs templates - Base template updates Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
259 lines
13 KiB
HTML
259 lines
13 KiB
HTML
{% extends "base.html" %}
|
|
{% block title %}Correspondance QuickWin #{{ run.id }}{% endblock %}
|
|
|
|
{% macro qs(pg=page) -%}
|
|
?page={{ pg }}&per_page={{ per_page }}&search={{ filters.search or '' }}&pair_filter={{ filters.pair_filter or '' }}&domain_filter={{ filters.domain_filter or '' }}&env_filter={{ filters.env_filter or '' }}
|
|
{%- endmacro %}
|
|
|
|
{% block content %}
|
|
<div class="flex items-center justify-between mb-4">
|
|
<div>
|
|
<a href="/quickwin/{{ run.id }}" class="text-xs text-gray-500 hover:text-gray-300">← Retour campagne</a>
|
|
<h1 class="text-xl font-bold" style="color:#a78bfa">Correspondance H-Prod ↔ Prod</h1>
|
|
<p class="text-xs text-gray-500">{{ run.label }} — Appariement des serveurs hors-production avec leur homologue production</p>
|
|
</div>
|
|
<div class="flex gap-2 items-center">
|
|
<form method="post" action="/quickwin/{{ run.id }}/correspondance/auto">
|
|
<button class="btn-primary" style="padding:5px 16px;font-size:0.85rem">Auto-apparier</button>
|
|
</form>
|
|
<form method="post" action="/quickwin/{{ run.id }}/correspondance/clear-all"
|
|
onsubmit="return confirm('Supprimer tous les appariements ?')">
|
|
<button class="btn-sm btn-danger" style="padding:4px 12px">Tout effacer</button>
|
|
</form>
|
|
</div>
|
|
</div>
|
|
|
|
{% if msg %}
|
|
{% if msg == 'auto' %}
|
|
{% set am = request.query_params.get('am', '0') %}
|
|
{% set au = request.query_params.get('au', '0') %}
|
|
{% set aa = request.query_params.get('aa', '0') %}
|
|
<div style="background:#1a5a2e;color:#8f8;padding:8px 16px;border-radius:6px;margin-bottom:12px;font-size:0.85rem">
|
|
Auto-appariement terminé : {{ am }} apparié(s), {{ au }} sans homologue, {{ aa }} anomalie(s)
|
|
</div>
|
|
{% elif msg == 'cleared' %}
|
|
<div style="background:#5a3a1a;color:#ffcc00;padding:8px 16px;border-radius:6px;margin-bottom:12px;font-size:0.85rem">
|
|
Tous les appariements ont été supprimés.
|
|
</div>
|
|
{% elif msg == 'bulk' %}
|
|
{% set bc = request.query_params.get('bc', '0') %}
|
|
<div style="background:#1a5a2e;color:#8f8;padding:8px 16px;border-radius:6px;margin-bottom:12px;font-size:0.85rem">
|
|
{{ bc }} appariement(s) modifié(s) en masse.
|
|
</div>
|
|
{% endif %}
|
|
{% endif %}
|
|
|
|
<!-- KPIs -->
|
|
<div style="display:grid;grid-template-columns:repeat(4,1fr);gap:12px;margin-bottom:16px">
|
|
<div class="card p-3 text-center">
|
|
<div class="text-2xl font-bold" style="color:#fff">{{ stats.total }}</div>
|
|
<div class="text-xs text-gray-500">Total H-Prod</div>
|
|
</div>
|
|
<div class="card p-3 text-center">
|
|
<div class="text-2xl font-bold" style="color:#00ff88">{{ stats.matched }}</div>
|
|
<div class="text-xs text-gray-500">Appariés</div>
|
|
</div>
|
|
<div class="card p-3 text-center">
|
|
<div class="text-2xl font-bold" style="color:#ffcc00">{{ stats.unmatched }}</div>
|
|
<div class="text-xs text-gray-500">Sans homologue</div>
|
|
</div>
|
|
<div class="card p-3 text-center">
|
|
<div class="text-2xl font-bold" style="color:#ff3366">{{ stats.anomalies }}</div>
|
|
<div class="text-xs text-gray-500">Anomalies</div>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- Filtres -->
|
|
<form method="GET" class="card mb-4" style="padding:10px 16px;display:flex;gap:12px;align-items:center;flex-wrap:wrap">
|
|
<input type="text" name="search" value="{{ filters.search or '' }}" placeholder="Recherche hostname..." style="width:200px">
|
|
<select name="pair_filter" onchange="this.form.submit()" style="width:160px">
|
|
<option value="">Tous</option>
|
|
<option value="matched" {% if filters.pair_filter == 'matched' %}selected{% endif %}>Appariés</option>
|
|
<option value="unmatched" {% if filters.pair_filter == 'unmatched' %}selected{% endif %}>Sans homologue</option>
|
|
<option value="anomaly" {% if filters.pair_filter == 'anomaly' %}selected{% endif %}>Anomalies</option>
|
|
</select>
|
|
<select name="domain_filter" onchange="this.form.submit()" style="width:150px">
|
|
<option value="">Tous domaines</option>
|
|
{% for d in domains_in_run %}
|
|
<option value="{{ d }}" {% if filters.domain_filter == d %}selected{% endif %}>{{ d }}</option>
|
|
{% endfor %}
|
|
</select>
|
|
<select name="env_filter" onchange="this.form.submit()" style="width:140px">
|
|
<option value="">Tous envs</option>
|
|
<option value="preprod" {% if filters.env_filter == 'preprod' %}selected{% endif %}>Pré-Prod</option>
|
|
<option value="recette" {% if filters.env_filter == 'recette' %}selected{% endif %}>Recette</option>
|
|
<option value="dev" {% if filters.env_filter == 'dev' %}selected{% endif %}>Développement</option>
|
|
<option value="test" {% if filters.env_filter == 'test' %}selected{% endif %}>Test</option>
|
|
</select>
|
|
<select name="per_page" onchange="this.form.submit()" style="width:70px">
|
|
{% for n in [20,50,100,200] %}
|
|
<option value="{{ n }}" {% if per_page == n %}selected{% endif %}>{{ n }}</option>
|
|
{% endfor %}
|
|
</select>
|
|
<button type="submit" class="btn-primary" style="padding:4px 14px;font-size:0.8rem">Filtrer</button>
|
|
<a href="/quickwin/{{ run.id }}/correspondance" class="text-xs text-gray-500 hover:text-gray-300">Reset</a>
|
|
<span class="text-xs text-gray-500" style="margin-left:auto">{{ total_filtered }} résultat(s)</span>
|
|
</form>
|
|
|
|
<!-- Actions en masse -->
|
|
<div class="card mb-3" style="padding:8px 16px;display:flex;gap:10px;align-items:center">
|
|
<span class="text-xs text-gray-400"><span id="sel-count">0</span> sélectionné(s)</span>
|
|
<button class="btn-sm" style="background:#ff336622;color:#ff3366;padding:3px 12px" onclick="bulkClear()">Dissocier la sélection</button>
|
|
<span style="color:#1e3a5f">|</span>
|
|
<span class="text-xs text-gray-400">Associer la sélection à :</span>
|
|
<select id="bulk-prod" style="width:220px;font-size:0.8rem;padding:3px 6px">
|
|
<option value="">-- Serveur prod --</option>
|
|
{% for a in available %}
|
|
<option value="{{ a.id }}">{{ a.hostname }}{% if a.domaine %} ({{ a.domaine }}){% endif %}</option>
|
|
{% endfor %}
|
|
</select>
|
|
<button class="btn-sm" style="background:#00ff8822;color:#00ff88;padding:3px 12px" onclick="bulkAssign()">Associer</button>
|
|
</div>
|
|
|
|
<!-- Table -->
|
|
<div class="card">
|
|
<div class="table-wrap" style="max-height:65vh;overflow-y:auto">
|
|
<table class="table-cyber w-full">
|
|
<thead style="position:sticky;top:0;z-index:1"><tr>
|
|
<th class="px-1 py-2" style="width:28px"><input type="checkbox" id="check-all" title="Tout"></th>
|
|
<th class="px-2 py-2" style="width:160px">Serveur H-Prod</th>
|
|
<th class="px-2 py-2" style="width:100px">Domaine</th>
|
|
<th class="px-2 py-2" style="width:90px">Env</th>
|
|
<th class="px-2 py-2" style="width:160px">Candidat auto</th>
|
|
<th class="px-2 py-2" style="width:50px">Statut</th>
|
|
<th class="px-2 py-2">Serveur Prod apparié</th>
|
|
<th class="px-2 py-2" style="width:100px">Domaine Prod</th>
|
|
<th class="px-2 py-2" style="width:80px">Action</th>
|
|
</tr></thead>
|
|
<tbody>
|
|
{% for p in pairs %}
|
|
<tr id="row-{{ p.hprod_id }}" style="{% if p.is_anomaly %}background:#ff336610{% elif not p.is_matched %}background:#ffcc0008{% endif %}">
|
|
<td class="px-1 py-2"><input type="checkbox" class="row-check" value="{{ p.hprod_id }}"></td>
|
|
<td class="px-2 py-2 font-bold" style="color:#00d4ff">{{ p.hprod_hostname }}</td>
|
|
<td class="px-2 py-2 text-xs text-gray-400">{{ p.hprod_domaine }}</td>
|
|
<td class="px-2 py-2 text-xs">
|
|
{% if p.is_anomaly %}<span class="badge badge-red" title="Lettre 'p' mais classé hprod">{{ p.hprod_env or '?' }}</span>
|
|
{% else %}<span class="text-gray-400">{{ p.hprod_env }}</span>{% endif %}
|
|
</td>
|
|
<td class="px-2 py-2 text-xs text-gray-500">{{ p.candidate }}</td>
|
|
<td class="px-2 py-2 text-center">
|
|
{% if p.is_matched %}<span class="badge badge-green">OK</span>
|
|
{% elif p.is_anomaly %}<span class="badge badge-red">!</span>
|
|
{% else %}<span class="badge badge-yellow">--</span>{% endif %}
|
|
</td>
|
|
<td class="px-2 py-2">
|
|
{% if p.is_matched %}
|
|
<span style="color:#00ff88;font-weight:600">{{ p.prod_hostname }}</span>
|
|
{% else %}
|
|
<select class="prod-select" data-hprod="{{ p.hprod_id }}" style="width:100%;font-size:0.8rem;padding:3px 6px">
|
|
<option value="">-- Choisir serveur prod --</option>
|
|
{% for a in available %}
|
|
<option value="{{ a.id }}">{{ a.hostname }}{% if a.domaine %} ({{ a.domaine }}){% endif %}</option>
|
|
{% endfor %}
|
|
</select>
|
|
{% endif %}
|
|
</td>
|
|
<td class="px-2 py-2 text-xs text-gray-400">
|
|
{% if p.is_matched %}{{ p.prod_domaine }}{% endif %}
|
|
</td>
|
|
<td class="px-2 py-2 text-center">
|
|
{% if p.is_matched %}
|
|
<button class="btn-sm" style="background:#ff336622;color:#ff3366;padding:2px 8px"
|
|
onclick="clearPair({{ p.hprod_id }})">X</button>
|
|
{% else %}
|
|
<button class="btn-sm" style="background:#00ff8822;color:#00ff88;padding:2px 8px"
|
|
onclick="setPairFromSelect({{ p.hprod_id }})">OK</button>
|
|
{% endif %}
|
|
</td>
|
|
</tr>
|
|
{% endfor %}
|
|
{% if not pairs %}
|
|
<tr><td colspan="9" class="px-2 py-8 text-center text-gray-500">Aucun résultat{% if filters.search or filters.pair_filter %} pour ces filtres{% endif %}</td></tr>
|
|
{% endif %}
|
|
</tbody>
|
|
</table>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- Pagination -->
|
|
{% if total_pages > 1 %}
|
|
<div style="display:flex;justify-content:center;gap:6px;margin-top:12px">
|
|
{% if page > 1 %}
|
|
<a href="/quickwin/{{ run.id }}/correspondance{{ qs(page - 1) }}" class="btn-sm" style="background:#1e3a5f;color:#94a3b8;padding:4px 10px">←</a>
|
|
{% endif %}
|
|
{% for pg in range(1, total_pages + 1) %}
|
|
{% if pg == page %}
|
|
<span class="btn-sm" style="background:#00d4ff;color:#0a0e17;padding:4px 10px;font-weight:bold">{{ pg }}</span>
|
|
{% elif pg <= 3 or pg >= total_pages - 1 or (pg >= page - 1 and pg <= page + 1) %}
|
|
<a href="/quickwin/{{ run.id }}/correspondance{{ qs(pg) }}" class="btn-sm" style="background:#1e3a5f;color:#94a3b8;padding:4px 10px">{{ pg }}</a>
|
|
{% elif pg == 4 or pg == total_pages - 2 %}
|
|
<span class="text-gray-500" style="padding:4px 4px">…</span>
|
|
{% endif %}
|
|
{% endfor %}
|
|
{% if page < total_pages %}
|
|
<a href="/quickwin/{{ run.id }}/correspondance{{ qs(page + 1) }}" class="btn-sm" style="background:#1e3a5f;color:#94a3b8;padding:4px 10px">→</a>
|
|
{% endif %}
|
|
</div>
|
|
{% endif %}
|
|
|
|
<script>
|
|
/* ---- Select all / count ---- */
|
|
const checkAll = document.getElementById('check-all');
|
|
if (checkAll) {
|
|
checkAll.addEventListener('change', function() {
|
|
document.querySelectorAll('.row-check').forEach(cb => cb.checked = this.checked);
|
|
updateSelCount();
|
|
});
|
|
}
|
|
document.querySelectorAll('.row-check').forEach(cb => cb.addEventListener('change', updateSelCount));
|
|
function updateSelCount() {
|
|
document.getElementById('sel-count').textContent = document.querySelectorAll('.row-check:checked').length;
|
|
}
|
|
|
|
/* ---- Single actions ---- */
|
|
function setPairFromSelect(hprodId) {
|
|
const sel = document.querySelector('select[data-hprod="' + hprodId + '"]');
|
|
if (!sel) return;
|
|
const prodId = parseInt(sel.value);
|
|
if (!prodId) { alert('Choisissez un serveur prod'); return; }
|
|
apiSetPair(hprodId, prodId).then(() => location.reload());
|
|
}
|
|
function clearPair(hprodId) {
|
|
if (!confirm('Dissocier cet appariement ?')) return;
|
|
apiSetPair(hprodId, 0).then(() => location.reload());
|
|
}
|
|
|
|
/* ---- Bulk actions ---- */
|
|
function getSelected() {
|
|
return [...document.querySelectorAll('.row-check:checked')].map(cb => parseInt(cb.value));
|
|
}
|
|
function bulkClear() {
|
|
const ids = getSelected();
|
|
if (!ids.length) { alert('Aucune ligne sélectionnée'); return; }
|
|
if (!confirm('Dissocier ' + ids.length + ' appariement(s) ?')) return;
|
|
Promise.all(ids.map(id => apiSetPair(id, 0))).then(() => {
|
|
location.href = '/quickwin/{{ run.id }}/correspondance?msg=bulk&bc=' + ids.length;
|
|
});
|
|
}
|
|
function bulkAssign() {
|
|
const ids = getSelected();
|
|
if (!ids.length) { alert('Aucune ligne sélectionnée'); return; }
|
|
const prodId = parseInt(document.getElementById('bulk-prod').value);
|
|
if (!prodId) { alert('Choisissez un serveur prod'); return; }
|
|
if (!confirm('Associer ' + ids.length + ' serveur(s) au même prod ?')) return;
|
|
Promise.all(ids.map(id => apiSetPair(id, prodId))).then(() => {
|
|
location.href = '/quickwin/{{ run.id }}/correspondance?msg=bulk&bc=' + ids.length;
|
|
});
|
|
}
|
|
|
|
/* ---- API call ---- */
|
|
function apiSetPair(hprodId, prodId) {
|
|
return fetch('/api/quickwin/correspondance/set-pair', {
|
|
method: 'POST',
|
|
headers: {'Content-Type': 'application/json'},
|
|
body: JSON.stringify({hprod_id: hprodId, prod_id: prodId})
|
|
}).then(r => r.json());
|
|
}
|
|
</script>
|
|
{% endblock %}
|