Files

249 lines
9.2 KiB
Python

#!/usr/bin/env python3
"""Génère un rapport PDF de monitoring PVE pour une période donnée.
Usage:
python3 pve-report.py weekly # 7 derniers jours
python3 pve-report.py monthly # mois en cours complet
python3 pve-report.py range 2026-07-01 2026-07-31 # période custom
Le PDF est écrit dans ~/.hermes/cron/output/pve-monitor/
Le chemin du PDF est imprimé sur stdout."""
import csv, os, sys
from datetime import datetime, timedelta
from reportlab.lib import colors
from reportlab.lib.pagesizes import A4
from reportlab.lib.styles import getSampleStyleSheet, ParagraphStyle
from reportlab.lib.units import mm
from reportlab.platypus import SimpleDocTemplate, Paragraph, Spacer, Table, TableStyle, PageBreak
from reportlab.graphics.shapes import Drawing
from reportlab.graphics.charts.lineplots import LinePlot
OUTPUT_DIR = os.path.expanduser("~/.hermes/cron/output/pve-monitor")
def find_csv():
"""Trouve le CSV mensuel le plus récent dans OUTPUT_DIR."""
files = [f for f in os.listdir(OUTPUT_DIR) if f.startswith("metrics-") and f.endswith(".csv")]
if not files:
return None
files.sort(reverse=True)
return os.path.join(OUTPUT_DIR, files[0])
def load_data(csv_path, start_dt=None, end_dt=None):
"""Charge les lignes du CSV, filtre par période si spécifiée."""
with open(csv_path) as f:
reader = csv.DictReader(f)
rows = list(reader)
if not rows:
return []
if start_dt or end_dt:
filtered = []
for r in rows:
try:
ts = datetime.strptime(r["timestamp"], "%Y-%m-%d %H:%M:%S")
except (ValueError, KeyError):
continue
if start_dt and ts < start_dt:
continue
if end_dt and ts > end_dt:
continue
filtered.append(r)
return filtered
return rows
def safe_float(val, default=0.0):
try:
return float(val)
except (ValueError, TypeError):
return default
def generate_pdf(rows, period_label, pdf_filename):
"""Génère le PDF avec résumé, graphiques et tableau VMs/CTs."""
if len(rows) < 2:
print(f"ERREUR: Pas assez de données ({len(rows)} lignes)", file=sys.stderr)
return None
# Stats
mem_pcts = [safe_float(r.get("mem_pct", "0")) for r in rows]
swap_pcts = [safe_float(r.get("swap_pct", "0")) for r in rows]
loads = [safe_float(r.get("load_1", "0")) for r in rows]
disk_pcts = [safe_float(r.get("disk_pct", "0")) for r in rows]
first_ts = rows[0]["timestamp"]
last_ts = rows[-1]["timestamp"]
pdf_path = os.path.join(OUTPUT_DIR, pdf_filename)
doc = SimpleDocTemplate(
pdf_path, pagesize=A4,
title=f"Rapport Monitoring PVE - {period_label}",
author="Hermes Agent"
)
styles = getSampleStyleSheet()
title_style = ParagraphStyle("Title2", parent=styles["Title"], fontSize=18, spaceAfter=20)
subtitle_style = ParagraphStyle("Sub", parent=styles["Normal"], fontSize=10, textColor=colors.grey)
h2_style = ParagraphStyle("H2", parent=styles["Heading2"], fontSize=14, spaceBefore=20, spaceAfter=10)
normal = styles["Normal"]
elements = []
# Titre
elements.append(Paragraph(f"Rapport Monitoring PVE — {period_label}", title_style))
elements.append(Paragraph(f"Période: {first_ts}{last_ts}", subtitle_style))
elements.append(Paragraph(f"Points collectés: {len(rows)} (toutes les 15 min)", subtitle_style))
elements.append(Spacer(1, 8*mm))
# Résumé
elements.append(Paragraph("Résumé de la période", h2_style))
summary_data = [
["Métrique", "Min", "Moyen", "Max", "Actuel"],
["RAM utilisée",
f"{min(mem_pcts):.1f}%", f"{sum(mem_pcts)/len(mem_pcts):.1f}%",
f"{max(mem_pcts):.1f}%", f"{mem_pcts[-1]:.1f}%"],
["Swap utilisé",
f"{min(swap_pcts):.1f}%", f"{sum(swap_pcts)/len(swap_pcts):.1f}%",
f"{max(swap_pcts):.1f}%", f"{swap_pcts[-1]:.1f}%"],
["Load (1 min)",
f"{min(loads):.2f}", f"{sum(loads)/len(loads):.2f}",
f"{max(loads):.2f}", f"{loads[-1]:.2f}"],
["Disque /",
f"{min(disk_pcts):.1f}%", f"{sum(disk_pcts)/len(disk_pcts):.1f}%",
f"{max(disk_pcts):.1f}%", f"{disk_pcts[-1]:.1f}%"],
]
t = Table(summary_data, colWidths=[80*mm, 30*mm, 30*mm, 30*mm, 30*mm])
t.setStyle(TableStyle([
("BACKGROUND", (0, 0), (-1, 0), colors.HexColor("#1a1a2e")),
("TEXTCOLOR", (0, 0), (-1, 0), colors.white),
("FONTNAME", (0, 0), (-1, 0), "Helvetica-Bold"),
("FONTSIZE", (0, 0), (-1, -1), 9),
("ALIGN", (1, 0), (-1, -1), "CENTER"),
("GRID", (0, 0), (-1, -1), 0.5, colors.grey),
("ROWBACKGROUNDS", (0, 1), (-1, -1), [colors.white, colors.HexColor("#f0f0f5")]),
]))
elements.append(t)
elements.append(Spacer(1, 8*mm))
# Graphique RAM + Swap
elements.append(Paragraph("Évolution RAM et Swap", h2_style))
drawing = Drawing(500, 200)
lp = LinePlot()
lp.x = 50
lp.y = 30
lp.width = 400
lp.height = 150
lp.data = [
(list(range(len(rows))), mem_pcts),
(list(range(len(rows))), swap_pcts),
]
lp.lines[0].strokeColor = colors.HexColor("#4361ee")
lp.lines[0].strokeWidth = 2
lp.lines[1].strokeColor = colors.HexColor("#e63946")
lp.lines[1].strokeWidth = 2
n = len(rows)
step = max(1, n // 5)
lp.xValueAxis.valueSteps = list(range(0, n, step)) + ([n-1] if n > 1 else [])
lp.xValueAxis.labels.boxAnchor = 'ne'
lp.xValueAxis.labels.angle = 45
lp.xValueAxis.labels.fontSize = 7
lp.yValueAxis.valueSteps = [0, 25, 50, 75, 100]
lp.yValueAxis.labels.fontSize = 7
drawing.add(lp)
elements.append(drawing)
elements.append(Paragraph("■ RAM (%) ■ Swap (%)",
ParagraphStyle("Legend", parent=normal, fontSize=8, textColor=colors.grey)))
elements.append(Spacer(1, 5*mm))
# Graphique Load
elements.append(Paragraph("Charge CPU (Load Average 1 min)", h2_style))
drawing2 = Drawing(500, 200)
lp2 = LinePlot()
lp2.x = 50
lp2.y = 30
lp2.width = 400
lp2.height = 150
lp2.data = [(list(range(len(rows))), loads)]
lp2.lines[0].strokeColor = colors.HexColor("#2ec4b6")
lp2.lines[0].strokeWidth = 2
lp2.xValueAxis.valueSteps = list(range(0, n, step)) + ([n-1] if n > 1 else [])
lp2.xValueAxis.labels.boxAnchor = 'ne'
lp2.xValueAxis.labels.angle = 45
lp2.xValueAxis.labels.fontSize = 7
lp2.yValueAxis.labels.fontSize = 7
drawing2.add(lp2)
elements.append(drawing2)
elements.append(Spacer(1, 8*mm))
# Tableau des VMs/CTs (dernière ligne)
elements.append(Paragraph("État des VMs et Conteneurs (fin de période)", h2_style))
last = rows[-1]
vm_ct_headers = ["Ressource", "RAM (GB)", "Statut"]
vm_ct_data = [vm_ct_headers]
for key in sorted(last.keys()):
if key.endswith("_mem_gb"):
rid = key.split("_")[0] + " " + key.split("_")[1]
mem = last.get(key, "N/A")
status_key = key.replace("_mem_gb", "_status")
status = last.get(status_key, "N/A")
vm_ct_data.append([rid, str(mem), status])
t2 = Table(vm_ct_data, colWidths=[60*mm, 40*mm, 40*mm])
t2.setStyle(TableStyle([
("BACKGROUND", (0, 0), (-1, 0), colors.HexColor("#1a1a2e")),
("TEXTCOLOR", (0, 0), (-1, 0), colors.white),
("FONTNAME", (0, 0), (-1, 0), "Helvetica-Bold"),
("FONTSIZE", (0, 0), (-1, -1), 8),
("ALIGN", (1, 0), (-1, -1), "CENTER"),
("GRID", (0, 0), (-1, -1), 0.5, colors.grey),
("ROWBACKGROUNDS", (0, 1), (-1, -1), [colors.white, colors.HexColor("#f0f0f5")]),
]))
elements.append(t2)
elements.append(Spacer(1, 8*mm))
# Footer
elements.append(Paragraph(
f"Généré automatiquement par Hermes Agent le {datetime.now().strftime('%d/%m/%Y à %H:%M')}",
ParagraphStyle("Footer", parent=normal, fontSize=8, textColor=colors.grey, alignment=1)))
doc.build(elements)
print(f"PDF généré: {pdf_path}")
return pdf_path
def main():
if len(sys.argv) < 2:
print("Usage: pve-report.py weekly|monthly", file=sys.stderr)
sys.exit(1)
mode = sys.argv[1]
csv_path = find_csv()
if not csv_path:
print("ERREUR: Aucun fichier CSV trouvé", file=sys.stderr)
sys.exit(1)
now = datetime.now()
if mode == "weekly":
start_dt = now - timedelta(days=7)
end_dt = now
period_label = f"S {start_dt.strftime('%d/%m')}{end_dt.strftime('%d/%m/%Y')}"
pdf_name = f"rapport-pve-hebdo-{now.strftime('%Y-%m-%d')}.pdf"
elif mode == "monthly":
start_dt = now.replace(day=1, hour=0, minute=0, second=0, microsecond=0)
end_dt = now
period_label = f"Mensuel {now.strftime('%B %Y')}"
pdf_name = f"rapport-pve-mensuel-{now.strftime('%Y-%m')}.pdf"
else:
print(f"ERREUR: Mode inconnu '{mode}'", file=sys.stderr)
sys.exit(1)
rows = load_data(csv_path, start_dt, end_dt)
if not rows:
print(f"ERREUR: Aucune donnée pour la période {period_label}", file=sys.stderr)
sys.exit(1)
pdf_path = generate_pdf(rows, period_label, pdf_name)
if pdf_path:
print(pdf_path)
if __name__ == "__main__":
main()