Module Patching: KPIs, graphe semaines, domaine, detail par serveur, tri, filtre, 2025/2026
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
parent
24c1db2aca
commit
dea2889746
@ -224,6 +224,112 @@ async def audit_full_import(request: Request, db=Depends(get_db),
|
||||
)
|
||||
|
||||
|
||||
@router.get("/audit-full/patching", response_class=HTMLResponse)
|
||||
async def audit_full_patching(request: Request, db=Depends(get_db)):
|
||||
user = get_current_user(request)
|
||||
if not user:
|
||||
return RedirectResponse(url="/login")
|
||||
|
||||
year = int(request.query_params.get("year", "2026"))
|
||||
search = request.query_params.get("q", "").strip()
|
||||
domain = request.query_params.get("domain", "")
|
||||
page = int(request.query_params.get("page", "1"))
|
||||
sort = request.query_params.get("sort", "hostname")
|
||||
sort_dir = request.query_params.get("dir", "asc")
|
||||
per_page = 30
|
||||
|
||||
yr_count = "patch_count_2026" if year == 2026 else "patch_count_2025"
|
||||
yr_weeks = "patch_weeks_2026" if year == 2026 else "patch_weeks_2025"
|
||||
|
||||
kpis = db.execute(text(
|
||||
f"SELECT COUNT(*) as total,"
|
||||
f" COUNT(*) FILTER (WHERE {yr_count} >= 1) as patched,"
|
||||
f" COUNT(*) FILTER (WHERE {yr_count} = 1) as once,"
|
||||
f" COUNT(*) FILTER (WHERE {yr_count} >= 2) as twice,"
|
||||
f" COUNT(*) FILTER (WHERE {yr_count} >= 3) as thrice,"
|
||||
f" COUNT(*) FILTER (WHERE {yr_count} = 0 OR {yr_count} IS NULL) as never"
|
||||
f" FROM server_audit_full WHERE status = 'ok'"
|
||||
f" AND id IN (SELECT DISTINCT ON (hostname) id FROM server_audit_full WHERE status = 'ok' ORDER BY hostname, audit_date DESC)"
|
||||
)).fetchone()
|
||||
|
||||
patch_by_domain = db.execute(text(
|
||||
f"SELECT d.name as domain, d.code,"
|
||||
f" COUNT(DISTINCT saf.hostname) as total,"
|
||||
f" COUNT(DISTINCT saf.hostname) FILTER (WHERE saf.{yr_count} >= 1) as patched,"
|
||||
f" COUNT(DISTINCT saf.hostname) FILTER (WHERE saf.{yr_count} >= 2) as twice,"
|
||||
f" COUNT(DISTINCT saf.hostname) FILTER (WHERE saf.{yr_count} = 0 OR saf.{yr_count} IS NULL) as never"
|
||||
f" FROM server_audit_full saf JOIN servers s ON saf.server_id = s.id"
|
||||
f" JOIN domain_environments de ON s.domain_env_id = de.id JOIN domains d ON de.domain_id = d.id"
|
||||
f" WHERE saf.status = 'ok'"
|
||||
f" AND saf.id IN (SELECT DISTINCT ON (hostname) id FROM server_audit_full WHERE status = 'ok' ORDER BY hostname, audit_date DESC)"
|
||||
f" GROUP BY d.name, d.code, d.display_order ORDER BY d.display_order"
|
||||
)).fetchall()
|
||||
|
||||
patch_weekly = []
|
||||
if year == 2026:
|
||||
patch_weekly = db.execute(text(
|
||||
"SELECT last_patch_week as week, COUNT(*) as cnt FROM server_audit_full"
|
||||
" WHERE status = 'ok' AND last_patch_year = 2026 AND last_patch_week IS NOT NULL"
|
||||
" AND id IN (SELECT DISTINCT ON (hostname) id FROM server_audit_full WHERE status = 'ok' ORDER BY hostname, audit_date DESC)"
|
||||
" GROUP BY last_patch_week ORDER BY last_patch_week"
|
||||
)).fetchall()
|
||||
|
||||
all_domains = db.execute(text("SELECT code, name, 'domain' as type FROM domains ORDER BY name")).fetchall()
|
||||
all_zones = db.execute(text("SELECT name as code, name, 'zone' as type FROM zones ORDER BY name")).fetchall()
|
||||
|
||||
servers = db.execute(text(
|
||||
f"SELECT DISTINCT ON (saf.hostname) saf.id, saf.hostname, saf.os_release,"
|
||||
f" saf.last_patch_date, saf.last_patch_week, saf.last_patch_year,"
|
||||
f" saf.{yr_count} as patch_count, saf.{yr_weeks} as patch_weeks,"
|
||||
f" d.name as domain, e.name as env, z.name as zone"
|
||||
f" FROM server_audit_full saf"
|
||||
f" LEFT JOIN servers s ON saf.server_id = s.id"
|
||||
f" LEFT JOIN domain_environments de ON s.domain_env_id = de.id"
|
||||
f" LEFT JOIN domains d ON de.domain_id = d.id"
|
||||
f" LEFT JOIN environments e ON de.environment_id = e.id"
|
||||
f" LEFT JOIN zones z ON s.zone_id = z.id"
|
||||
f" WHERE saf.status = 'ok'"
|
||||
f" ORDER BY saf.hostname, saf.audit_date DESC"
|
||||
)).fetchall()
|
||||
|
||||
if domain:
|
||||
zone_hosts = {r.hostname for r in db.execute(text(
|
||||
"SELECT s.hostname FROM servers s JOIN zones z ON s.zone_id = z.id WHERE z.name = :n"
|
||||
), {"n": domain}).fetchall()}
|
||||
if zone_hosts:
|
||||
servers = [s for s in servers if s.hostname in zone_hosts]
|
||||
else:
|
||||
dom_hosts = {r.hostname for r in db.execute(text(
|
||||
"SELECT s.hostname FROM servers s JOIN domain_environments de ON s.domain_env_id = de.id"
|
||||
" JOIN domains d ON de.domain_id = d.id WHERE d.code = :dc"
|
||||
), {"dc": domain}).fetchall()}
|
||||
servers = [s for s in servers if s.hostname in dom_hosts]
|
||||
if search:
|
||||
servers = [s for s in servers if search.lower() in s.hostname.lower()]
|
||||
|
||||
if sort == "hostname":
|
||||
servers.sort(key=lambda s: s.hostname.lower(), reverse=(sort_dir == "desc"))
|
||||
elif sort == "count":
|
||||
servers.sort(key=lambda s: s.patch_count or 0, reverse=(sort_dir == "desc"))
|
||||
elif sort == "last":
|
||||
servers.sort(key=lambda s: s.last_patch_week or "", reverse=(sort_dir == "desc"))
|
||||
|
||||
total_filtered = len(servers)
|
||||
total_pages = max(1, (total_filtered + per_page - 1) // per_page)
|
||||
page = max(1, min(page, total_pages))
|
||||
servers_page = servers[(page - 1) * per_page : page * per_page]
|
||||
|
||||
ctx = base_context(request, db, user)
|
||||
ctx.update({
|
||||
"app_name": APP_NAME, "year": year, "kpis": kpis,
|
||||
"patch_by_domain": patch_by_domain, "patch_weekly": patch_weekly,
|
||||
"servers": servers_page, "all_domains": all_domains, "all_zones": all_zones,
|
||||
"search": search, "domain": domain, "sort": sort, "sort_dir": sort_dir,
|
||||
"page": page, "total_pages": total_pages, "total_filtered": total_filtered,
|
||||
})
|
||||
return templates.TemplateResponse("audit_full_patching.html", ctx)
|
||||
|
||||
|
||||
@router.get("/audit-full/export-csv")
|
||||
async def audit_full_export_csv(request: Request, db=Depends(get_db)):
|
||||
user = get_current_user(request)
|
||||
|
||||
134
app/templates/audit_full_patching.html
Normal file
134
app/templates/audit_full_patching.html
Normal file
@ -0,0 +1,134 @@
|
||||
{% extends 'base.html' %}
|
||||
{% block title %}Patching {{ year }}{% endblock %}
|
||||
{% block content %}
|
||||
<div class="flex justify-between items-center mb-4">
|
||||
<div>
|
||||
<a href="/audit-full" class="text-xs text-gray-500 hover:text-gray-300">< Audit complet</a>
|
||||
<h2 class="text-xl font-bold text-cyber-accent">Patching {{ year }}</h2>
|
||||
</div>
|
||||
<div class="flex gap-2 items-center">
|
||||
<a href="/audit-full/patching?year=2025" class="btn-sm {% if year == 2025 %}bg-cyber-accent text-black{% else %}bg-cyber-border text-gray-400{% endif %} px-3 py-1">2025</a>
|
||||
<a href="/audit-full/patching?year=2026" class="btn-sm {% if year == 2026 %}bg-cyber-accent text-black{% else %}bg-cyber-border text-gray-400{% endif %} px-3 py-1">2026</a>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- KPIs -->
|
||||
{% if kpis %}
|
||||
{% set pct = (kpis.patched / kpis.total * 100)|int if kpis.total > 0 else 0 %}
|
||||
<div style="display:flex;flex-wrap:nowrap;gap:6px;margin-bottom:12px;">
|
||||
<div class="card p-2 text-center" style="flex:1;min-width:0"><div class="text-xl font-bold text-cyber-accent">{{ kpis.total }}</div><div style="font-size:10px;" class="text-gray-500">Total</div></div>
|
||||
<div class="card p-2 text-center" style="flex:1;min-width:0"><div class="text-xl font-bold text-cyber-green">{{ kpis.patched }}</div><div style="font-size:10px;" class="text-gray-500">Patches</div></div>
|
||||
<div class="card p-2 text-center" style="flex:1;min-width:0"><div class="text-xl font-bold text-green-300">{{ kpis.once }}</div><div style="font-size:10px;" class="text-gray-500">1 fois</div></div>
|
||||
<div class="card p-2 text-center" style="flex:1;min-width:0"><div class="text-xl font-bold text-blue-400">{{ kpis.twice }}</div><div style="font-size:10px;" class="text-gray-500">2+ fois</div></div>
|
||||
<div class="card p-2 text-center" style="flex:1;min-width:0"><div class="text-xl font-bold text-purple-400">{{ kpis.thrice }}</div><div style="font-size:10px;" class="text-gray-500">3+ fois</div></div>
|
||||
<div class="card p-2 text-center" style="flex:1;min-width:0"><div class="text-xl font-bold text-cyber-red">{{ kpis.never }}</div><div style="font-size:10px;" class="text-gray-500">Jamais</div></div>
|
||||
<div class="card p-2 text-center" style="flex:2;min-width:0">
|
||||
<div class="text-xl font-bold {% if pct >= 80 %}text-cyber-green{% elif pct >= 50 %}text-cyber-yellow{% else %}text-cyber-red{% endif %}">{{ pct }}%</div>
|
||||
<div style="font-size:10px;" class="text-gray-500">Couverture</div>
|
||||
<div style="height:4px;background:#1f2937;border-radius:2px;margin-top:4px;">
|
||||
<div style="height:100%;width:{{ pct }}%;background:{% if pct >= 80 %}#22c55e{% elif pct >= 50 %}#eab308{% else %}#ef4444{% endif %};border-radius:2px;"></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Graphe + domaines -->
|
||||
<div class="grid grid-cols-2 gap-4 mb-4">
|
||||
{% if patch_weekly %}
|
||||
<div class="card p-3">
|
||||
<div class="text-xs text-gray-500 mb-2">Derniere semaine de patch par serveur</div>
|
||||
<div style="display:flex;align-items:flex-end;gap:2px;height:100px;">
|
||||
{% set max_cnt = patch_weekly|map(attribute='cnt')|max %}
|
||||
{% for w in patch_weekly %}
|
||||
<div style="flex:1;display:flex;flex-direction:column;align-items:center;justify-content:flex-end;height:100%;" title="{{ w.week }}: {{ w.cnt }}">
|
||||
<div style="font-size:8px;color:#94a3b8;">{{ w.cnt }}</div>
|
||||
<div style="width:100%;background:#22c55e;border-radius:2px 2px 0 0;min-height:2px;height:{{ (w.cnt / max_cnt * 100)|int }}%;opacity:0.8;"></div>
|
||||
<div style="font-size:7px;color:#6b7280;margin-top:2px;transform:rotate(-45deg);white-space:nowrap;">{{ w.week }}</div>
|
||||
</div>
|
||||
{% endfor %}
|
||||
</div>
|
||||
</div>
|
||||
{% endif %}
|
||||
<div class="card p-3">
|
||||
<div class="text-xs text-gray-500 mb-2">Par domaine</div>
|
||||
<table class="w-full text-xs">
|
||||
<thead><tr><th class="text-left p-1">Domaine</th><th class="p-1">Total</th><th class="p-1">OK</th><th class="p-1">2x</th><th class="p-1">Jamais</th><th class="p-1">%</th></tr></thead>
|
||||
<tbody>
|
||||
{% for d in patch_by_domain %}
|
||||
{% set dp = (d.patched / d.total * 100)|int if d.total > 0 else 0 %}
|
||||
<tr>
|
||||
<td class="p-1"><a href="/audit-full/patching?year={{ year }}&domain={{ d.code }}" class="hover:text-cyber-accent">{{ d.domain }}</a></td>
|
||||
<td class="p-1 text-center">{{ d.total }}</td>
|
||||
<td class="p-1 text-center text-cyber-green">{{ d.patched }}</td>
|
||||
<td class="p-1 text-center text-blue-400">{{ d.twice }}</td>
|
||||
<td class="p-1 text-center text-cyber-red">{{ d.never }}</td>
|
||||
<td class="p-1 text-center">
|
||||
<div style="display:inline-block;width:40px;height:6px;background:#1f2937;border-radius:3px;vertical-align:middle;">
|
||||
<div style="height:100%;width:{{ dp }}%;background:{% if dp >= 80 %}#22c55e{% elif dp >= 50 %}#eab308{% else %}#ef4444{% endif %};border-radius:3px;"></div>
|
||||
</div>
|
||||
<span class="{% if dp >= 80 %}text-cyber-green{% elif dp >= 50 %}text-cyber-yellow{% else %}text-cyber-red{% endif %} font-bold ml-1">{{ dp }}%</span>
|
||||
</td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
<!-- Filtres -->
|
||||
<div class="card p-3 mb-4 flex gap-3 items-center flex-wrap">
|
||||
<form method="GET" action="/audit-full/patching" class="flex gap-2 items-center flex-1">
|
||||
<input type="hidden" name="year" value="{{ year }}">
|
||||
<input type="text" name="q" value="{{ search }}" placeholder="Rechercher..." class="text-xs py-1 px-2 flex-1 font-mono">
|
||||
<select name="domain" class="text-xs py-1 px-2" onchange="this.form.submit()">
|
||||
<option value="">Tous</option>
|
||||
<optgroup label="Zones">{% for z in all_zones %}<option value="{{ z.code }}" {% if domain == z.code %}selected{% endif %}>{{ z.name }}</option>{% endfor %}</optgroup>
|
||||
<optgroup label="Domaines">{% for d in all_domains %}<option value="{{ d.code }}" {% if domain == d.code %}selected{% endif %}>{{ d.name }}</option>{% endfor %}</optgroup>
|
||||
</select>
|
||||
<button type="submit" class="btn-primary px-3 py-1 text-xs">Filtrer</button>
|
||||
{% if search or domain %}<a href="/audit-full/patching?year={{ year }}" class="text-xs text-gray-400">Reset</a>{% endif %}
|
||||
</form>
|
||||
<span class="text-xs text-gray-500">{{ total_filtered }} serveur(s)</span>
|
||||
</div>
|
||||
|
||||
<!-- Tableau serveurs -->
|
||||
{% set qs %}{% if search %}&q={{ search }}{% endif %}{% if domain %}&domain={{ domain }}{% endif %}{% endset %}
|
||||
<div class="card overflow-x-auto">
|
||||
<table class="w-full table-cyber text-xs">
|
||||
<thead><tr>
|
||||
<th class="text-left p-2"><a href="/audit-full/patching?year={{ year }}&sort=hostname&dir={% if sort == 'hostname' and sort_dir == 'asc' %}desc{% else %}asc{% endif %}{{ qs }}" class="hover:text-cyber-accent">Hostname {% if sort == 'hostname' %}{{ '▲' if sort_dir == 'asc' else '▼' }}{% endif %}</a></th>
|
||||
<th class="p-2">Domaine</th>
|
||||
<th class="p-2">Env</th>
|
||||
<th class="p-2">Zone</th>
|
||||
<th class="p-2"><a href="/audit-full/patching?year={{ year }}&sort=count&dir={% if sort == 'count' and sort_dir == 'desc' %}asc{% else %}desc{% endif %}{{ qs }}" class="hover:text-cyber-accent">Nb patches {% if sort == 'count' %}{{ '▲' if sort_dir == 'asc' else '▼' }}{% endif %}</a></th>
|
||||
<th class="text-left p-2">Semaines</th>
|
||||
<th class="p-2"><a href="/audit-full/patching?year={{ year }}&sort=last&dir={% if sort == 'last' and sort_dir == 'desc' %}asc{% else %}desc{% endif %}{{ qs }}" class="hover:text-cyber-accent">Dernier {% if sort == 'last' %}{{ '▲' if sort_dir == 'asc' else '▼' }}{% endif %}</a></th>
|
||||
</tr></thead>
|
||||
<tbody>
|
||||
{% for s in servers %}
|
||||
<tr class="hover:bg-cyber-hover cursor-pointer" onclick="location.href='/audit-full/{{ s.id }}'">
|
||||
<td class="p-2 font-mono text-cyber-accent font-bold">{{ s.hostname }}</td>
|
||||
<td class="p-2 text-center text-gray-400">{{ s.domain or '-' }}</td>
|
||||
<td class="p-2 text-center"><span class="badge {% if s.env == 'Production' %}badge-green{% elif s.env == 'Recette' %}badge-yellow{% else %}badge-gray{% endif %}">{{ (s.env or '-')[:6] }}</span></td>
|
||||
<td class="p-2 text-center">{% if s.zone == 'DMZ' %}<span class="badge badge-red">DMZ</span>{% else %}{{ s.zone or '-' }}{% endif %}</td>
|
||||
<td class="p-2 text-center font-bold {% if (s.patch_count or 0) >= 2 %}text-cyber-green{% elif (s.patch_count or 0) == 1 %}text-green-300{% else %}text-cyber-red{% endif %}">{{ s.patch_count or 0 }}</td>
|
||||
<td class="p-2 font-mono text-gray-400">{% if s.patch_weeks %}{% for w in s.patch_weeks.split(',') %}<span class="inline-block px-1 rounded text-xs {% if w == 'S15' %}bg-green-900/30 text-cyber-green{% else %}bg-cyber-border text-gray-400{% endif %} mr-1">{{ w }}</span>{% endfor %}{% else %}-{% endif %}</td>
|
||||
<td class="p-2 text-center font-mono {% if s.last_patch_year == year %}text-cyber-green{% elif s.last_patch_date %}text-cyber-yellow{% else %}text-cyber-red{% endif %}">{% if s.last_patch_date %}{{ s.last_patch_date }}{% elif s.last_patch_week %}{{ s.last_patch_week }}{% else %}-{% endif %}</td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
|
||||
{% if total_pages > 1 %}
|
||||
{% set pqs %}&year={{ year }}{% if search %}&q={{ search }}{% endif %}{% if domain %}&domain={{ domain }}{% endif %}{% if sort %}&sort={{ sort }}&dir={{ sort_dir }}{% endif %}{% endset %}
|
||||
<div class="flex justify-between items-center p-3 border-t border-cyber-border">
|
||||
<span class="text-xs text-gray-500">Page {{ page }}/{{ total_pages }}</span>
|
||||
<div class="flex gap-1">
|
||||
{% if page > 1 %}<a href="/audit-full/patching?page=1{{ pqs }}" class="btn-sm bg-cyber-border text-gray-400 px-2 py-1 text-xs">1</a>{% if page > 2 %}<a href="/audit-full/patching?page={{ page-1 }}{{ pqs }}" class="btn-sm bg-cyber-border text-gray-400 px-2 py-1 text-xs"><</a>{% endif %}{% endif %}
|
||||
<span class="btn-sm bg-cyber-accent text-black px-2 py-1 text-xs font-bold">{{ page }}</span>
|
||||
{% if page < total_pages %}<a href="/audit-full/patching?page={{ page+1 }}{{ pqs }}" class="btn-sm bg-cyber-border text-gray-400 px-2 py-1 text-xs">></a><a href="/audit-full/patching?page={{ total_pages }}{{ pqs }}" class="btn-sm bg-cyber-border text-gray-400 px-2 py-1 text-xs">{{ total_pages }}</a>{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
{% endblock %}
|
||||
@ -64,6 +64,7 @@
|
||||
{% if p.audit %}<a href="/audit" class="block px-3 py-2 rounded-md text-sm hover:bg-cyber-border/30 {% if request.url.path == '/audit' %}bg-cyber-border/30 text-cyber-accent{% else %}text-gray-400{% endif %}">Audit</a>{% endif %}
|
||||
{% if p.audit in ('edit', 'admin') %}<a href="/audit/specific" class="block px-3 py-2 rounded-md text-sm hover:bg-cyber-border/30 {% if 'specific' in request.url.path %}bg-cyber-border/30 text-cyber-accent{% else %}text-gray-400{% endif %} pl-6 text-xs">Spécifique</a>{% endif %}
|
||||
{% if p.audit %}<a href="/audit-full" class="block px-3 py-2 rounded-md text-sm hover:bg-cyber-border/30 {% if 'audit-full' in request.url.path %}bg-cyber-border/30 text-cyber-accent{% else %}text-gray-400{% endif %} pl-6 text-xs">Complet</a>{% endif %}
|
||||
{% if p.audit %}<a href="/audit-full/patching" class="block px-3 py-2 rounded-md text-sm hover:bg-cyber-border/30 {% if 'patching' in request.url.path %}bg-cyber-border/30 text-cyber-accent{% else %}text-gray-400{% endif %} pl-6 text-xs">Patching</a>{% endif %}
|
||||
{% if p.servers %}<a href="/contacts" class="block px-3 py-2 rounded-md text-sm hover:bg-cyber-border/30 {% if 'contacts' in request.url.path %}bg-cyber-border/30 text-cyber-accent{% else %}text-gray-400{% endif %}">Contacts</a>{% endif %}
|
||||
{% if p.users %}<a href="/users" class="block px-3 py-2 rounded-md text-sm hover:bg-cyber-border/30 {% if 'users' in request.url.path %}bg-cyber-border/30 text-cyber-accent{% else %}text-gray-400{% endif %}">Utilisateurs</a>{% endif %}
|
||||
{% if p.settings %}<a href="/settings" class="block px-3 py-2 rounded-md text-sm hover:bg-cyber-border/30 {% if 'settings' in request.url.path %}bg-cyber-border/30 text-cyber-accent{% else %}text-gray-400{% endif %}">Settings</a>{% endif %}
|
||||
|
||||
Loading…
Reference in New Issue
Block a user