249 lines
9.2 KiB
Python
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()
|