#!/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()