patchcenter/app/templates/patching_import.html
Admin MPCZ 040448696b feat(patching/import): boutons d'action toujours cliquables + alerte si aucune selection
Avant: les boutons (+ Ajouter, Reporter, Annuler, Pré-patching) etaient grises (disabled)
quand aucune row n'etait selectionnee, et le clic ne provoquait rien -> confusion.

Apres:
- Boutons toujours cliquables (HTML disabled retire)
- Atténuation visuelle (opacity-50) quand selection vide pour feedback
- Au clic sans selection: alerte 'Veuillez selectionner au moins un serveur.'
- Btn Pre-patching: si selection mais aucun eligible, alerte indiquant
  d'utiliser '+ Ajouter au patching' d'abord
2026-05-07 19:51:32 +02:00

457 lines
24 KiB
HTML
Raw 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 %}Importer planning patching{% endblock %}
{% block content %}
<div class="flex justify-between items-center mb-4">
<div>
<h2 class="text-xl font-bold text-cyber-accent">Importer planning de patching</h2>
<p class="text-xs text-gray-500 mt-1">
Upload du fichier Excel "Plan de Patching serveurs YYYY". Une feuille = une semaine (S02..S52).
Les onglets historiques (Histo-XXXX) sont ignorés.
</p>
</div>
{% if current_import %}
<a href="/patching/import" class="btn-sm bg-cyber-border text-cyber-accent px-4 py-2">← Liste imports</a>
{% endif %}
</div>
{% if msg == 'ok' %}<div class="bg-cyber-green/20 text-cyber-green p-2 mb-3 text-xs rounded">Import réussi.</div>{% endif %}
{% if msg == 'deleted' %}<div class="bg-cyber-blue/20 text-cyber-blue p-2 mb-3 text-xs rounded">Import supprimé.</div>{% endif %}
{% if err == 'ext' %}<div class="bg-cyber-red/20 text-cyber-red p-2 mb-3 text-xs rounded">Le fichier doit être un .xlsx.</div>{% endif %}
{% if err == 'parse' %}<div class="bg-cyber-red/20 text-cyber-red p-2 mb-3 text-xs rounded">Impossible de parser le fichier.</div>{% endif %}
{% if err == 'denied' %}<div class="bg-cyber-red/20 text-cyber-red p-2 mb-3 text-xs rounded">Permission refusée.</div>{% endif %}
{% if err == 'notfound' %}<div class="bg-cyber-red/20 text-cyber-red p-2 mb-3 text-xs rounded">Import introuvable.</div>{% endif %}
{% if err == 'openpyxl_missing' %}<div class="bg-cyber-red/20 text-cyber-red p-2 mb-3 text-xs rounded">Lib openpyxl manquante côté serveur.</div>{% endif %}
{# ──────────── Upload form ──────────── #}
{% if can_import %}
<div class="card p-4 mb-4">
<h3 class="text-sm font-bold text-cyber-accent mb-2">Nouvel import</h3>
<form method="POST" action="/patching/import/upload" enctype="multipart/form-data" class="flex flex-wrap gap-2 items-center">
<input type="file" name="file" accept=".xlsx" required class="text-xs">
<input type="text" name="note" placeholder="Note (optionnelle)" class="text-xs px-2 py-1 flex-1 min-w-[200px]">
<button type="submit" class="btn-primary px-3 py-1 text-xs">Importer</button>
</form>
</div>
{% endif %}
{# ──────────── Liste des imports ──────────── #}
<div class="card p-3 mb-4">
<h3 class="text-sm font-bold text-cyber-accent mb-2">Imports récents ({{ imports|length }})</h3>
{% if imports %}
<table class="w-full text-xs">
<thead class="text-cyber-accent border-b border-cyber-border">
<tr>
<th class="text-left p-1">ID</th>
<th class="text-left p-1">Fichier</th>
<th class="text-left p-1">Année</th>
<th class="text-right p-1">Feuilles</th>
<th class="text-right p-1">Lignes</th>
<th class="text-left p-1">Date</th>
<th class="text-left p-1">Par</th>
<th class="text-right p-1">Actions</th>
</tr>
</thead>
<tbody>
{% for i in imports %}
<tr class="border-b border-cyber-border/30 {% if current_import and current_import.id == i.id %}bg-cyber-border/30{% endif %}">
<td class="p-1">#{{ i.id }}</td>
<td class="p-1"><a href="/patching/import/{{ i.id }}" class="text-cyber-accent hover:underline">{{ i.filename }}</a></td>
<td class="p-1">{{ i.year or '' }}</td>
<td class="p-1 text-right">{{ i.sheet_count }}</td>
<td class="p-1 text-right">{{ i.row_count }}</td>
<td class="p-1">{{ i.uploaded_at.strftime('%Y-%m-%d %H:%M') }}</td>
<td class="p-1">{{ i.uploaded_by_name or '' }}</td>
<td class="p-1 text-right">
<a href="/patching/import/{{ i.id }}" class="text-cyber-blue hover:underline">Voir</a>
{% if can_import %}
· <form method="POST" action="/patching/import/{{ i.id }}/delete" class="inline" onsubmit="return confirm('Supprimer cet import ?')">
<button type="submit" class="text-cyber-red hover:underline">Suppr</button>
</form>
{% endif %}
</td>
</tr>
{% endfor %}
</tbody>
</table>
{% else %}
<p class="text-xs text-gray-500">Aucun import pour le moment.</p>
{% endif %}
</div>
{# ──────────── Détail de l'import courant ──────────── #}
{% if current_import %}
<div class="card p-4 mb-4">
<div class="flex justify-between items-start mb-3">
<div>
<h3 class="text-sm font-bold text-cyber-accent">Import #{{ current_import.id }} : {{ current_import.filename }}</h3>
<p class="text-xs text-gray-500 mt-1">
{{ current_import.sheet_count }} feuilles · {{ current_import.row_count }} lignes ·
{{ current_import.uploaded_at.strftime('%Y-%m-%d %H:%M') }}
{% if current_import.note %} · <em>{{ current_import.note }}</em>{% endif %}
</p>
</div>
</div>
{# Sélecteur de semaine #}
<div class="flex gap-2 items-center mb-3 flex-wrap">
<label class="text-xs text-gray-400">Semaine :</label>
<select id="sheet-select" class="text-xs px-2 py-1">
<option value="">— Choisir —</option>
{% for s in sheets %}
<option value="{{ s.sheet_name }}">{{ s.sheet_name }} (S{{ '%02d' % s.week_number }}) — {{ s.nb }} serveur(s)</option>
{% endfor %}
</select>
<span id="sheet-summary" class="text-xs text-gray-500"></span>
</div>
{# Tableau dynamique #}
<div id="sheet-table-wrap" class="overflow-x-auto" style="display:none;">
{# Filtres client-side #}
<div class="flex gap-2 items-center mb-2 flex-wrap">
<select id="filter-intervenant" class="text-xs px-2 py-1">
<option value="">— Tous intervenants —</option>
</select>
<select id="filter-env" class="text-xs px-2 py-1">
<option value="">— Tous environnements —</option>
</select>
<button id="filter-reset" class="text-xs text-gray-500 hover:text-cyber-accent">Reset</button>
<span class="text-xs text-gray-400" id="filter-count"></span>
</div>
<div class="flex gap-2 items-center mb-2 flex-wrap">
<label class="text-xs">
<input type="checkbox" id="select-all" class="mr-1"> Tout sélectionner (visibles)
</label>
<span class="text-xs text-gray-400" id="selection-count">0 sélectionné(s)</span>
<div class="flex-1"></div>
{% if can_import %}
<button id="btn-add-eligible" class="btn-sm bg-cyber-green/20 text-cyber-green px-3 py-1 text-xs" title="Marque les lignes sélectionnées comme éligibles au patching">
+ Ajouter au patching
</button>
<button id="btn-report" class="btn-sm bg-cyber-blue/20 text-cyber-blue px-3 py-1 text-xs" title="Reporter les lignes sélectionnées à une autre semaine">
⤳ Reporter
</button>
<button id="btn-unset" class="btn-sm bg-cyber-border text-gray-400 px-3 py-1 text-xs" title="Annuler éligibilité / report sur les lignes sélectionnées">
Annuler
</button>
{% endif %}
<button id="btn-prepatch" class="btn-sm bg-cyber-yellow/20 text-cyber-yellow px-3 py-1 text-xs" title="Lancer le pré-patching (rows éligibles uniquement)">
Pré-patching →
</button>
</div>
<table class="w-full text-xs" id="sheet-table">
<thead class="text-cyber-accent border-b border-cyber-border">
<tr>
<th class="text-left p-1 w-6"><input type="checkbox" id="select-all-head"></th>
<th class="text-left p-1">État</th>
<th class="text-left p-1 cursor-pointer select-none hover:text-cyber-accent" id="th-asset" title="Cliquer pour trier">
Asset <span id="th-asset-arrow" class="text-[10px] opacity-50"></span>
</th>
<th class="text-left p-1">Env</th>
<th class="text-left p-1">Domaine</th>
<th class="text-left p-1">OS</th>
<th class="text-left p-1">Version OS</th>
<th class="text-left p-1">Application</th>
<th class="text-left p-1">Intervenant</th>
<th class="text-left p-1">Valideur RA</th>
<th class="text-left p-1">Resp. Domaine DTS</th>
<th class="text-left p-1">Référent tech.</th>
<th class="text-left p-1 cursor-pointer select-none hover:text-cyber-accent" id="th-date" title="Cliquer pour trier par date+heure">
Date <span id="th-date-arrow" class="text-[10px] opacity-50"></span>
</th>
<th class="text-left p-1">Heure</th>
</tr>
</thead>
<tbody id="sheet-table-body"></tbody>
</table>
</div>
<div id="sheet-empty" class="text-xs text-gray-500" style="display:none;">Aucune ligne pour cette feuille.</div>
</div>
<script>
(function(){
const importId = {{ current_import.id }};
const sel = document.getElementById('sheet-select');
const wrap = document.getElementById('sheet-table-wrap');
const empty = document.getElementById('sheet-empty');
const tbody = document.getElementById('sheet-table-body');
const summary = document.getElementById('sheet-summary');
const selAll = document.getElementById('select-all');
const selAllHead = document.getElementById('select-all-head');
const selCount = document.getElementById('selection-count');
const btnPre = document.getElementById('btn-prepatch');
const btnAddElig = document.getElementById('btn-add-eligible');
const btnReport = document.getElementById('btn-report');
const btnUnset = document.getElementById('btn-unset');
const fInter = document.getElementById('filter-intervenant');
const fEnv = document.getElementById('filter-env');
const fReset = document.getElementById('filter-reset');
const fCount = document.getElementById('filter-count');
const thAsset = document.getElementById('th-asset');
const thAssetArrow = document.getElementById('th-asset-arrow');
const thDate = document.getElementById('th-date');
const thDateArrow = document.getElementById('th-date-arrow');
let currentRows = [];
// sortKey ∈ {null, 'asset', 'date'} ; sortDir ∈ {1 asc, -1 desc}
let sortKey = null;
let sortDir = 1;
function escapeHTML(s){
if (s === null || s === undefined) return '';
return String(s).replace(/[&<>"']/g, c => ({'&':'&amp;','<':'&lt;','>':'&gt;','"':'&quot;',"'":'&#39;'}[c]));
}
function dateFR(iso){
// 'YYYY-MM-DD' -> 'DD/MM/YYYY'
if (!iso) return '';
const m = String(iso).match(/^(\d{4})-(\d{2})-(\d{2})/);
return m ? (m[3] + '/' + m[2] + '/' + m[1]) : iso;
}
function shortOSVersion(v){
if (!v) return '';
const s = String(v);
let m = s.match(/red\s*hat[^0-9]+(\d+)/i);
if (m) return 'RedHat ' + m[1];
m = s.match(/centos[^0-9]+(\d+)/i);
if (m) return 'CentOS ' + m[1];
m = s.match(/oracle\s*linux[^0-9]+(\d+)/i);
if (m) return 'Oracle ' + m[1];
m = s.match(/ubuntu[^0-9]+(\d+(?:\.\d+)?)/i);
if (m) return 'Ubuntu ' + m[1];
m = s.match(/debian[^0-9]+(\d+)/i);
if (m) return 'Debian ' + m[1];
m = s.match(/windows\s*server\s*(\d{4})/i);
if (m) return 'Win ' + m[1];
m = s.match(/windows\s*(\d+)/i);
if (m) return 'Win ' + m[1];
// Fallback : 30 premiers chars
return s.length > 30 ? s.slice(0, 30) + '…' : s;
}
function refreshSelection(){
const visible = tbody.querySelectorAll('tr:not(.row-hidden)');
const visibleCb = tbody.querySelectorAll('tr:not(.row-hidden) input.row-cb');
const checkedCb = Array.from(tbody.querySelectorAll('input.row-cb:checked'));
const checked = checkedCb.length;
const eligibleSelected = checkedCb.filter(cb => cb.dataset.eligible === '1').length;
selCount.textContent = checked + ' sélectionné(s) · ' + visible.length + ' visible(s)' + (eligibleSelected ? ' · ' + eligibleSelected + ' éligible(s)' : '');
const allVisibleChecked = visibleCb.length > 0 && Array.from(visibleCb).every(cb => cb.checked);
selAll.checked = allVisibleChecked;
selAllHead.checked = allVisibleChecked;
// Boutons toujours cliquables : si rien de sélectionné on alerte au clic
// (au lieu de griser un bouton qu'on ne peut pas atteindre).
// Atténuation visuelle quand pas de sélection pour donner un feedback :
const hasSel = checked > 0;
const dimIfEmpty = (btn, active) => {
if (!btn) return;
btn.classList.toggle('opacity-50', !active);
};
dimIfEmpty(btnAddElig, hasSel);
dimIfEmpty(btnReport, hasSel);
dimIfEmpty(btnUnset, hasSel);
dimIfEmpty(btnPre, eligibleSelected > 0);
}
function getSelectedRowIds(){
return Array.from(tbody.querySelectorAll('input.row-cb:checked')).map(cb => parseInt(cb.dataset.id, 10)).filter(x => x);
}
async function postAction(payload){
const r = await fetch('/patching/import/' + importId + '/rows/action', {
method: 'POST', headers: {'Content-Type':'application/json'},
body: JSON.stringify(payload),
});
const j = await r.json();
if (!j.ok) { alert('Erreur : ' + (j.msg || 'inconnue')); return false; }
return true;
}
async function reloadCurrentSheet(){
if (sel.value) await loadSheet(sel.value);
}
function applyFilters(){
// Comparaisons case-insensitive (Production == production, etc.)
const fi = (fInter.value || '').trim().toLowerCase();
const fe = (fEnv.value || '').trim().toLowerCase();
let visibleCount = 0;
tbody.querySelectorAll('tr').forEach(tr => {
const i = (tr.dataset.intervenant || '').toLowerCase();
const e = (tr.dataset.env || '').toLowerCase();
const ok = (!fi || i === fi) && (!fe || e === fe);
if (ok) { tr.classList.remove('row-hidden'); tr.style.display=''; visibleCount++; }
else { tr.classList.add('row-hidden'); tr.style.display='none'; }
});
fCount.textContent = visibleCount + ' / ' + currentRows.length + ' affichées';
refreshSelection();
}
function rebuildSelectOptions(sel, values, placeholder){
// Dedup case-insensitive : on garde la 1re forme rencontrée comme canonique
// (généralement la majuscule "Production" si elle apparaît avant "production")
const cur = sel.value;
const seen = new Map(); // lowercase -> canonical form
for (const v of values) {
if (!v) continue;
const k = v.toLowerCase();
if (!seen.has(k)) seen.set(k, v);
}
const opts = Array.from(seen.values()).sort((a,b) =>
a.localeCompare(b, 'fr', {sensitivity:'base'}));
sel.innerHTML = '<option value="">' + placeholder + '</option>'
+ opts.map(v => '<option value="' + escapeHTML(v) + '"' + (cur === v ? ' selected' : '') + '>' + escapeHTML(v) + '</option>').join('');
}
function updateSortArrow(){
const arrow = sortDir === 1 ? '▲' : '▼';
thAssetArrow.textContent = sortKey === 'asset' ? arrow : '↕';
thAssetArrow.classList.toggle('opacity-50', sortKey !== 'asset');
thDateArrow.textContent = sortKey === 'date' ? arrow : '↕';
thDateArrow.classList.toggle('opacity-50', sortKey !== 'date');
}
function cycleSort(key){
if (sortKey !== key) { sortKey = key; sortDir = 1; }
else if (sortDir === 1) { sortDir = -1; }
else { sortKey = null; sortDir = 1; }
}
async function loadSheet(name){
if (!name) { wrap.style.display='none'; empty.style.display='none'; return; }
summary.textContent = 'Chargement…';
const r = await fetch('/patching/import/' + importId + '/sheet/' + encodeURIComponent(name));
const j = await r.json();
if (!j.ok) { summary.textContent = j.msg || 'Erreur'; return; }
if (!j.rows.length) {
wrap.style.display='none'; empty.style.display=''; summary.textContent='';
return;
}
empty.style.display='none'; wrap.style.display='';
summary.textContent = j.count + ' lignes';
currentRows = j.rows;
rebuildSelectOptions(fInter, currentRows.map(r => r.intervenant), '— Tous intervenants —');
rebuildSelectOptions(fEnv, currentRows.map(r => r.environnement), '— Tous environnements —');
sortKey = null; sortDir = 1;
updateSortArrow();
renderTable();
}
function renderTable(){
let rows = currentRows.slice();
if (sortKey === 'asset') {
rows.sort((a, b) => {
const av = (a.asset_name || '').toLowerCase();
const bv = (b.asset_name || '').toLowerCase();
if (av < bv) return -sortDir;
if (av > bv) return sortDir;
return 0;
});
} else if (sortKey === 'date') {
rows.sort((a, b) => {
// Lignes sans start_iso (date texte libre) en fin
const av = a.start_iso || '';
const bv = b.start_iso || '';
if (!av && !bv) return 0;
if (!av) return 1;
if (!bv) return -1;
if (av < bv) return -sortDir;
if (av > bv) return sortDir;
return 0;
});
}
tbody.innerHTML = rows.map(r => {
const display = escapeHTML(r.resolved_hostname || r.asset_name || '');
const assetCell = r.server_id
? '<a href="/servers?search=' + encodeURIComponent(r.resolved_hostname || r.asset_name) + '" target="_blank" rel="noopener" class="text-cyber-blue hover:underline">' + display + '</a>'
: '<span class="text-cyber-yellow" title="Pas matché en base PatchCenter">' + escapeHTML(r.asset_name || '') + ' ⚠</span>';
let badge = '';
if (r.is_eligible) {
badge = '<span class="text-cyber-green" title="Éligible au patching">✓ ÉLIG.</span>';
} else if (r.reported_to_sheet) {
const t = r.report_reason ? ('Reporté → ' + r.reported_to_sheet + ' : ' + r.report_reason) : ('Reporté → ' + r.reported_to_sheet);
badge = '<span class="text-cyber-blue" title="' + escapeHTML(t) + '">⤳ ' + escapeHTML(r.reported_to_sheet) + '</span>';
}
return '<tr class="border-b border-cyber-border/20 hover:bg-cyber-border/10"'
+ ' data-asset="' + escapeHTML(r.asset_name||'') + '"'
+ ' data-intervenant="' + escapeHTML(r.intervenant||'') + '"'
+ ' data-env="' + escapeHTML(r.environnement||'') + '">'
+ '<td class="p-1"><input type="checkbox" class="row-cb" data-id="' + r.id + '" data-asset="' + escapeHTML(r.asset_name||'') + '" data-server-id="' + (r.server_id||'') + '" data-eligible="' + (r.is_eligible ? '1':'0') + '"></td>'
+ '<td class="p-1 text-[10px]">' + badge + '</td>'
+ '<td class="p-1 font-mono">' + assetCell + '</td>'
+ '<td class="p-1">' + escapeHTML(r.environnement||'') + '</td>'
+ '<td class="p-1">' + escapeHTML(r.domaine||'') + '</td>'
+ '<td class="p-1">' + escapeHTML(r.os||'') + '</td>'
+ '<td class="p-1" title="' + escapeHTML(r.os_version||'') + '">' + escapeHTML(shortOSVersion(r.os_version)) + '</td>'
+ '<td class="p-1">' + escapeHTML(r.application_name||'') + '</td>'
+ '<td class="p-1">' + escapeHTML(r.intervenant||'') + '</td>'
+ '<td class="p-1">' + escapeHTML(r.valideur_ra||'') + '</td>'
+ '<td class="p-1">' + escapeHTML(r.responsable_domaine_dts||'') + '</td>'
+ '<td class="p-1">' + escapeHTML(r.referent_technique||'') + '</td>'
+ '<td class="p-1">' + (r.jour ? escapeHTML(dateFR(r.jour)) : (r.jour_text ? '<span class="text-cyber-yellow" title="Texte libre">' + escapeHTML(r.jour_text) + '</span>' : '')) + '</td>'
+ '<td class="p-1">' + escapeHTML(r.heure||'') + '</td>'
+ '</tr>';
}).join('');
tbody.querySelectorAll('input.row-cb').forEach(cb => cb.addEventListener('change', refreshSelection));
applyFilters();
}
sel.addEventListener('change', () => loadSheet(sel.value));
function toggleAll(state){
tbody.querySelectorAll('tr:not(.row-hidden) input.row-cb').forEach(cb => cb.checked = state);
refreshSelection();
}
selAll.addEventListener('change', () => toggleAll(selAll.checked));
selAllHead.addEventListener('change', () => toggleAll(selAllHead.checked));
[fInter, fEnv].forEach(el => el.addEventListener('change', applyFilters));
fReset.addEventListener('click', () => {
fInter.value = ''; fEnv.value = '';
sortKey = null; sortDir = 1;
updateSortArrow();
renderTable();
});
thAsset.addEventListener('click', () => { cycleSort('asset'); updateSortArrow(); renderTable(); });
thDate.addEventListener('click', () => { cycleSort('date'); updateSortArrow(); renderTable(); });
if (btnAddElig) btnAddElig.addEventListener('click', async () => {
const ids = getSelectedRowIds();
if (!ids.length) { alert('Veuillez sélectionner au moins un serveur.'); return; }
if (!confirm('Marquer ' + ids.length + ' ligne(s) comme éligibles au patching ?')) return;
if (await postAction({row_ids: ids, action: 'eligible'})) await reloadCurrentSheet();
});
if (btnReport) btnReport.addEventListener('click', async () => {
const ids = getSelectedRowIds();
if (!ids.length) { alert('Veuillez sélectionner au moins un serveur.'); return; }
const target = (prompt('Reporter vers quelle semaine ? (ex: S23)') || '').trim();
if (!target) return;
if (!/^S\d{1,2}$/i.test(target)) { alert('Format attendu : Sxx (ex S23)'); return; }
const reason = (prompt('Raison du report (optionnel) :') || '').trim();
if (await postAction({row_ids: ids, action: 'report', target_sheet: target.toUpperCase(), reason})) await reloadCurrentSheet();
});
if (btnUnset) btnUnset.addEventListener('click', async () => {
const ids = getSelectedRowIds();
if (!ids.length) { alert('Veuillez sélectionner au moins un serveur.'); return; }
if (!confirm('Annuler éligibilité ET report sur ' + ids.length + ' ligne(s) ?')) return;
if (await postAction({row_ids: ids, action: 'unset_eligible'})) {
await postAction({row_ids: ids, action: 'unset_report'});
await reloadCurrentSheet();
}
});
btnPre.addEventListener('click', () => {
const checkedAny = Array.from(tbody.querySelectorAll('input.row-cb:checked'));
if (!checkedAny.length) { alert('Veuillez sélectionner au moins un serveur.'); return; }
const ids = checkedAny
.filter(cb => cb.dataset.eligible === '1')
.map(cb => cb.dataset.id);
if (!ids.length) {
alert('Aucun serveur sélectionné n\'est éligible. Marque-les d\'abord avec "+ Ajouter au patching".');
return;
}
window.location.href = '/patching/iexec?row_ids=' + ids.join(',');
});
})();
</script>
{% endif %}
{% endblock %}