Add page Historique patching : vue unifiee import xlsx + campagnes + quickwin

This commit is contained in:
Pierre & Lumière 2026-04-17 08:47:25 +00:00
parent c9890a274f
commit 4b1794d4d1
3 changed files with 481 additions and 252 deletions

View File

@ -1,4 +1,4 @@
"""Router Historique patching — vue de patch_history""" """Router Historique patching — vue unifiee patch_history + quickwin_entries"""
from fastapi import APIRouter, Request, Depends, Query from fastapi import APIRouter, Request, Depends, Query
from fastapi.responses import HTMLResponse, RedirectResponse from fastapi.responses import HTMLResponse, RedirectResponse
from fastapi.templating import Jinja2Templates from fastapi.templating import Jinja2Templates
@ -13,7 +13,8 @@ templates = Jinja2Templates(directory="app/templates")
@router.get("/patching/historique", response_class=HTMLResponse) @router.get("/patching/historique", response_class=HTMLResponse)
async def patch_history_page(request: Request, db=Depends(get_db), async def patch_history_page(request: Request, db=Depends(get_db),
year: int = Query(None), week: int = Query(None), year: int = Query(None), week: int = Query(None),
hostname: str = Query(None), page: int = Query(1)): hostname: str = Query(None), source: str = Query(None),
page: int = Query(1)):
user = get_current_user(request) user = get_current_user(request)
if not user: if not user:
return RedirectResponse(url="/login") return RedirectResponse(url="/login")
@ -25,14 +26,27 @@ async def patch_history_page(request: Request, db=Depends(get_db),
per_page = 100 per_page = 100
offset = (page - 1) * per_page offset = (page - 1) * per_page
# KPIs
kpis = {} kpis = {}
kpis["total"] = db.execute(text( kpis["total_ph"] = db.execute(text(
"SELECT COUNT(*) FROM patch_history WHERE EXTRACT(YEAR FROM date_patch)=:y" "SELECT COUNT(*) FROM patch_history WHERE EXTRACT(YEAR FROM date_patch)=:y"
), {"y": year}).scalar() ), {"y": year}).scalar()
kpis["servers"] = db.execute(text( kpis["total_qw"] = db.execute(text("""
"SELECT COUNT(DISTINCT server_id) FROM patch_history WHERE EXTRACT(YEAR FROM date_patch)=:y" SELECT COUNT(*) FROM quickwin_entries qe
), {"y": year}).scalar() JOIN quickwin_runs qr ON qe.run_id=qr.id
WHERE qe.status='patched' AND qr.year=:y
"""), {"y": year}).scalar()
kpis["total"] = kpis["total_ph"] + kpis["total_qw"]
kpis["servers"] = db.execute(text("""
SELECT COUNT(DISTINCT sid) FROM (
SELECT server_id AS sid FROM patch_history WHERE EXTRACT(YEAR FROM date_patch)=:y
UNION
SELECT qe.server_id FROM quickwin_entries qe
JOIN quickwin_runs qr ON qe.run_id=qr.id
WHERE qe.status='patched' AND qr.year=:y
) u
"""), {"y": year}).scalar()
kpis["patchables"] = db.execute(text( kpis["patchables"] = db.execute(text(
"SELECT COUNT(*) FROM servers WHERE etat='Production' AND patch_os_owner='secops'" "SELECT COUNT(*) FROM servers WHERE etat='Production' AND patch_os_owner='secops'"
)).scalar() )).scalar()
@ -41,56 +55,131 @@ async def patch_history_page(request: Request, db=Depends(get_db),
WHERE s.etat='Production' AND s.patch_os_owner='secops' WHERE s.etat='Production' AND s.patch_os_owner='secops'
AND NOT EXISTS (SELECT 1 FROM patch_history ph AND NOT EXISTS (SELECT 1 FROM patch_history ph
WHERE ph.server_id=s.id AND EXTRACT(YEAR FROM ph.date_patch)=:y) WHERE ph.server_id=s.id AND EXTRACT(YEAR FROM ph.date_patch)=:y)
AND NOT EXISTS (SELECT 1 FROM quickwin_entries qe
JOIN quickwin_runs qr ON qe.run_id=qr.id
WHERE qe.server_id=s.id AND qe.status='patched' AND qr.year=:y)
"""), {"y": year}).scalar() """), {"y": year}).scalar()
kpis["coverage_pct"] = round((kpis["servers"] / kpis["patchables"] * 100), 1) if kpis["patchables"] else 0 kpis["coverage_pct"] = round((kpis["servers"] / kpis["patchables"] * 100), 1) if kpis["patchables"] else 0
# Par semaine by_source = {}
by_source["import"] = db.execute(text(
"SELECT COUNT(*) FROM patch_history WHERE campaign_id IS NULL AND EXTRACT(YEAR FROM date_patch)=:y"
), {"y": year}).scalar()
by_source["standard"] = db.execute(text("""
SELECT COUNT(*) FROM patch_history ph
JOIN campaigns c ON ph.campaign_id=c.id
WHERE c.campaign_type='standard' AND EXTRACT(YEAR FROM ph.date_patch)=:y
"""), {"y": year}).scalar()
by_source["quickwin"] = kpis["total_qw"]
by_week = db.execute(text(""" by_week = db.execute(text("""
SELECT TO_CHAR(date_patch, 'IW') as week_num, SELECT week_num, SUM(cnt)::int as servers FROM (
COUNT(DISTINCT server_id) as servers SELECT TO_CHAR(date_patch, 'IW') as week_num, COUNT(DISTINCT server_id) as cnt
FROM patch_history FROM patch_history
WHERE EXTRACT(YEAR FROM date_patch)=:y WHERE EXTRACT(YEAR FROM date_patch)=:y
GROUP BY TO_CHAR(date_patch, 'IW') GROUP BY TO_CHAR(date_patch, 'IW')
ORDER BY week_num UNION ALL
SELECT LPAD(qr.week_number::text, 2, '0') as week_num, COUNT(DISTINCT qe.server_id) as cnt
FROM quickwin_entries qe
JOIN quickwin_runs qr ON qe.run_id=qr.id
WHERE qe.status='patched' AND qr.year=:y
GROUP BY qr.week_number
) u GROUP BY week_num ORDER BY week_num
"""), {"y": year}).fetchall() """), {"y": year}).fetchall()
# Filtres where_ph = ["EXTRACT(YEAR FROM ph.date_patch)=:y"]
where = ["EXTRACT(YEAR FROM ph.date_patch)=:y"] where_qw = ["qr.year=:y", "qe.status='patched'"]
params = {"y": year, "limit": per_page, "offset": offset} params = {"y": year, "limit": per_page, "offset": offset}
if week: if week:
where.append("EXTRACT(WEEK FROM ph.date_patch)=:wk") where_ph.append("EXTRACT(WEEK FROM ph.date_patch)=:wk")
where_qw.append("qr.week_number=:wk")
params["wk"] = week params["wk"] = week
if hostname: if hostname:
where.append("s.hostname ILIKE :h") where_ph.append("s.hostname ILIKE :h")
where_qw.append("s.hostname ILIKE :h")
params["h"] = f"%{hostname}%" params["h"] = f"%{hostname}%"
wc = " AND ".join(where) if source == "import":
where_ph.append("ph.campaign_id IS NULL")
elif source == "standard":
where_ph.append("c.campaign_type='standard'")
total_filtered = db.execute(text( wc_ph = " AND ".join(where_ph)
f"SELECT COUNT(*) FROM patch_history ph JOIN servers s ON ph.server_id=s.id WHERE {wc}" wc_qw = " AND ".join(where_qw)
), params).scalar()
skip_qw = source in ("import", "standard")
skip_ph = source == "quickwin"
count_parts = []
if not skip_ph:
count_parts.append(f"""
SELECT COUNT(*) FROM patch_history ph
JOIN servers s ON ph.server_id=s.id
LEFT JOIN campaigns c ON ph.campaign_id=c.id
WHERE {wc_ph}
""")
if not skip_qw:
count_parts.append(f"""
SELECT COUNT(*) FROM quickwin_entries qe
JOIN quickwin_runs qr ON qe.run_id=qr.id
JOIN servers s ON qe.server_id=s.id
WHERE {wc_qw}
""")
count_sql = " + ".join(f"({p})" for p in count_parts) if count_parts else "0"
total_filtered = db.execute(text(f"SELECT {count_sql}"), params).scalar()
union_parts = []
if not skip_ph:
union_parts.append(f"""
SELECT s.id as sid, s.hostname, s.os_family, s.etat,
ph.date_patch, ph.status, ph.notes,
z.name as zone,
CASE WHEN ph.campaign_id IS NULL THEN 'import'
ELSE COALESCE(c.campaign_type, 'standard') END as source_type,
c.id as campaign_id, c.label as campaign_label,
NULL::int as run_id, NULL::text as run_label
FROM patch_history ph
JOIN servers s ON ph.server_id=s.id
LEFT JOIN zones z ON s.zone_id=z.id
LEFT JOIN campaigns c ON ph.campaign_id=c.id
WHERE {wc_ph}
""")
if not skip_qw:
union_parts.append(f"""
SELECT s.id as sid, s.hostname, s.os_family, s.etat,
qe.patch_date as date_patch, qe.status, qe.notes,
z.name as zone,
'quickwin' as source_type,
NULL::int as campaign_id, NULL::text as campaign_label,
qr.id as run_id, qr.label as run_label
FROM quickwin_entries qe
JOIN quickwin_runs qr ON qe.run_id=qr.id
JOIN servers s ON qe.server_id=s.id
LEFT JOIN zones z ON s.zone_id=z.id
WHERE {wc_qw}
""")
if not union_parts:
union_parts.append("SELECT NULL::int as sid, NULL as hostname, NULL as os_family, NULL as etat, NULL::timestamptz as date_patch, NULL as status, NULL as notes, NULL as zone, NULL as source_type, NULL::int as campaign_id, NULL as campaign_label, NULL::int as run_id, NULL as run_label WHERE 1=0")
union_sql = " UNION ALL ".join(union_parts)
rows = db.execute(text(f""" rows = db.execute(text(f"""
SELECT s.id as sid, s.hostname, s.os_family, s.etat, SELECT * FROM ({union_sql}) combined
ph.date_patch, ph.status, ph.notes, ORDER BY date_patch DESC NULLS LAST
z.name as zone
FROM patch_history ph
JOIN servers s ON ph.server_id = s.id
LEFT JOIN zones z ON s.zone_id = z.id
WHERE {wc}
ORDER BY ph.date_patch DESC
LIMIT :limit OFFSET :offset LIMIT :limit OFFSET :offset
"""), params).fetchall() """), params).fetchall()
# Années dispo
years = db.execute(text(""" years = db.execute(text("""
SELECT DISTINCT EXTRACT(YEAR FROM date_patch)::int as y SELECT DISTINCT y FROM (
FROM patch_history ORDER BY y DESC SELECT EXTRACT(YEAR FROM date_patch)::int as y FROM patch_history
UNION
SELECT year as y FROM quickwin_runs
) u ORDER BY y DESC
""")).fetchall() """)).fetchall()
return templates.TemplateResponse("patch_history.html", { return templates.TemplateResponse("patch_history.html", {
"request": request, "user": user, "app_name": APP_NAME, "request": request, "user": user, "app_name": APP_NAME,
"kpis": kpis, "by_week": by_week, "rows": rows, "kpis": kpis, "by_week": by_week, "by_source": by_source,
"year": year, "week": week, "hostname": hostname, "rows": rows, "year": year, "week": week, "hostname": hostname,
"page": page, "per_page": per_page, "total_filtered": total_filtered, "source": source, "page": page, "per_page": per_page,
"years": [y.y for y in years], "total_filtered": total_filtered, "years": [y.y for y in years],
}) })

View File

@ -89,6 +89,7 @@
{% if p.campaigns %}<a href="/campaigns" class="block px-3 py-1.5 rounded-md text-xs hover:bg-cyber-border/30 {% if 'campaigns' in path and 'assignments' not in path %}bg-cyber-border/30 text-cyber-accent{% else %}text-gray-400{% endif %} pl-6">Campagnes</a>{% endif %} {% if p.campaigns %}<a href="/campaigns" class="block px-3 py-1.5 rounded-md text-xs hover:bg-cyber-border/30 {% if 'campaigns' in path and 'assignments' not in path %}bg-cyber-border/30 text-cyber-accent{% else %}text-gray-400{% endif %} pl-6">Campagnes</a>{% endif %}
{% if p.servers in ('edit','admin') or p.campaigns in ('edit','admin') or p.quickwin in ('edit','admin') %}<a href="/patching/config-exclusions" class="block px-3 py-1.5 rounded-md text-xs hover:bg-cyber-border/30 {% if 'config-exclusions' in path %}bg-cyber-border/30 text-cyber-accent{% else %}text-gray-400{% endif %} pl-6">Config exclusions</a>{% endif %} {% if p.servers in ('edit','admin') or p.campaigns in ('edit','admin') or p.quickwin in ('edit','admin') %}<a href="/patching/config-exclusions" class="block px-3 py-1.5 rounded-md text-xs hover:bg-cyber-border/30 {% if 'config-exclusions' in path %}bg-cyber-border/30 text-cyber-accent{% else %}text-gray-400{% endif %} pl-6">Config exclusions</a>{% endif %}
{% if p.campaigns in ('edit','admin') or p.quickwin in ('edit','admin') %}<a href="/patching/validations" class="block px-3 py-1.5 rounded-md text-xs hover:bg-cyber-border/30 {% if '/patching/validations' in path %}bg-cyber-border/30 text-cyber-accent{% else %}text-gray-400{% endif %} pl-6">Validations</a>{% endif %} {% if p.campaigns in ('edit','admin') or p.quickwin in ('edit','admin') %}<a href="/patching/validations" class="block px-3 py-1.5 rounded-md text-xs hover:bg-cyber-border/30 {% if '/patching/validations' in path %}bg-cyber-border/30 text-cyber-accent{% else %}text-gray-400{% endif %} pl-6">Validations</a>{% endif %}
<a href="/patching/historique" class="block px-3 py-1.5 rounded-md text-xs hover:bg-cyber-border/30 {% if '/patching/historique' in path %}bg-cyber-border/30 text-cyber-accent{% else %}text-gray-400{% endif %} pl-6">Historique</a>
{# Quickwin sous-groupe #} {# Quickwin sous-groupe #}
{% if p.campaigns or p.quickwin %} {% if p.campaigns or p.quickwin %}

View File

@ -0,0 +1,139 @@
{% extends 'base.html' %}
{% block title %}Historique patching{% endblock %}
{% block content %}
<div class="flex justify-between items-center mb-4">
<div>
<h2 class="text-xl font-bold text-cyber-accent">Historique patching</h2>
<p class="text-xs text-gray-500 mt-1">Vue unifiée : imports xlsx + campagnes standard + QuickWin.</p>
</div>
<div class="flex gap-2">
{% for y in years %}<a href="?year={{ y }}" class="btn-sm {% if y == year %}bg-cyber-accent text-black{% else %}bg-cyber-border text-gray-300{% endif %} px-3 py-1 text-xs">{{ y }}</a>{% endfor %}
</div>
</div>
<!-- KPIs -->
<div style="display:flex;flex-wrap:wrap;gap:8px;margin-bottom:16px;">
<div class="card p-3 text-center" style="flex:1;min-width:0">
<div class="text-2xl font-bold text-cyber-accent">{{ kpis.total }}</div>
<div class="text-xs text-gray-500">Events {{ year }}</div>
</div>
<div class="card p-3 text-center" style="flex:1;min-width:0">
<div class="text-2xl font-bold text-cyber-green">{{ kpis.servers }}</div>
<div class="text-xs text-gray-500">Serveurs distincts</div>
</div>
<div class="card p-3 text-center" style="flex:1;min-width:0">
<div class="text-2xl font-bold text-white">{{ kpis.patchables }}</div>
<div class="text-xs text-gray-500">Patchables SecOps</div>
</div>
<div class="card p-3 text-center" style="flex:1;min-width:0">
<div class="text-2xl font-bold {% if kpis.never > 0 %}text-cyber-red{% else %}text-cyber-green{% endif %}">{{ kpis.never }}</div>
<div class="text-xs text-gray-500">Jamais patchés {{ year }}</div>
</div>
<div class="card p-3 text-center" style="flex:1;min-width:0">
<div class="text-2xl font-bold {% if kpis.coverage_pct >= 80 %}text-cyber-green{% elif kpis.coverage_pct >= 50 %}text-cyber-yellow{% else %}text-cyber-red{% endif %}">{{ kpis.coverage_pct }}%</div>
<div class="text-xs text-gray-500">Couverture</div>
</div>
</div>
<!-- Répartition par source -->
<div style="display:flex;flex-wrap:wrap;gap:8px;margin-bottom:16px;">
<a href="?year={{ year }}&source=import" class="card p-3 text-center hover:border-cyber-accent" style="flex:1;min-width:0">
<div class="text-2xl font-bold text-blue-400">{{ by_source.import }}</div>
<div class="text-xs text-gray-500">Import xlsx</div>
</a>
<a href="?year={{ year }}&source=standard" class="card p-3 text-center hover:border-cyber-accent" style="flex:1;min-width:0">
<div class="text-2xl font-bold text-cyan-400">{{ by_source.standard }}</div>
<div class="text-xs text-gray-500">Campagnes standard</div>
</a>
<a href="?year={{ year }}&source=quickwin" class="card p-3 text-center hover:border-cyber-accent" style="flex:1;min-width:0">
<div class="text-2xl font-bold text-purple-400">{{ by_source.quickwin }}</div>
<div class="text-xs text-gray-500">QuickWin</div>
</a>
</div>
<!-- Graphique par semaine -->
{% if by_week %}
<div class="card p-4 mb-4">
<h3 class="text-sm font-bold text-cyber-accent mb-3">Serveurs patchés par semaine ({{ year }})</h3>
<div style="display:flex;align-items:flex-end;gap:2px;height:120px;">
{% set max_val = by_week|map(attribute='servers')|max %}
{% for w in by_week %}
<a href="?year={{ year }}&week={{ w.week_num|int }}" title="S{{ w.week_num }} : {{ w.servers }} serveur(s)" style="flex:1;display:flex;flex-direction:column;align-items:center;min-width:0;">
<div style="width:100%;background:{% if week and week == w.week_num|int %}#00ff88{% elif w.servers >= 30 %}#06b6d4{% elif w.servers >= 15 %}#0e7490{% else %}#164e63{% endif %};border-radius:2px 2px 0 0;height:{{ (w.servers / max_val * 100)|int if max_val else 0 }}px;min-height:2px;"></div>
<span style="font-size:8px;color:#6b7280;margin-top:2px;">{{ w.week_num }}</span>
</a>
{% endfor %}
</div>
</div>
{% endif %}
<!-- Filtres -->
<div class="card p-3 mb-4">
<form method="GET" class="flex gap-2 items-center flex-wrap">
<input type="hidden" name="year" value="{{ year }}">
<select name="week" class="text-xs py-1 px-2">
<option value="">Toutes semaines</option>
{% for w in by_week %}<option value="{{ w.week_num|int }}" {% if week == w.week_num|int %}selected{% endif %}>S{{ w.week_num }} ({{ w.servers }})</option>{% endfor %}
</select>
<select name="source" class="text-xs py-1 px-2">
<option value="">Toutes sources</option>
<option value="import" {% if source == 'import' %}selected{% endif %}>Import xlsx</option>
<option value="standard" {% if source == 'standard' %}selected{% endif %}>Campagne standard</option>
<option value="quickwin" {% if source == 'quickwin' %}selected{% endif %}>QuickWin</option>
</select>
<input type="text" name="hostname" value="{{ hostname or '' }}" placeholder="Hostname..." class="text-xs py-1 px-2" style="width:180px">
<button type="submit" class="btn-primary px-3 py-1 text-xs">Filtrer</button>
<a href="/patching/historique?year={{ year }}" class="text-xs text-gray-500 hover:text-cyber-accent">Reset</a>
<span class="text-xs text-gray-500 ml-auto">{{ total_filtered }} résultat{{ 's' if total_filtered != 1 }}</span>
</form>
</div>
<!-- Tableau -->
<div class="card overflow-x-auto">
<table class="w-full table-cyber text-xs">
<thead><tr>
<th class="p-2 text-left">Hostname</th>
<th class="p-2 text-center">OS</th>
<th class="p-2 text-center">Zone</th>
<th class="p-2 text-center">État</th>
<th class="p-2 text-center">Date</th>
<th class="p-2 text-center">Semaine</th>
<th class="p-2 text-center">Source</th>
<th class="p-2 text-center">Status</th>
<th class="p-2 text-left">Notes</th>
</tr></thead>
<tbody>
{% for r in rows %}
<tr class="border-t border-cyber-border/30 hover:bg-cyber-hover/20">
<td class="p-2 font-mono text-cyber-accent"><a href="/servers/{{ r.sid }}" class="hover:underline">{{ r.hostname }}</a></td>
<td class="p-2 text-center text-gray-400">{{ (r.os_family or '-')[:6] }}</td>
<td class="p-2 text-center"><span class="badge {% if r.zone == 'DMZ' %}badge-red{% else %}badge-gray{% endif %}">{{ r.zone or '-' }}</span></td>
<td class="p-2 text-center"><span class="badge {% if r.etat == 'Production' %}badge-green{% else %}badge-yellow{% endif %}">{{ (r.etat or '-')[:6] }}</span></td>
<td class="p-2 text-center text-gray-300">{{ r.date_patch.strftime('%Y-%m-%d %H:%M') if r.date_patch else '-' }}</td>
<td class="p-2 text-center text-gray-400">{% if r.date_patch %}S{{ r.date_patch.strftime('%V') }}{% else %}-{% endif %}</td>
<td class="p-2 text-center">
{% if r.source_type == 'import' %}<span class="badge" style="background:#1e3a5f;color:#60a5fa;">xlsx</span>
{% elif r.source_type == 'standard' %}<a href="/campaigns/{{ r.campaign_id }}" class="badge" style="background:#164e63;color:#22d3ee;text-decoration:none">{{ r.campaign_label or 'Campagne' }}</a>
{% elif r.source_type == 'quickwin' %}<a href="/quickwin/{{ r.run_id }}" class="badge" style="background:#3b1f5e;color:#c084fc;text-decoration:none">{{ r.run_label or 'QuickWin' }}</a>
{% else %}<span class="badge badge-gray">{{ r.source_type or '?' }}</span>{% endif %}
</td>
<td class="p-2 text-center"><span class="badge {% if r.status == 'ok' or r.status == 'patched' %}badge-green{% elif r.status == 'ko' or r.status == 'failed' %}badge-red{% else %}badge-yellow{% endif %}">{{ r.status }}</span></td>
<td class="p-2 text-gray-400" style="max-width:200px;overflow:hidden;text-overflow:ellipsis;white-space:nowrap" title="{{ r.notes or '' }}">{{ (r.notes or '-')[:50] }}</td>
</tr>
{% endfor %}
{% if not rows %}
<tr><td colspan="9" class="p-6 text-center text-gray-500">Aucun event de patching pour ce filtre</td></tr>
{% endif %}
</tbody>
</table>
</div>
<!-- Pagination -->
{% if total_filtered > per_page %}
<div class="flex justify-center gap-2 mt-4">
{% if page > 1 %}<a href="?year={{ year }}{% if week %}&week={{ week }}{% endif %}{% if source %}&source={{ source }}{% endif %}{% if hostname %}&hostname={{ hostname }}{% endif %}&page={{ page - 1 }}" class="btn-sm bg-cyber-border text-gray-300 px-3 py-1 text-xs">← Précédent</a>{% endif %}
<span class="text-xs text-gray-500 py-1">Page {{ page }} / {{ ((total_filtered - 1) // per_page) + 1 }}</span>
{% if page * per_page < total_filtered %}<a href="?year={{ year }}{% if week %}&week={{ week }}{% endif %}{% if source %}&source={{ source }}{% endif %}{% if hostname %}&hostname={{ hostname }}{% endif %}&page={{ page + 1 }}" class="btn-sm bg-cyber-border text-gray-300 px-3 py-1 text-xs">Suivant →</a>{% endif %}
</div>
{% endif %}
{% endblock %}