331 lines
18 KiB
HTML
331 lines
18 KiB
HTML
{% 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>
|
||
<button id="btn-prepatch" class="btn-sm bg-cyber-yellow/20 text-cyber-yellow px-3 py-1 text-xs" disabled>
|
||
Pré-patching <span class="text-[10px] opacity-60">(étape 2)</span>
|
||
</button>
|
||
<button id="btn-patch" class="btn-sm bg-cyber-green/20 text-cyber-green px-3 py-1 text-xs" disabled>
|
||
Patcher <span class="text-[10px] opacity-60">(étape 3)</span>
|
||
</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 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">Mode op.</th>
|
||
<th class="text-left p-1">Impacts</th>
|
||
<th class="text-left p-1">BDD</th>
|
||
<th class="text-left p-1">Date</th>
|
||
<th class="text-left p-1">Heure</th>
|
||
<th class="text-left p-1">Coupure</th>
|
||
<th class="text-left p-1">Pb disque</th>
|
||
<th class="text-left p-1">Lien serveur</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 btnPatch = document.getElementById('btn-patch');
|
||
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');
|
||
|
||
let currentRows = [];
|
||
let sortAsset = 0; // 0 = ordre fichier, 1 = asc, -1 = desc
|
||
|
||
function escapeHTML(s){
|
||
if (s === null || s === undefined) return '';
|
||
return String(s).replace(/[&<>"']/g, c => ({'&':'&','<':'<','>':'>','"':'"',"'":'''}[c]));
|
||
}
|
||
|
||
function refreshSelection(){
|
||
const visible = tbody.querySelectorAll('tr:not(.row-hidden)');
|
||
const visibleCb = tbody.querySelectorAll('tr:not(.row-hidden) input.row-cb');
|
||
const checked = tbody.querySelectorAll('input.row-cb:checked').length;
|
||
selCount.textContent = checked + ' sélectionné(s) · ' + visible.length + ' visible(s)';
|
||
const allVisibleChecked = visibleCb.length > 0 && Array.from(visibleCb).every(cb => cb.checked);
|
||
selAll.checked = allVisibleChecked;
|
||
selAllHead.checked = allVisibleChecked;
|
||
const hasSel = checked > 0;
|
||
btnPre.disabled = !hasSel;
|
||
btnPatch.disabled = !hasSel;
|
||
}
|
||
|
||
function applyFilters(){
|
||
const fi = (fInter.value || '').trim();
|
||
const fe = (fEnv.value || '').trim();
|
||
let visibleCount = 0;
|
||
tbody.querySelectorAll('tr').forEach(tr => {
|
||
const i = tr.dataset.intervenant || '';
|
||
const e = tr.dataset.env || '';
|
||
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){
|
||
const cur = sel.value;
|
||
const opts = Array.from(new Set(values.filter(x => x))).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(){
|
||
thAssetArrow.textContent = sortAsset === 1 ? '▲' : (sortAsset === -1 ? '▼' : '↕');
|
||
thAssetArrow.classList.toggle('opacity-50', sortAsset === 0);
|
||
}
|
||
|
||
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 —');
|
||
sortAsset = 0;
|
||
updateSortArrow();
|
||
renderTable();
|
||
}
|
||
|
||
function renderTable(){
|
||
let rows = currentRows.slice();
|
||
if (sortAsset !== 0) {
|
||
rows.sort((a, b) => {
|
||
const av = (a.asset_name || '').toLowerCase();
|
||
const bv = (b.asset_name || '').toLowerCase();
|
||
if (av < bv) return -sortAsset;
|
||
if (av > bv) return sortAsset;
|
||
return 0;
|
||
});
|
||
}
|
||
tbody.innerHTML = rows.map(r => {
|
||
const linkSrv = r.server_id
|
||
? '<a href="/qualys/search?field=hostname&q=' + encodeURIComponent(r.resolved_hostname || r.asset_name) + '" class="text-cyber-blue hover:underline">' + escapeHTML(r.resolved_hostname || r.asset_name) + '</a>'
|
||
: '<span class="text-cyber-yellow" title="Pas matché en base PatchCenter">' + escapeHTML(r.asset_name || '') + ' ⚠</span>';
|
||
const pb = r.pb_espace_disque === true ? '<span class="text-cyber-red">⚠ Oui</span>' : (r.pb_espace_disque === false ? 'Non' : '');
|
||
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||'') + '"></td>'
|
||
+ '<td class="p-1 font-mono">' + escapeHTML(r.asset_name||'') + '</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">' + escapeHTML(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">' + escapeHTML(r.mode_operatoire||'') + '</td>'
|
||
+ '<td class="p-1">' + escapeHTML(r.impacts||'') + '</td>'
|
||
+ '<td class="p-1">' + escapeHTML(r.base_de_donnees||'') + '</td>'
|
||
+ '<td class="p-1">' + escapeHTML(r.jour||'') + '</td>'
|
||
+ '<td class="p-1">' + escapeHTML(r.heure||'') + '</td>'
|
||
+ '<td class="p-1">' + escapeHTML(r.duree_coupure||'') + '</td>'
|
||
+ '<td class="p-1">' + pb + '</td>'
|
||
+ '<td class="p-1">' + linkSrv + '</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 = '';
|
||
sortAsset = 0;
|
||
updateSortArrow();
|
||
renderTable();
|
||
});
|
||
thAsset.addEventListener('click', () => {
|
||
sortAsset = sortAsset === 1 ? -1 : (sortAsset === -1 ? 0 : 1);
|
||
updateSortArrow();
|
||
renderTable();
|
||
});
|
||
|
||
btnPre.addEventListener('click', () => {
|
||
const ids = Array.from(tbody.querySelectorAll('input.row-cb:checked')).map(cb => cb.dataset.serverId).filter(x => x);
|
||
alert('Pré-patching à brancher (étape 2) — ' + ids.length + ' serveur(s) résolu(s) en base.');
|
||
});
|
||
btnPatch.addEventListener('click', () => {
|
||
const ids = Array.from(tbody.querySelectorAll('input.row-cb:checked')).map(cb => cb.dataset.serverId).filter(x => x);
|
||
alert('Patching by-step à brancher (étape 3) — ' + ids.length + ' serveur(s) résolu(s) en base.');
|
||
});
|
||
})();
|
||
</script>
|
||
{% endif %}
|
||
{% endblock %}
|