- QuickWin: campagnes patching rapide avec exclusions générales (OS/reboot) et spécifiques (applicatifs) - Config serveurs: pagination, filtres (search, env, domain, zone, per_page), dry run, bulk edit - Détail campagne: pagination hprod/prod séparée, filtres (search, status, domain), section prod masquée si hprod non terminé - Auth: redirection qw_only vers /quickwin, profil lecture seule quickwin - Serveurs: filtres OS (Linux/Windows) et Owner (secops/ipop/na), exclusion EOL - Sidebar: lien QuickWin conditionné sur permission campaigns ou quickwin Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
272 lines
15 KiB
HTML
272 lines
15 KiB
HTML
{% extends "base.html" %}
|
|
{% block title %}QuickWin #{{ run.id }}{% endblock %}
|
|
|
|
{% macro qs(hp=hp_page, pp=p_page) -%}
|
|
?hp_page={{ hp }}&p_page={{ pp }}&per_page={{ per_page }}&search={{ filters.search or '' }}&status={{ filters.status or '' }}&domain={{ filters.domain or '' }}
|
|
{%- endmacro %}
|
|
|
|
{% block content %}
|
|
<div class="flex items-center justify-between mb-4">
|
|
<div>
|
|
<a href="/quickwin" class="text-xs text-gray-500 hover:text-gray-300">← Retour campagnes</a>
|
|
<h1 class="text-xl font-bold" style="color:#00d4ff">{{ run.label }}</h1>
|
|
<p class="text-xs text-gray-500">S{{ '%02d'|format(run.week_number) }} {{ run.year }} — Créé par {{ run.created_by_name or '?' }} — pas de reboot nécessaire</p>
|
|
</div>
|
|
<div class="flex gap-2 items-center">
|
|
{% if run.status == 'draft' %}
|
|
<span class="badge badge-gray" style="padding:4px 12px">Brouillon</span>
|
|
{% elif run.status == 'hprod_done' %}
|
|
<span class="badge badge-blue" style="padding:4px 12px">H-Prod terminé</span>
|
|
{% elif run.status == 'completed' %}
|
|
<span class="badge badge-green" style="padding:4px 12px">Terminé</span>
|
|
{% else %}
|
|
<span class="badge badge-yellow" style="padding:4px 12px">{{ run.status }}</span>
|
|
{% endif %}
|
|
<form method="post" action="/quickwin/{{ run.id }}/delete" onsubmit="return confirm('Supprimer cette campagne ?')">
|
|
<button class="btn-sm btn-danger" style="padding:4px 12px">Supprimer</button>
|
|
</form>
|
|
</div>
|
|
</div>
|
|
|
|
{% if msg %}
|
|
<div style="background:#1a5a2e;color:#8f8;padding:8px 16px;border-radius:6px;margin-bottom:12px;font-size:0.85rem">{{ msg }}</div>
|
|
{% endif %}
|
|
|
|
<!-- Stats -->
|
|
<div style="display:grid;grid-template-columns:repeat(6,1fr);gap:12px;margin-bottom:20px">
|
|
<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</div>
|
|
</div>
|
|
<div class="card p-3 text-center">
|
|
<div class="text-2xl font-bold" style="color:#00d4ff">{{ stats.hprod_total }}</div>
|
|
<div class="text-xs text-gray-500">H-Prod</div>
|
|
</div>
|
|
<div class="card p-3 text-center">
|
|
<div class="text-2xl font-bold" style="color:#ffcc00">{{ stats.prod_total }}</div>
|
|
<div class="text-xs text-gray-500">Prod</div>
|
|
</div>
|
|
<div class="card p-3 text-center">
|
|
<div class="text-2xl font-bold" style="color:#00ff88">{{ stats.patched }}</div>
|
|
<div class="text-xs text-gray-500">Patchés</div>
|
|
</div>
|
|
<div class="card p-3 text-center">
|
|
<div class="text-2xl font-bold" style="color:#ff3366">{{ stats.failed }}</div>
|
|
<div class="text-xs text-gray-500">KO</div>
|
|
</div>
|
|
<div class="card p-3 text-center">
|
|
<div class="text-2xl font-bold" style="color:#ff8800">{{ stats.reboot_count }}</div>
|
|
<div class="text-xs text-gray-500">Reboot</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 serveur..." style="width:200px">
|
|
<select name="status" onchange="this.form.submit()" style="width:140px">
|
|
<option value="">Tous statuts</option>
|
|
<option value="pending" {% if filters.status == 'pending' %}selected{% endif %}>En attente</option>
|
|
<option value="in_progress" {% if filters.status == 'in_progress' %}selected{% endif %}>En cours</option>
|
|
<option value="patched" {% if filters.status == 'patched' %}selected{% endif %}>Patché</option>
|
|
<option value="failed" {% if filters.status == 'failed' %}selected{% endif %}>KO</option>
|
|
<option value="excluded" {% if filters.status == 'excluded' %}selected{% endif %}>Exclu</option>
|
|
<option value="skipped" {% if filters.status == 'skipped' %}selected{% endif %}>Ignoré</option>
|
|
</select>
|
|
<select name="domain" onchange="this.form.submit()" style="width:160px">
|
|
<option value="">Tous domaines</option>
|
|
{% set all_entries_list = entries %}
|
|
{% set doms = all_entries_list|map(attribute='domaine')|select('string')|unique|sort %}
|
|
{% for d in doms %}<option value="{{ d }}" {% if filters.domain == d %}selected{% endif %}>{{ d }}</option>{% endfor %}
|
|
</select>
|
|
<select name="per_page" onchange="this.form.submit()" style="width:150px">
|
|
<option value="">Affichage / page</option>
|
|
{% for n in [14, 25, 50, 100] %}<option value="{{ n }}" {% if per_page == n %}selected{% endif %}>{{ n }} par page</option>{% endfor %}
|
|
</select>
|
|
<button type="submit" class="btn-primary" style="padding:4px 14px;font-size:0.8rem">Filtrer</button>
|
|
<a href="/quickwin/{{ run.id }}" class="text-xs text-gray-500 hover:text-gray-300">Reset</a>
|
|
</form>
|
|
|
|
<!-- Regle hprod first -->
|
|
{% if not prod_ok %}
|
|
<div class="card mb-4" style="border-left:3px solid #ff3366;padding:12px 16px">
|
|
<p style="color:#ff3366;font-size:0.85rem;font-weight:600">Hors-production d'abord : {{ stats.pending }} serveur(s) hprod en attente. Terminer le hprod avant de lancer le prod.</p>
|
|
</div>
|
|
{% endif %}
|
|
|
|
<!-- H-PROD -->
|
|
<div class="card mb-4">
|
|
<div class="p-3 flex items-center justify-between" style="border-bottom:1px solid #1e3a5f">
|
|
<h2 class="text-sm font-bold" style="color:#00d4ff">HORS-PRODUCTION ({{ hprod_total }})</h2>
|
|
<div class="flex gap-1 items-center">
|
|
<span class="badge badge-green">{{ hprod|selectattr('status','eq','patched')|list|length }} OK</span>
|
|
<span class="badge badge-red">{{ hprod|selectattr('status','eq','failed')|list|length }} KO</span>
|
|
<span class="badge badge-gray">{{ hprod|selectattr('status','eq','pending')|list|length }} en attente</span>
|
|
</div>
|
|
</div>
|
|
<div class="table-wrap">
|
|
<table class="table-cyber w-full">
|
|
<thead><tr>
|
|
<th class="px-2 py-2">Serveur</th>
|
|
<th class="px-2 py-2">Domaine</th>
|
|
<th class="px-2 py-2">Env</th>
|
|
<th class="px-2 py-2">Statut</th>
|
|
<th class="px-2 py-2">Exclusions gén.</th>
|
|
<th class="px-2 py-2">Exclusions spéc.</th>
|
|
<th class="px-2 py-2">Packages</th>
|
|
<th class="px-2 py-2">Date patch</th>
|
|
<th class="px-2 py-2">Reboot</th>
|
|
<th class="px-2 py-2">Notes</th>
|
|
</tr></thead>
|
|
<tbody>
|
|
{% for e in hprod %}
|
|
<tr data-id="{{ e.id }}">
|
|
<td class="px-2 py-2 font-bold" style="color:#00d4ff">{{ e.hostname }}</td>
|
|
<td class="px-2 py-2 text-gray-400">{{ e.domaine or '?' }}</td>
|
|
<td class="px-2 py-2 text-gray-400">{{ e.environnement or '?' }}</td>
|
|
<td class="px-2 py-2">
|
|
{% if e.status == 'patched' %}<span class="badge badge-green">Patché</span>
|
|
{% elif e.status == 'failed' %}<span class="badge badge-red">KO</span>
|
|
{% elif e.status == 'in_progress' %}<span class="badge badge-yellow">En cours</span>
|
|
{% elif e.status == 'excluded' %}<span class="badge badge-gray">Exclu</span>
|
|
{% elif e.status == 'skipped' %}<span class="badge badge-gray">Ignoré</span>
|
|
{% else %}<span class="badge badge-gray">En attente</span>{% endif %}
|
|
</td>
|
|
<td class="px-2 py-2 text-xs" style="color:#ffcc00;max-width:150px;overflow:hidden;text-overflow:ellipsis;white-space:nowrap" title="{{ e.general_excludes }}">
|
|
<span class="editable" data-id="{{ e.id }}" data-field="general_excludes">{{ e.general_excludes or '—' }}</span>
|
|
</td>
|
|
<td class="px-2 py-2 text-xs" style="color:#ff8800;max-width:150px;overflow:hidden;text-overflow:ellipsis;white-space:nowrap" title="{{ e.specific_excludes }}">
|
|
<span class="editable" data-id="{{ e.id }}" data-field="specific_excludes">{{ e.specific_excludes or '—' }}</span>
|
|
</td>
|
|
<td class="px-2 py-2 text-center">{{ e.patch_packages_count or '—' }}</td>
|
|
<td class="px-2 py-2 text-xs text-gray-500">{{ e.patch_date.strftime('%d/%m %H:%M') if e.patch_date else '—' }}</td>
|
|
<td class="px-2 py-2 text-center">{% if e.reboot_required %}<span style="color:#ff3366">OUI</span>{% else %}—{% endif %}</td>
|
|
<td class="px-2 py-2 text-xs text-gray-500">
|
|
<span class="editable" data-id="{{ e.id }}" data-field="notes">{{ e.notes or '—' }}</span>
|
|
</td>
|
|
</tr>
|
|
{% endfor %}
|
|
{% if not hprod %}<tr><td colspan="10" class="px-2 py-6 text-center text-gray-500">Aucun serveur hors-production{% if filters.search or filters.status or filters.domain %} (filtre actif){% endif %}</td></tr>{% endif %}
|
|
</tbody>
|
|
</table>
|
|
</div>
|
|
<!-- Pagination H-PROD -->
|
|
{% if hp_total_pages > 1 %}
|
|
<div class="flex justify-between items-center p-3 text-sm text-gray-500" style="border-top:1px solid #1e3a5f">
|
|
<span>Page {{ hp_page }} / {{ hp_total_pages }} — {{ hprod_total }} serveur(s)</span>
|
|
<div class="flex gap-2">
|
|
{% if hp_page > 1 %}<a href="{{ qs(hp=hp_page - 1, pp=p_page) }}" class="btn-sm bg-cyber-border text-gray-300">Précédent</a>{% endif %}
|
|
{% if hp_page < hp_total_pages %}<a href="{{ qs(hp=hp_page + 1, pp=p_page) }}" class="btn-sm bg-cyber-border text-gray-300">Suivant</a>{% endif %}
|
|
</div>
|
|
</div>
|
|
{% endif %}
|
|
</div>
|
|
|
|
<!-- PROD -->
|
|
{% if prod_ok %}
|
|
<div class="card mb-4">
|
|
<div class="p-3 flex items-center justify-between" style="border-bottom:1px solid #1e3a5f">
|
|
<h2 class="text-sm font-bold" style="color:#ffcc00">PRODUCTION ({{ prod_total }})</h2>
|
|
<div class="flex gap-1 items-center">
|
|
<span class="badge badge-green">{{ prod|selectattr('status','eq','patched')|list|length }} OK</span>
|
|
<span class="badge badge-red">{{ prod|selectattr('status','eq','failed')|list|length }} KO</span>
|
|
<span class="badge badge-gray">{{ prod|selectattr('status','eq','pending')|list|length }} en attente</span>
|
|
</div>
|
|
</div>
|
|
<div class="table-wrap">
|
|
<table class="table-cyber w-full">
|
|
<thead><tr>
|
|
<th class="px-2 py-2">Serveur</th>
|
|
<th class="px-2 py-2">Domaine</th>
|
|
<th class="px-2 py-2">Env</th>
|
|
<th class="px-2 py-2">Statut</th>
|
|
<th class="px-2 py-2">Exclusions gén.</th>
|
|
<th class="px-2 py-2">Exclusions spéc.</th>
|
|
<th class="px-2 py-2">Packages</th>
|
|
<th class="px-2 py-2">Date patch</th>
|
|
<th class="px-2 py-2">Reboot</th>
|
|
<th class="px-2 py-2">Notes</th>
|
|
</tr></thead>
|
|
<tbody>
|
|
{% for e in prod %}
|
|
<tr data-id="{{ e.id }}">
|
|
<td class="px-2 py-2 font-bold" style="color:#ffcc00">{{ e.hostname }}</td>
|
|
<td class="px-2 py-2 text-gray-400">{{ e.domaine or '?' }}</td>
|
|
<td class="px-2 py-2 text-gray-400">{{ e.environnement or '?' }}</td>
|
|
<td class="px-2 py-2">
|
|
{% if e.status == 'patched' %}<span class="badge badge-green">Patché</span>
|
|
{% elif e.status == 'failed' %}<span class="badge badge-red">KO</span>
|
|
{% elif e.status == 'in_progress' %}<span class="badge badge-yellow">En cours</span>
|
|
{% elif e.status == 'excluded' %}<span class="badge badge-gray">Exclu</span>
|
|
{% else %}<span class="badge badge-gray">En attente</span>{% endif %}
|
|
</td>
|
|
<td class="px-2 py-2 text-xs" style="color:#ffcc00;max-width:150px;overflow:hidden;text-overflow:ellipsis;white-space:nowrap">
|
|
<span class="editable" data-id="{{ e.id }}" data-field="general_excludes">{{ e.general_excludes or '—' }}</span>
|
|
</td>
|
|
<td class="px-2 py-2 text-xs" style="color:#ff8800;max-width:150px;overflow:hidden;text-overflow:ellipsis;white-space:nowrap">
|
|
<span class="editable" data-id="{{ e.id }}" data-field="specific_excludes">{{ e.specific_excludes or '—' }}</span>
|
|
</td>
|
|
<td class="px-2 py-2 text-center">{{ e.patch_packages_count or '—' }}</td>
|
|
<td class="px-2 py-2 text-xs text-gray-500">{{ e.patch_date.strftime('%d/%m %H:%M') if e.patch_date else '—' }}</td>
|
|
<td class="px-2 py-2 text-center">{% if e.reboot_required %}<span style="color:#ff3366">OUI</span>{% else %}—{% endif %}</td>
|
|
<td class="px-2 py-2 text-xs text-gray-500">
|
|
<span class="editable" data-id="{{ e.id }}" data-field="notes">{{ e.notes or '—' }}</span>
|
|
</td>
|
|
</tr>
|
|
{% endfor %}
|
|
{% if not prod %}<tr><td colspan="10" class="px-2 py-6 text-center text-gray-500">Aucun serveur production{% if filters.search or filters.status or filters.domain %} (filtre actif){% endif %}</td></tr>{% endif %}
|
|
</tbody>
|
|
</table>
|
|
</div>
|
|
<!-- Pagination PROD -->
|
|
{% if p_total_pages > 1 %}
|
|
<div class="flex justify-between items-center p-3 text-sm text-gray-500" style="border-top:1px solid #1e3a5f">
|
|
<span>Page {{ p_page }} / {{ p_total_pages }} — {{ prod_total }} serveur(s)</span>
|
|
<div class="flex gap-2">
|
|
{% if p_page > 1 %}<a href="{{ qs(hp=hp_page, pp=p_page - 1) }}" class="btn-sm bg-cyber-border text-gray-300">Précédent</a>{% endif %}
|
|
{% if p_page < p_total_pages %}<a href="{{ qs(hp=hp_page, pp=p_page + 1) }}" class="btn-sm bg-cyber-border text-gray-300">Suivant</a>{% endif %}
|
|
</div>
|
|
</div>
|
|
{% endif %}
|
|
</div>
|
|
{% endif %}
|
|
|
|
{% if run.notes %}
|
|
<div class="card p-4 mb-4">
|
|
<h3 class="text-xs font-bold text-gray-500 mb-2">NOTES</h3>
|
|
<p class="text-sm text-gray-300">{{ run.notes }}</p>
|
|
</div>
|
|
{% endif %}
|
|
|
|
<script>
|
|
document.querySelectorAll('.editable').forEach(el => {
|
|
el.style.cursor = 'pointer';
|
|
el.addEventListener('dblclick', function() {
|
|
const field = this.dataset.field;
|
|
const id = this.dataset.id;
|
|
const current = this.textContent.trim() === '—' ? '' : this.textContent.trim();
|
|
const input = document.createElement('input');
|
|
input.value = current;
|
|
input.style.cssText = 'background:#0a0e17;border:1px solid #00d4ff;color:#fff;padding:2px 6px;border-radius:4px;font-size:0.75rem;width:100%';
|
|
this.textContent = '';
|
|
this.appendChild(input);
|
|
input.focus();
|
|
input.select();
|
|
const save = () => {
|
|
const val = input.value.trim();
|
|
this.textContent = val || '—';
|
|
fetch('/api/quickwin/entry/update', {
|
|
method: 'POST',
|
|
headers: {'Content-Type': 'application/json'},
|
|
body: JSON.stringify({id: parseInt(id), field: field, value: val})
|
|
});
|
|
};
|
|
input.addEventListener('blur', save);
|
|
input.addEventListener('keydown', e => {
|
|
if (e.key === 'Enter') { e.preventDefault(); input.blur(); }
|
|
if (e.key === 'Escape') { this.textContent = current || '—'; }
|
|
});
|
|
});
|
|
});
|
|
</script>
|
|
{% endblock %}
|