Add page Historique patching : vue unifiee import xlsx + campagnes + quickwin
This commit is contained in:
parent
c9890a274f
commit
4b1794d4d1
@ -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.responses import HTMLResponse, RedirectResponse
|
||||
from fastapi.templating import Jinja2Templates
|
||||
@ -13,7 +13,8 @@ templates = Jinja2Templates(directory="app/templates")
|
||||
@router.get("/patching/historique", response_class=HTMLResponse)
|
||||
async def patch_history_page(request: Request, db=Depends(get_db),
|
||||
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)
|
||||
if not user:
|
||||
return RedirectResponse(url="/login")
|
||||
@ -25,14 +26,27 @@ async def patch_history_page(request: Request, db=Depends(get_db),
|
||||
per_page = 100
|
||||
offset = (page - 1) * per_page
|
||||
|
||||
# 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"
|
||||
), {"y": year}).scalar()
|
||||
kpis["servers"] = db.execute(text(
|
||||
"SELECT COUNT(DISTINCT server_id) FROM patch_history WHERE EXTRACT(YEAR FROM date_patch)=:y"
|
||||
), {"y": year}).scalar()
|
||||
kpis["total_qw"] = db.execute(text("""
|
||||
SELECT COUNT(*) FROM quickwin_entries qe
|
||||
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(
|
||||
"SELECT COUNT(*) FROM servers WHERE etat='Production' AND patch_os_owner='secops'"
|
||||
)).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'
|
||||
AND NOT EXISTS (SELECT 1 FROM patch_history ph
|
||||
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()
|
||||
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("""
|
||||
SELECT TO_CHAR(date_patch, 'IW') as week_num,
|
||||
COUNT(DISTINCT server_id) as servers
|
||||
FROM patch_history
|
||||
WHERE EXTRACT(YEAR FROM date_patch)=:y
|
||||
GROUP BY TO_CHAR(date_patch, 'IW')
|
||||
ORDER BY week_num
|
||||
SELECT week_num, SUM(cnt)::int as servers FROM (
|
||||
SELECT TO_CHAR(date_patch, 'IW') as week_num, COUNT(DISTINCT server_id) as cnt
|
||||
FROM patch_history
|
||||
WHERE EXTRACT(YEAR FROM date_patch)=:y
|
||||
GROUP BY TO_CHAR(date_patch, 'IW')
|
||||
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()
|
||||
|
||||
# Filtres
|
||||
where = ["EXTRACT(YEAR FROM ph.date_patch)=:y"]
|
||||
where_ph = ["EXTRACT(YEAR FROM ph.date_patch)=:y"]
|
||||
where_qw = ["qr.year=:y", "qe.status='patched'"]
|
||||
params = {"y": year, "limit": per_page, "offset": offset}
|
||||
|
||||
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
|
||||
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}%"
|
||||
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(
|
||||
f"SELECT COUNT(*) FROM patch_history ph JOIN servers s ON ph.server_id=s.id WHERE {wc}"
|
||||
), params).scalar()
|
||||
wc_ph = " AND ".join(where_ph)
|
||||
wc_qw = " AND ".join(where_qw)
|
||||
|
||||
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"""
|
||||
SELECT s.id as sid, s.hostname, s.os_family, s.etat,
|
||||
ph.date_patch, ph.status, ph.notes,
|
||||
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
|
||||
SELECT * FROM ({union_sql}) combined
|
||||
ORDER BY date_patch DESC NULLS LAST
|
||||
LIMIT :limit OFFSET :offset
|
||||
"""), params).fetchall()
|
||||
|
||||
# Années dispo
|
||||
years = db.execute(text("""
|
||||
SELECT DISTINCT EXTRACT(YEAR FROM date_patch)::int as y
|
||||
FROM patch_history ORDER BY y DESC
|
||||
SELECT DISTINCT y FROM (
|
||||
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()
|
||||
|
||||
return templates.TemplateResponse("patch_history.html", {
|
||||
"request": request, "user": user, "app_name": APP_NAME,
|
||||
"kpis": kpis, "by_week": by_week, "rows": rows,
|
||||
"year": year, "week": week, "hostname": hostname,
|
||||
"page": page, "per_page": per_page, "total_filtered": total_filtered,
|
||||
"years": [y.y for y in years],
|
||||
"kpis": kpis, "by_week": by_week, "by_source": by_source,
|
||||
"rows": rows, "year": year, "week": week, "hostname": hostname,
|
||||
"source": source, "page": page, "per_page": per_page,
|
||||
"total_filtered": total_filtered, "years": [y.y for y in years],
|
||||
})
|
||||
|
||||
@ -1,215 +1,216 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="fr">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>{{ app_name }} - {% block title %}{% endblock %}</title>
|
||||
<link rel="icon" type="image/svg+xml" href="/static/favicon.svg">
|
||||
<link rel="stylesheet" href="/static/css/tailwind.css">
|
||||
<script src="/static/js/htmx.min.js"></script>
|
||||
<script src="/static/js/alpine.min.js" defer></script>
|
||||
<style>
|
||||
body { background: #0a0e17; color: #e2e8f0; font-family: 'Segoe UI', system-ui, sans-serif; }
|
||||
.sidebar { background: #111827; border-right: 1px solid #1e3a5f; }
|
||||
.card { background: #111827; border: 1px solid #1e3a5f; border-radius: 8px; }
|
||||
.btn-primary { background: #00d4ff; color: #0a0e17; font-weight: 600; border-radius: 6px; }
|
||||
.btn-primary:hover { background: #00b8e6; }
|
||||
.btn-danger { background: #ff3366; color: white; border-radius: 6px; }
|
||||
.btn-sm { padding: 2px 10px; font-size: 0.75rem; border-radius: 4px; cursor: pointer; }
|
||||
.table-cyber th { background: #1e3a5f; color: #00d4ff; font-size: 0.7rem; text-transform: uppercase; letter-spacing: 0.05em; }
|
||||
.table-cyber td { border-bottom: 1px solid #1e3a5f; font-size: 0.8rem; }
|
||||
.table-cyber tr:hover { background: #1a2332; cursor: pointer; }
|
||||
.table-cyber tr.selected { background: #1e3a5f44; }
|
||||
.badge { padding: 2px 8px; border-radius: 12px; font-size: 0.65rem; font-weight: 600; }
|
||||
.badge-green { background: #00ff8822; color: #00ff88; }
|
||||
.badge-red { background: #ff336622; color: #ff3366; }
|
||||
.badge-yellow { background: #ffcc0022; color: #ffcc00; }
|
||||
.badge-blue { background: #00d4ff22; color: #00d4ff; }
|
||||
.badge-gray { background: #4a556822; color: #94a3b8; }
|
||||
input, select, textarea { background: #0a0e17; border: 1px solid #1e3a5f; color: #e2e8f0; border-radius: 6px; padding: 6px 12px; font-size: 0.85rem; }
|
||||
input:focus, select:focus, textarea:focus { outline: none; border-color: #00d4ff; box-shadow: 0 0 0 2px #00d4ff33; }
|
||||
.panel-slide { transition: transform 0.3s ease, opacity 0.3s ease; }
|
||||
.htmx-indicator { opacity: 0; transition: opacity 200ms; }
|
||||
.htmx-request .htmx-indicator { opacity: 1; }
|
||||
.inline-edit { background: transparent; border: 1px solid transparent; padding: 2px 4px; }
|
||||
.inline-edit:hover { border-color: #1e3a5f; }
|
||||
.inline-edit:focus { background: #0a0e17; border-color: #00d4ff; }
|
||||
.toast { position: fixed; bottom: 20px; right: 20px; padding: 12px 24px; border-radius: 8px; z-index: 1000; animation: fadeIn 0.3s; }
|
||||
@keyframes fadeIn { from { opacity: 0; transform: translateY(10px); } to { opacity: 1; transform: translateY(0); } }
|
||||
[x-cloak] { display: none !important; }
|
||||
</style>
|
||||
</head>
|
||||
<body class="min-h-screen" hx-headers='{"X-Requested-With": "htmx"}'>
|
||||
{% if user %}
|
||||
<div class="flex min-h-screen">
|
||||
<aside class="sidebar w-52 flex-shrink-0 flex flex-col">
|
||||
<div class="p-4 border-b border-cyber-border">
|
||||
<div class="flex items-center gap-2">
|
||||
<img src="/static/logo_sanef.jpg" alt="SANEF" class="h-8 rounded" style="opacity:0.85">
|
||||
</div>
|
||||
<h1 class="text-cyber-accent font-bold text-lg mt-1">PatchCenter</h1>
|
||||
<p class="text-xs text-gray-500">v2.0 — SecOps</p>
|
||||
</div>
|
||||
<nav class="flex-1 p-3 space-y-1" x-data='{
|
||||
open: localStorage.getItem("menu_open") || "",
|
||||
subOpen: localStorage.getItem("menu_sub_open") || "",
|
||||
toggle(k){ this.open = (this.open === k) ? "" : k; this.subOpen = ""; localStorage.setItem("menu_open", this.open); localStorage.setItem("menu_sub_open", ""); },
|
||||
toggleSub(k){ this.subOpen = (this.subOpen === k) ? "" : k; localStorage.setItem("menu_sub_open", this.subOpen); }
|
||||
}'>
|
||||
{% set p = perms if perms is defined else request.state.perms %}
|
||||
{% set path = request.url.path %}
|
||||
|
||||
{# Dashboard principal #}
|
||||
{% if p.servers or p.qualys or p.audit or p.planning %}<a href="/dashboard" class="block px-3 py-2 rounded-md text-sm hover:bg-cyber-border/30 {% if 'dashboard' in path %}bg-cyber-border/30 text-cyber-accent{% else %}text-gray-400{% endif %}">Dashboard</a>{% endif %}
|
||||
|
||||
{# Serveurs (groupe repliable avec Correspondance) #}
|
||||
{% if p.servers %}
|
||||
<div>
|
||||
<button @click="toggle('servers')" class="w-full flex justify-between items-center px-3 py-2 rounded-md text-sm hover:bg-cyber-border/30 text-cyber-accent font-bold">
|
||||
<span>Serveurs</span>
|
||||
<span x-text="open === 'servers' ? '▾' : '▸'" class="text-xs"></span>
|
||||
</button>
|
||||
<div x-show="open === 'servers'" x-cloak class="space-y-1 pl-1">
|
||||
<a href="/servers" class="block px-3 py-1.5 rounded-md text-xs hover:bg-cyber-border/30 {% if path == '/servers' or path.startswith('/servers/') %}bg-cyber-border/30 text-cyber-accent{% else %}text-gray-400{% endif %} pl-6">Liste</a>
|
||||
{% if p.campaigns or p.quickwin %}<a href="/patching/correspondance" class="block px-3 py-1.5 rounded-md text-xs hover:bg-cyber-border/30 {% if '/patching/correspondance' in path %}bg-cyber-border/30 text-cyber-accent{% else %}text-gray-400{% endif %} pl-6">Correspondance prod ↔ hors-prod</a>{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
{# ===== PATCHING (groupe repliable) ===== #}
|
||||
{% if p.campaigns or p.planning or p.quickwin %}
|
||||
<div>
|
||||
<button @click="toggle('patching')" class="w-full flex justify-between items-center px-3 py-2 rounded-md text-sm hover:bg-cyber-border/30 text-cyber-accent font-bold">
|
||||
<span>Patching</span>
|
||||
<span x-text="open === 'patching' ? '▾' : '▸'" class="text-xs"></span>
|
||||
</button>
|
||||
<div x-show="open === 'patching'" x-cloak class="space-y-1 pl-1">
|
||||
{% if p.planning %}<a href="/planning" class="block px-3 py-1.5 rounded-md text-xs hover:bg-cyber-border/30 {% if 'planning' in path %}bg-cyber-border/30 text-cyber-accent{% else %}text-gray-400{% endif %} pl-6">Planning</a>{% endif %}
|
||||
{% if p.campaigns in ('edit', 'admin') %}<a href="/assignments" class="block px-3 py-1.5 rounded-md text-xs hover:bg-cyber-border/30 {% if 'assignments' in path %}bg-cyber-border/30 text-cyber-accent{% else %}text-gray-400{% endif %} pl-6">Assignation</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.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 %}
|
||||
|
||||
{# Quickwin sous-groupe #}
|
||||
{% if p.campaigns or p.quickwin %}
|
||||
<div>
|
||||
<button @click="toggleSub('quickwin')" class="w-full flex justify-between items-center px-3 py-1.5 rounded-md text-xs hover:bg-cyber-border/30 pl-6 {% if 'quickwin' in path %}text-cyber-accent{% else %}text-gray-400{% endif %}">
|
||||
<span>QuickWin</span>
|
||||
<span x-text="subOpen === 'quickwin' ? '▾' : '▸'" class="text-xs opacity-60"></span>
|
||||
</button>
|
||||
<div x-show="subOpen === 'quickwin'" x-cloak class="space-y-1">
|
||||
<a href="/quickwin" style="padding-left:3rem" class="block py-1 pr-3 rounded-md text-xs hover:bg-cyber-border/30 {% if 'quickwin' in path and 'config' not in path and 'correspondance' not in path %}bg-cyber-border/30 text-cyber-accent{% else %}text-gray-500{% endif %}">Vue d'ensemble</a>
|
||||
{% if p.quickwin in ('edit', 'admin') or p.campaigns in ('edit', 'admin') %}
|
||||
<a href="/quickwin/config" style="padding-left:3rem" class="block py-1 pr-3 rounded-md text-xs hover:bg-cyber-border/30 {% if '/quickwin/config' in path %}bg-cyber-border/30 text-cyber-accent{% else %}text-gray-500{% endif %}">Config exclusion</a>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
{# ===== AUDIT (au meme niveau que Patching, repliable) ===== #}
|
||||
{% if p.audit %}
|
||||
<div>
|
||||
<button @click="toggle('audit')" class="w-full flex justify-between items-center px-3 py-2 rounded-md text-sm hover:bg-cyber-border/30 text-cyber-accent font-bold">
|
||||
<span>Audit</span>
|
||||
<span x-text="open === 'audit' ? '▾' : '▸'" class="text-xs"></span>
|
||||
</button>
|
||||
<div x-show="open === 'audit'" x-cloak class="space-y-1 pl-1">
|
||||
<a href="/audit" class="block px-3 py-1.5 rounded-md text-xs hover:bg-cyber-border/30 {% if path == '/audit' %}bg-cyber-border/30 text-cyber-accent{% else %}text-gray-400{% endif %} pl-6">Audit global</a>
|
||||
{% if p.audit in ('edit', 'admin') %}<a href="/audit/specific" class="block px-3 py-1.5 rounded-md text-xs hover:bg-cyber-border/30 {% if 'specific' in path %}bg-cyber-border/30 text-cyber-accent{% else %}text-gray-400{% endif %} pl-6">Spécifique</a>{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
{# ===== QUALYS (groupe repliable) ===== #}
|
||||
{% if p.qualys %}
|
||||
<div>
|
||||
<button @click="toggle('qualys')" class="w-full flex justify-between items-center px-3 py-2 rounded-md text-sm hover:bg-cyber-border/30 text-cyber-accent font-bold">
|
||||
<span>Qualys</span>
|
||||
<span x-text="open === 'qualys' ? '▾' : '▸'" class="text-xs"></span>
|
||||
</button>
|
||||
<div x-show="open === 'qualys'" x-cloak class="space-y-1 pl-1">
|
||||
<a href="/qualys/search" class="block px-3 py-1.5 rounded-md text-xs hover:bg-cyber-border/30 {% if '/qualys/search' in path %}bg-cyber-border/30 text-cyber-accent{% else %}text-gray-400{% endif %} pl-6">Recherche</a>
|
||||
<a href="/qualys/tags" class="block px-3 py-1.5 rounded-md text-xs hover:bg-cyber-border/30 {% if '/qualys/tags' in path and '/qualys/tagsv3' not in path %}bg-cyber-border/30 text-cyber-accent{% else %}text-gray-400{% endif %} pl-6">Tags</a>
|
||||
<a href="/qualys/tagsv3" class="block px-3 py-1.5 rounded-md text-xs hover:bg-cyber-border/30 {% if '/qualys/tagsv3' in path and '/catalog' not in path and '/gap' not in path %}bg-cyber-border/30 text-cyber-accent{% else %}text-gray-400{% endif %} pl-8">↳ Tags V3 (vue)</a>
|
||||
<a href="/qualys/tagsv3/catalog" class="block px-3 py-1.5 rounded-md text-xs hover:bg-cyber-border/30 {% if '/qualys/tagsv3/catalog' in path %}bg-cyber-border/30 text-cyber-accent{% else %}text-gray-400{% endif %} pl-8">↳ Tags V3 (catalogue)</a>
|
||||
<a href="/qualys/tagsv3/gap" class="block px-3 py-1.5 rounded-md text-xs hover:bg-cyber-border/30 {% if '/qualys/tagsv3/gap' in path %}bg-cyber-border/30 text-cyber-accent{% else %}text-gray-400{% endif %} pl-8">↳ Tags V3 (gap)</a>
|
||||
<a href="/qualys/agents" class="block px-3 py-1.5 rounded-md text-xs hover:bg-cyber-border/30 {% if 'agents' in path and 'deploy' not in path %}bg-cyber-border/30 text-cyber-accent{% else %}text-gray-400{% endif %} pl-6">Agents</a>
|
||||
{% if p.qualys in ('edit', 'admin') %}<a href="/qualys/deploy" class="block px-3 py-1.5 rounded-md text-xs hover:bg-cyber-border/30 {% if 'deploy' in path %}bg-cyber-border/30 text-cyber-accent{% else %}text-gray-400{% endif %} pl-6">Déployer Agent</a>{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
{# ===== ADMIN (groupe repliable) ===== #}
|
||||
{% if p.users or p.settings or p.servers or p.contacts %}
|
||||
<div>
|
||||
<button @click="toggle('admin')" class="w-full flex justify-between items-center px-3 py-2 rounded-md text-sm hover:bg-cyber-border/30 text-cyber-accent font-bold">
|
||||
<span>Administration</span>
|
||||
<span x-text="open === 'admin' ? '▾' : '▸'" class="text-xs"></span>
|
||||
</button>
|
||||
<div x-show="open === 'admin'" x-cloak class="space-y-1 pl-1">
|
||||
{% if p.servers or p.contacts %}<a href="/contacts" class="block px-3 py-1.5 rounded-md text-xs hover:bg-cyber-border/30 {% if 'contacts' in path %}bg-cyber-border/30 text-cyber-accent{% else %}text-gray-400{% endif %} pl-6">Contacts</a>{% endif %}
|
||||
{% if p.users %}<a href="/users" class="block px-3 py-1.5 rounded-md text-xs hover:bg-cyber-border/30 {% if path == '/users' or path.startswith('/users/') %}bg-cyber-border/30 text-cyber-accent{% else %}text-gray-400{% endif %} pl-6">Utilisateurs</a>{% endif %}
|
||||
{% if p.settings or p.users %}<a href="/admin/applications" class="block px-3 py-1.5 rounded-md text-xs hover:bg-cyber-border/30 {% if '/admin/applications' in path %}bg-cyber-border/30 text-cyber-accent{% else %}text-gray-400{% endif %} pl-6">Applications</a>{% endif %}
|
||||
{% if p.settings %}<a href="/settings" class="block px-3 py-1.5 rounded-md text-xs hover:bg-cyber-border/30 {% if 'settings' in path %}bg-cyber-border/30 text-cyber-accent{% else %}text-gray-400{% endif %} pl-6">Settings</a>{% endif %}
|
||||
{% if p.settings or p.referentiel %}<a href="/referentiel" class="block px-3 py-1.5 rounded-md text-xs hover:bg-cyber-border/30 {% if 'referentiel' in path %}bg-cyber-border/30 text-cyber-accent{% else %}text-gray-400{% endif %} pl-6">Référentiel</a>{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
{% endif %}
|
||||
</nav>
|
||||
</aside>
|
||||
<main class="flex-1 flex flex-col overflow-hidden">
|
||||
<!-- Top bar -->
|
||||
<header class="flex items-center justify-end px-6 py-2 border-b border-cyber-border bg-cyber-card">
|
||||
<div class="flex items-center gap-3">
|
||||
<span class="text-sm text-cyber-accent font-medium">{{ user.display or user.sub }}</span>
|
||||
{% if user.auth == 'ldap' %}<span class="text-xs text-gray-500">(AD)</span>{% endif %}
|
||||
<span class="text-xs text-gray-500">·</span>
|
||||
<span class="text-xs text-gray-400">{{ user.sub }}</span>
|
||||
<span class="badge badge-blue">{{ user.role }}</span>
|
||||
<a href="/logout" class="btn-sm bg-cyber-border text-gray-300 hover:bg-red-900/40 hover:text-cyber-red transition-colors">Deconnexion</a>
|
||||
</div>
|
||||
</header>
|
||||
<div class="flex flex-1 overflow-hidden">
|
||||
<div class="flex-1 p-6 overflow-auto" id="main-content">
|
||||
{% block content %}{% endblock %}
|
||||
</div>
|
||||
<div id="detail-panel" class="w-0 overflow-hidden transition-all duration-300 border-l border-cyber-border bg-cyber-card">
|
||||
</div>
|
||||
</div>
|
||||
</main>
|
||||
</div>
|
||||
{% else %}
|
||||
{% block fullpage %}{% endblock %}
|
||||
{% endif %}
|
||||
<div id="toast-container"></div>
|
||||
<!-- Overlay chargement -->
|
||||
<div id="loading-overlay" style="display:none; position:fixed; top:0; left:0; width:100%; height:100%; background:rgba(10,14,23,0.85); z-index:9999; justify-content:center; align-items:center;">
|
||||
<div style="text-align:center">
|
||||
<div style="border:3px solid #1e3a5f; border-top:3px solid #00d4ff; border-radius:50%; width:40px; height:40px; animation:spin 1s linear infinite; margin:0 auto 16px"></div>
|
||||
<div id="loading-msg" style="color:#00d4ff; font-size:14px; font-weight:600">Opération en cours...</div>
|
||||
<div id="loading-sub" style="color:#94a3b8; font-size:12px; margin-top:6px"></div>
|
||||
</div>
|
||||
</div>
|
||||
<style>@keyframes spin { 0% { transform: rotate(0deg); } 100% { transform: rotate(360deg); } }</style>
|
||||
<script>
|
||||
function showLoading(msg, sub) {
|
||||
document.getElementById('loading-msg').textContent = msg || 'Opération en cours...';
|
||||
document.getElementById('loading-sub').textContent = sub || '';
|
||||
document.getElementById('loading-overlay').style.display = 'flex';
|
||||
}
|
||||
function hideLoading() { document.getElementById('loading-overlay').style.display = 'none'; }
|
||||
// Auto-attach: tout bouton avec data-loading affiche l'overlay au clic
|
||||
document.addEventListener('DOMContentLoaded', function() {
|
||||
document.querySelectorAll('[data-loading]').forEach(function(btn) {
|
||||
btn.addEventListener('click', function(e) {
|
||||
var parts = (btn.dataset.loading || 'Opération en cours...|').split('|');
|
||||
showLoading(parts[0], parts[1] || '');
|
||||
});
|
||||
});
|
||||
});
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
<!DOCTYPE html>
|
||||
<html lang="fr">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>{{ app_name }} - {% block title %}{% endblock %}</title>
|
||||
<link rel="icon" type="image/svg+xml" href="/static/favicon.svg">
|
||||
<link rel="stylesheet" href="/static/css/tailwind.css">
|
||||
<script src="/static/js/htmx.min.js"></script>
|
||||
<script src="/static/js/alpine.min.js" defer></script>
|
||||
<style>
|
||||
body { background: #0a0e17; color: #e2e8f0; font-family: 'Segoe UI', system-ui, sans-serif; }
|
||||
.sidebar { background: #111827; border-right: 1px solid #1e3a5f; }
|
||||
.card { background: #111827; border: 1px solid #1e3a5f; border-radius: 8px; }
|
||||
.btn-primary { background: #00d4ff; color: #0a0e17; font-weight: 600; border-radius: 6px; }
|
||||
.btn-primary:hover { background: #00b8e6; }
|
||||
.btn-danger { background: #ff3366; color: white; border-radius: 6px; }
|
||||
.btn-sm { padding: 2px 10px; font-size: 0.75rem; border-radius: 4px; cursor: pointer; }
|
||||
.table-cyber th { background: #1e3a5f; color: #00d4ff; font-size: 0.7rem; text-transform: uppercase; letter-spacing: 0.05em; }
|
||||
.table-cyber td { border-bottom: 1px solid #1e3a5f; font-size: 0.8rem; }
|
||||
.table-cyber tr:hover { background: #1a2332; cursor: pointer; }
|
||||
.table-cyber tr.selected { background: #1e3a5f44; }
|
||||
.badge { padding: 2px 8px; border-radius: 12px; font-size: 0.65rem; font-weight: 600; }
|
||||
.badge-green { background: #00ff8822; color: #00ff88; }
|
||||
.badge-red { background: #ff336622; color: #ff3366; }
|
||||
.badge-yellow { background: #ffcc0022; color: #ffcc00; }
|
||||
.badge-blue { background: #00d4ff22; color: #00d4ff; }
|
||||
.badge-gray { background: #4a556822; color: #94a3b8; }
|
||||
input, select, textarea { background: #0a0e17; border: 1px solid #1e3a5f; color: #e2e8f0; border-radius: 6px; padding: 6px 12px; font-size: 0.85rem; }
|
||||
input:focus, select:focus, textarea:focus { outline: none; border-color: #00d4ff; box-shadow: 0 0 0 2px #00d4ff33; }
|
||||
.panel-slide { transition: transform 0.3s ease, opacity 0.3s ease; }
|
||||
.htmx-indicator { opacity: 0; transition: opacity 200ms; }
|
||||
.htmx-request .htmx-indicator { opacity: 1; }
|
||||
.inline-edit { background: transparent; border: 1px solid transparent; padding: 2px 4px; }
|
||||
.inline-edit:hover { border-color: #1e3a5f; }
|
||||
.inline-edit:focus { background: #0a0e17; border-color: #00d4ff; }
|
||||
.toast { position: fixed; bottom: 20px; right: 20px; padding: 12px 24px; border-radius: 8px; z-index: 1000; animation: fadeIn 0.3s; }
|
||||
@keyframes fadeIn { from { opacity: 0; transform: translateY(10px); } to { opacity: 1; transform: translateY(0); } }
|
||||
[x-cloak] { display: none !important; }
|
||||
</style>
|
||||
</head>
|
||||
<body class="min-h-screen" hx-headers='{"X-Requested-With": "htmx"}'>
|
||||
{% if user %}
|
||||
<div class="flex min-h-screen">
|
||||
<aside class="sidebar w-52 flex-shrink-0 flex flex-col">
|
||||
<div class="p-4 border-b border-cyber-border">
|
||||
<div class="flex items-center gap-2">
|
||||
<img src="/static/logo_sanef.jpg" alt="SANEF" class="h-8 rounded" style="opacity:0.85">
|
||||
</div>
|
||||
<h1 class="text-cyber-accent font-bold text-lg mt-1">PatchCenter</h1>
|
||||
<p class="text-xs text-gray-500">v2.0 — SecOps</p>
|
||||
</div>
|
||||
<nav class="flex-1 p-3 space-y-1" x-data='{
|
||||
open: localStorage.getItem("menu_open") || "",
|
||||
subOpen: localStorage.getItem("menu_sub_open") || "",
|
||||
toggle(k){ this.open = (this.open === k) ? "" : k; this.subOpen = ""; localStorage.setItem("menu_open", this.open); localStorage.setItem("menu_sub_open", ""); },
|
||||
toggleSub(k){ this.subOpen = (this.subOpen === k) ? "" : k; localStorage.setItem("menu_sub_open", this.subOpen); }
|
||||
}'>
|
||||
{% set p = perms if perms is defined else request.state.perms %}
|
||||
{% set path = request.url.path %}
|
||||
|
||||
{# Dashboard principal #}
|
||||
{% if p.servers or p.qualys or p.audit or p.planning %}<a href="/dashboard" class="block px-3 py-2 rounded-md text-sm hover:bg-cyber-border/30 {% if 'dashboard' in path %}bg-cyber-border/30 text-cyber-accent{% else %}text-gray-400{% endif %}">Dashboard</a>{% endif %}
|
||||
|
||||
{# Serveurs (groupe repliable avec Correspondance) #}
|
||||
{% if p.servers %}
|
||||
<div>
|
||||
<button @click="toggle('servers')" class="w-full flex justify-between items-center px-3 py-2 rounded-md text-sm hover:bg-cyber-border/30 text-cyber-accent font-bold">
|
||||
<span>Serveurs</span>
|
||||
<span x-text="open === 'servers' ? '▾' : '▸'" class="text-xs"></span>
|
||||
</button>
|
||||
<div x-show="open === 'servers'" x-cloak class="space-y-1 pl-1">
|
||||
<a href="/servers" class="block px-3 py-1.5 rounded-md text-xs hover:bg-cyber-border/30 {% if path == '/servers' or path.startswith('/servers/') %}bg-cyber-border/30 text-cyber-accent{% else %}text-gray-400{% endif %} pl-6">Liste</a>
|
||||
{% if p.campaigns or p.quickwin %}<a href="/patching/correspondance" class="block px-3 py-1.5 rounded-md text-xs hover:bg-cyber-border/30 {% if '/patching/correspondance' in path %}bg-cyber-border/30 text-cyber-accent{% else %}text-gray-400{% endif %} pl-6">Correspondance prod ↔ hors-prod</a>{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
{# ===== PATCHING (groupe repliable) ===== #}
|
||||
{% if p.campaigns or p.planning or p.quickwin %}
|
||||
<div>
|
||||
<button @click="toggle('patching')" class="w-full flex justify-between items-center px-3 py-2 rounded-md text-sm hover:bg-cyber-border/30 text-cyber-accent font-bold">
|
||||
<span>Patching</span>
|
||||
<span x-text="open === 'patching' ? '▾' : '▸'" class="text-xs"></span>
|
||||
</button>
|
||||
<div x-show="open === 'patching'" x-cloak class="space-y-1 pl-1">
|
||||
{% if p.planning %}<a href="/planning" class="block px-3 py-1.5 rounded-md text-xs hover:bg-cyber-border/30 {% if 'planning' in path %}bg-cyber-border/30 text-cyber-accent{% else %}text-gray-400{% endif %} pl-6">Planning</a>{% endif %}
|
||||
{% if p.campaigns in ('edit', 'admin') %}<a href="/assignments" class="block px-3 py-1.5 rounded-md text-xs hover:bg-cyber-border/30 {% if 'assignments' in path %}bg-cyber-border/30 text-cyber-accent{% else %}text-gray-400{% endif %} pl-6">Assignation</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.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 #}
|
||||
{% if p.campaigns or p.quickwin %}
|
||||
<div>
|
||||
<button @click="toggleSub('quickwin')" class="w-full flex justify-between items-center px-3 py-1.5 rounded-md text-xs hover:bg-cyber-border/30 pl-6 {% if 'quickwin' in path %}text-cyber-accent{% else %}text-gray-400{% endif %}">
|
||||
<span>QuickWin</span>
|
||||
<span x-text="subOpen === 'quickwin' ? '▾' : '▸'" class="text-xs opacity-60"></span>
|
||||
</button>
|
||||
<div x-show="subOpen === 'quickwin'" x-cloak class="space-y-1">
|
||||
<a href="/quickwin" style="padding-left:3rem" class="block py-1 pr-3 rounded-md text-xs hover:bg-cyber-border/30 {% if 'quickwin' in path and 'config' not in path and 'correspondance' not in path %}bg-cyber-border/30 text-cyber-accent{% else %}text-gray-500{% endif %}">Vue d'ensemble</a>
|
||||
{% if p.quickwin in ('edit', 'admin') or p.campaigns in ('edit', 'admin') %}
|
||||
<a href="/quickwin/config" style="padding-left:3rem" class="block py-1 pr-3 rounded-md text-xs hover:bg-cyber-border/30 {% if '/quickwin/config' in path %}bg-cyber-border/30 text-cyber-accent{% else %}text-gray-500{% endif %}">Config exclusion</a>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
{# ===== AUDIT (au meme niveau que Patching, repliable) ===== #}
|
||||
{% if p.audit %}
|
||||
<div>
|
||||
<button @click="toggle('audit')" class="w-full flex justify-between items-center px-3 py-2 rounded-md text-sm hover:bg-cyber-border/30 text-cyber-accent font-bold">
|
||||
<span>Audit</span>
|
||||
<span x-text="open === 'audit' ? '▾' : '▸'" class="text-xs"></span>
|
||||
</button>
|
||||
<div x-show="open === 'audit'" x-cloak class="space-y-1 pl-1">
|
||||
<a href="/audit" class="block px-3 py-1.5 rounded-md text-xs hover:bg-cyber-border/30 {% if path == '/audit' %}bg-cyber-border/30 text-cyber-accent{% else %}text-gray-400{% endif %} pl-6">Audit global</a>
|
||||
{% if p.audit in ('edit', 'admin') %}<a href="/audit/specific" class="block px-3 py-1.5 rounded-md text-xs hover:bg-cyber-border/30 {% if 'specific' in path %}bg-cyber-border/30 text-cyber-accent{% else %}text-gray-400{% endif %} pl-6">Spécifique</a>{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
{# ===== QUALYS (groupe repliable) ===== #}
|
||||
{% if p.qualys %}
|
||||
<div>
|
||||
<button @click="toggle('qualys')" class="w-full flex justify-between items-center px-3 py-2 rounded-md text-sm hover:bg-cyber-border/30 text-cyber-accent font-bold">
|
||||
<span>Qualys</span>
|
||||
<span x-text="open === 'qualys' ? '▾' : '▸'" class="text-xs"></span>
|
||||
</button>
|
||||
<div x-show="open === 'qualys'" x-cloak class="space-y-1 pl-1">
|
||||
<a href="/qualys/search" class="block px-3 py-1.5 rounded-md text-xs hover:bg-cyber-border/30 {% if '/qualys/search' in path %}bg-cyber-border/30 text-cyber-accent{% else %}text-gray-400{% endif %} pl-6">Recherche</a>
|
||||
<a href="/qualys/tags" class="block px-3 py-1.5 rounded-md text-xs hover:bg-cyber-border/30 {% if '/qualys/tags' in path and '/qualys/tagsv3' not in path %}bg-cyber-border/30 text-cyber-accent{% else %}text-gray-400{% endif %} pl-6">Tags</a>
|
||||
<a href="/qualys/tagsv3" class="block px-3 py-1.5 rounded-md text-xs hover:bg-cyber-border/30 {% if '/qualys/tagsv3' in path and '/catalog' not in path and '/gap' not in path %}bg-cyber-border/30 text-cyber-accent{% else %}text-gray-400{% endif %} pl-8">↳ Tags V3 (vue)</a>
|
||||
<a href="/qualys/tagsv3/catalog" class="block px-3 py-1.5 rounded-md text-xs hover:bg-cyber-border/30 {% if '/qualys/tagsv3/catalog' in path %}bg-cyber-border/30 text-cyber-accent{% else %}text-gray-400{% endif %} pl-8">↳ Tags V3 (catalogue)</a>
|
||||
<a href="/qualys/tagsv3/gap" class="block px-3 py-1.5 rounded-md text-xs hover:bg-cyber-border/30 {% if '/qualys/tagsv3/gap' in path %}bg-cyber-border/30 text-cyber-accent{% else %}text-gray-400{% endif %} pl-8">↳ Tags V3 (gap)</a>
|
||||
<a href="/qualys/agents" class="block px-3 py-1.5 rounded-md text-xs hover:bg-cyber-border/30 {% if 'agents' in path and 'deploy' not in path %}bg-cyber-border/30 text-cyber-accent{% else %}text-gray-400{% endif %} pl-6">Agents</a>
|
||||
{% if p.qualys in ('edit', 'admin') %}<a href="/qualys/deploy" class="block px-3 py-1.5 rounded-md text-xs hover:bg-cyber-border/30 {% if 'deploy' in path %}bg-cyber-border/30 text-cyber-accent{% else %}text-gray-400{% endif %} pl-6">Déployer Agent</a>{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
{# ===== ADMIN (groupe repliable) ===== #}
|
||||
{% if p.users or p.settings or p.servers or p.contacts %}
|
||||
<div>
|
||||
<button @click="toggle('admin')" class="w-full flex justify-between items-center px-3 py-2 rounded-md text-sm hover:bg-cyber-border/30 text-cyber-accent font-bold">
|
||||
<span>Administration</span>
|
||||
<span x-text="open === 'admin' ? '▾' : '▸'" class="text-xs"></span>
|
||||
</button>
|
||||
<div x-show="open === 'admin'" x-cloak class="space-y-1 pl-1">
|
||||
{% if p.servers or p.contacts %}<a href="/contacts" class="block px-3 py-1.5 rounded-md text-xs hover:bg-cyber-border/30 {% if 'contacts' in path %}bg-cyber-border/30 text-cyber-accent{% else %}text-gray-400{% endif %} pl-6">Contacts</a>{% endif %}
|
||||
{% if p.users %}<a href="/users" class="block px-3 py-1.5 rounded-md text-xs hover:bg-cyber-border/30 {% if path == '/users' or path.startswith('/users/') %}bg-cyber-border/30 text-cyber-accent{% else %}text-gray-400{% endif %} pl-6">Utilisateurs</a>{% endif %}
|
||||
{% if p.settings or p.users %}<a href="/admin/applications" class="block px-3 py-1.5 rounded-md text-xs hover:bg-cyber-border/30 {% if '/admin/applications' in path %}bg-cyber-border/30 text-cyber-accent{% else %}text-gray-400{% endif %} pl-6">Applications</a>{% endif %}
|
||||
{% if p.settings %}<a href="/settings" class="block px-3 py-1.5 rounded-md text-xs hover:bg-cyber-border/30 {% if 'settings' in path %}bg-cyber-border/30 text-cyber-accent{% else %}text-gray-400{% endif %} pl-6">Settings</a>{% endif %}
|
||||
{% if p.settings or p.referentiel %}<a href="/referentiel" class="block px-3 py-1.5 rounded-md text-xs hover:bg-cyber-border/30 {% if 'referentiel' in path %}bg-cyber-border/30 text-cyber-accent{% else %}text-gray-400{% endif %} pl-6">Référentiel</a>{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
{% endif %}
|
||||
</nav>
|
||||
</aside>
|
||||
<main class="flex-1 flex flex-col overflow-hidden">
|
||||
<!-- Top bar -->
|
||||
<header class="flex items-center justify-end px-6 py-2 border-b border-cyber-border bg-cyber-card">
|
||||
<div class="flex items-center gap-3">
|
||||
<span class="text-sm text-cyber-accent font-medium">{{ user.display or user.sub }}</span>
|
||||
{% if user.auth == 'ldap' %}<span class="text-xs text-gray-500">(AD)</span>{% endif %}
|
||||
<span class="text-xs text-gray-500">·</span>
|
||||
<span class="text-xs text-gray-400">{{ user.sub }}</span>
|
||||
<span class="badge badge-blue">{{ user.role }}</span>
|
||||
<a href="/logout" class="btn-sm bg-cyber-border text-gray-300 hover:bg-red-900/40 hover:text-cyber-red transition-colors">Deconnexion</a>
|
||||
</div>
|
||||
</header>
|
||||
<div class="flex flex-1 overflow-hidden">
|
||||
<div class="flex-1 p-6 overflow-auto" id="main-content">
|
||||
{% block content %}{% endblock %}
|
||||
</div>
|
||||
<div id="detail-panel" class="w-0 overflow-hidden transition-all duration-300 border-l border-cyber-border bg-cyber-card">
|
||||
</div>
|
||||
</div>
|
||||
</main>
|
||||
</div>
|
||||
{% else %}
|
||||
{% block fullpage %}{% endblock %}
|
||||
{% endif %}
|
||||
<div id="toast-container"></div>
|
||||
<!-- Overlay chargement -->
|
||||
<div id="loading-overlay" style="display:none; position:fixed; top:0; left:0; width:100%; height:100%; background:rgba(10,14,23,0.85); z-index:9999; justify-content:center; align-items:center;">
|
||||
<div style="text-align:center">
|
||||
<div style="border:3px solid #1e3a5f; border-top:3px solid #00d4ff; border-radius:50%; width:40px; height:40px; animation:spin 1s linear infinite; margin:0 auto 16px"></div>
|
||||
<div id="loading-msg" style="color:#00d4ff; font-size:14px; font-weight:600">Opération en cours...</div>
|
||||
<div id="loading-sub" style="color:#94a3b8; font-size:12px; margin-top:6px"></div>
|
||||
</div>
|
||||
</div>
|
||||
<style>@keyframes spin { 0% { transform: rotate(0deg); } 100% { transform: rotate(360deg); } }</style>
|
||||
<script>
|
||||
function showLoading(msg, sub) {
|
||||
document.getElementById('loading-msg').textContent = msg || 'Opération en cours...';
|
||||
document.getElementById('loading-sub').textContent = sub || '';
|
||||
document.getElementById('loading-overlay').style.display = 'flex';
|
||||
}
|
||||
function hideLoading() { document.getElementById('loading-overlay').style.display = 'none'; }
|
||||
// Auto-attach: tout bouton avec data-loading affiche l'overlay au clic
|
||||
document.addEventListener('DOMContentLoaded', function() {
|
||||
document.querySelectorAll('[data-loading]').forEach(function(btn) {
|
||||
btn.addEventListener('click', function(e) {
|
||||
var parts = (btn.dataset.loading || 'Opération en cours...|').split('|');
|
||||
showLoading(parts[0], parts[1] || '');
|
||||
});
|
||||
});
|
||||
});
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
|
||||
139
app/templates/patch_history.html
Normal file
139
app/templates/patch_history.html
Normal 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 %}
|
||||
Loading…
Reference in New Issue
Block a user