Initial import: monitoring, power-management, maintenance, web scripts
This commit is contained in:
@@ -0,0 +1,204 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
Export Hermes Agent metrics to Prometheus textfile collector format.
|
||||
|
||||
Reads ~/.hermes/state.db and writes /var/lib/node_exporter/textfile_collector/hermes.prom.
|
||||
Designed to run as a cron job (every 60s).
|
||||
|
||||
Metrics produced:
|
||||
hermes_sessions_total{source,model} — session count
|
||||
hermes_messages_total{source} — message count
|
||||
hermes_tokens_input_total{source,model} — input tokens
|
||||
hermes_tokens_output_total{source,model} — output tokens
|
||||
hermes_tokens_cache_read_total{source,model} — cache read tokens
|
||||
hermes_tokens_cache_write_total{source,model} — cache write tokens
|
||||
hermes_tokens_reasoning_total{source,model} — reasoning tokens
|
||||
hermes_cost_usd_total{source,model} — estimated cost in USD
|
||||
hermes_api_calls_total{source,model} — API call count
|
||||
hermes_sessions_active{source} — active sessions (not ended)
|
||||
hermes_sessions_ended{source} — ended sessions in period
|
||||
hermes_errors_total{source} — sessions with errors
|
||||
hermes_db_size_bytes — state.db file size
|
||||
hermes_scrape_duration_seconds — how long the scrape took
|
||||
"""
|
||||
|
||||
import json
|
||||
import os
|
||||
import sqlite3
|
||||
import time
|
||||
from collections import defaultdict
|
||||
from pathlib import Path
|
||||
|
||||
# --- Config ----------------------------------------------------------------
|
||||
HERMES_HOME = Path(os.environ.get("HERMES_HOME", os.path.expanduser("~/.hermes")))
|
||||
DB_PATH = HERMES_HOME / "state.db"
|
||||
OUTPUT_PATH = Path("/var/lib/node_exporter/textfile_collector/hermes.prom")
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
# Labels that are safe to expose (no PII)
|
||||
SAFE_SOURCES = {"cli", "webui", "telegram", "discord", "slack", "whatsapp",
|
||||
"signal", "matrix", "teams", "email", "cron", "gateway", "acp"}
|
||||
|
||||
|
||||
def _safe_source(source: str) -> str:
|
||||
"""Normalise source to a known label or 'other'."""
|
||||
s = source.lower().strip()
|
||||
return s if s in SAFE_SOURCES else "other"
|
||||
|
||||
|
||||
def _fmt_metric(name: str, value, labels: dict = None) -> str:
|
||||
"""Format one Prometheus metric line."""
|
||||
if labels:
|
||||
label_str = ",".join(f'{k}="{v}"' for k, v in sorted(labels.items()))
|
||||
return f"{name}{{{label_str}}} {value}"
|
||||
return f"{name} {value}"
|
||||
|
||||
|
||||
def scrape() -> str:
|
||||
"""Read state.db and return Prometheus text."""
|
||||
start = time.monotonic()
|
||||
lines = []
|
||||
lines.append("# HELP hermes_sessions_total Total Hermes sessions created")
|
||||
lines.append("# TYPE hermes_sessions_total counter")
|
||||
lines.append("# HELP hermes_messages_total Total messages across all sessions")
|
||||
lines.append("# TYPE hermes_messages_total counter")
|
||||
lines.append("# HELP hermes_tokens_input_total Total input tokens consumed")
|
||||
lines.append("# TYPE hermes_tokens_input_total counter")
|
||||
lines.append("# HELP hermes_tokens_output_total Total output tokens generated")
|
||||
lines.append("# TYPE hermes_tokens_output_total counter")
|
||||
lines.append("# HELP hermes_tokens_cache_read_total Total cache read tokens")
|
||||
lines.append("# TYPE hermes_tokens_cache_read_total counter")
|
||||
lines.append("# HELP hermes_tokens_cache_write_total Total cache write tokens")
|
||||
lines.append("# TYPE hermes_tokens_cache_write_total counter")
|
||||
lines.append("# HELP hermes_tokens_reasoning_total Total reasoning tokens")
|
||||
lines.append("# TYPE hermes_tokens_reasoning_total counter")
|
||||
lines.append("# HELP hermes_cost_usd_total Total estimated cost in USD")
|
||||
lines.append("# TYPE hermes_cost_usd_total counter")
|
||||
lines.append("# HELP hermes_api_calls_total Total API calls made")
|
||||
lines.append("# TYPE hermes_api_calls_total counter")
|
||||
lines.append("# HELP hermes_sessions_active Currently active sessions")
|
||||
lines.append("# TYPE hermes_sessions_active gauge")
|
||||
lines.append("# HELP hermes_sessions_ended Sessions ended in the last hour")
|
||||
lines.append("# TYPE hermes_sessions_ended gauge")
|
||||
lines.append("# HELP hermes_errors_total Sessions with non-nil end_reason (errors)")
|
||||
lines.append("# TYPE hermes_errors_total counter")
|
||||
lines.append("# HELP hermes_db_size_bytes Size of state.db on disk")
|
||||
lines.append("# TYPE hermes_db_size_bytes gauge")
|
||||
lines.append("# HELP hermes_scrape_duration_seconds Time spent scraping metrics")
|
||||
lines.append("# TYPE hermes_scrape_duration_seconds gauge")
|
||||
|
||||
if not DB_PATH.exists():
|
||||
lines.append(_fmt_metric("hermes_db_size_bytes", 0))
|
||||
lines.append(_fmt_metric("hermes_scrape_duration_seconds", time.monotonic() - start))
|
||||
return "\n".join(lines) + "\n"
|
||||
|
||||
# DB file size
|
||||
lines.append(_fmt_metric("hermes_db_size_bytes", DB_PATH.stat().st_size))
|
||||
|
||||
try:
|
||||
conn = sqlite3.connect(f"file:{DB_PATH}?mode=ro", uri=True)
|
||||
conn.row_factory = sqlite3.Row
|
||||
cur = conn.cursor()
|
||||
|
||||
now = time.time()
|
||||
one_hour_ago = now - 3600
|
||||
|
||||
# --- Per-source, per-model aggregates (all time) ---
|
||||
cur.execute("""
|
||||
SELECT
|
||||
COALESCE(NULLIF(source, ''), 'unknown') AS source,
|
||||
COALESCE(NULLIF(model, ''), 'unknown') AS model,
|
||||
COUNT(*) AS sessions,
|
||||
SUM(message_count) AS messages,
|
||||
SUM(input_tokens) AS input_tokens,
|
||||
SUM(output_tokens) AS output_tokens,
|
||||
SUM(cache_read_tokens) AS cache_read_tokens,
|
||||
SUM(cache_write_tokens) AS cache_write_tokens,
|
||||
SUM(reasoning_tokens) AS reasoning_tokens,
|
||||
SUM(estimated_cost_usd) AS cost,
|
||||
SUM(api_call_count) AS api_calls
|
||||
FROM sessions
|
||||
GROUP BY source, model
|
||||
""")
|
||||
for row in cur.fetchall():
|
||||
src = _safe_source(row["source"])
|
||||
model = row["model"]
|
||||
labels = {"source": src, "model": model}
|
||||
lines.append(_fmt_metric("hermes_sessions_total", row["sessions"] or 0, labels))
|
||||
lines.append(_fmt_metric("hermes_messages_total", row["messages"] or 0, labels))
|
||||
lines.append(_fmt_metric("hermes_tokens_input_total", row["input_tokens"] or 0, labels))
|
||||
lines.append(_fmt_metric("hermes_tokens_output_total", row["output_tokens"] or 0, labels))
|
||||
lines.append(_fmt_metric("hermes_tokens_cache_read_total", row["cache_read_tokens"] or 0, labels))
|
||||
lines.append(_fmt_metric("hermes_tokens_cache_write_total", row["cache_write_tokens"] or 0, labels))
|
||||
lines.append(_fmt_metric("hermes_tokens_reasoning_total", row["reasoning_tokens"] or 0, labels))
|
||||
lines.append(_fmt_metric("hermes_cost_usd_total", row["cost"] or 0, labels))
|
||||
lines.append(_fmt_metric("hermes_api_calls_total", row["api_calls"] or 0, labels))
|
||||
|
||||
# --- Active sessions (not ended) ---
|
||||
cur.execute("""
|
||||
SELECT COALESCE(NULLIF(source, ''), 'unknown') AS source, COUNT(*) AS cnt
|
||||
FROM sessions WHERE ended_at IS NULL
|
||||
GROUP BY source
|
||||
""")
|
||||
for row in cur.fetchall():
|
||||
lines.append(_fmt_metric("hermes_sessions_active", row["cnt"],
|
||||
{"source": _safe_source(row["source"])}))
|
||||
|
||||
# --- Sessions ended in last hour ---
|
||||
cur.execute("""
|
||||
SELECT COALESCE(NULLIF(source, ''), 'unknown') AS source, COUNT(*) AS cnt
|
||||
FROM sessions WHERE ended_at >= ? AND ended_at IS NOT NULL
|
||||
GROUP BY source
|
||||
""", (one_hour_ago,))
|
||||
for row in cur.fetchall():
|
||||
lines.append(_fmt_metric("hermes_sessions_ended", row["cnt"],
|
||||
{"source": _safe_source(row["source"])}))
|
||||
|
||||
# --- Sessions with error end_reason ---
|
||||
cur.execute("""
|
||||
SELECT COALESCE(NULLIF(source, ''), 'unknown') AS source, COUNT(*) AS cnt
|
||||
FROM sessions
|
||||
WHERE end_reason IS NOT NULL
|
||||
AND end_reason NOT IN ('completed', 'user_exit', 'timeout', 'compression_split', '')
|
||||
GROUP BY source
|
||||
""")
|
||||
for row in cur.fetchall():
|
||||
lines.append(_fmt_metric("hermes_errors_total", row["cnt"],
|
||||
{"source": _safe_source(row["source"])}))
|
||||
|
||||
conn.close()
|
||||
except sqlite3.Error as e:
|
||||
lines.append(f"# ERROR scraping state.db: {e}")
|
||||
|
||||
elapsed = time.monotonic() - start
|
||||
lines.append(_fmt_metric("hermes_scrape_duration_seconds", elapsed))
|
||||
return "\n".join(lines) + "\n"
|
||||
|
||||
|
||||
def main():
|
||||
try:
|
||||
output = scrape()
|
||||
OUTPUT_PATH.parent.mkdir(parents=True, exist_ok=True)
|
||||
# Atomic write via temp file + rename
|
||||
tmp = OUTPUT_PATH.with_suffix(".prom.tmp")
|
||||
tmp.write_text(output)
|
||||
tmp.rename(OUTPUT_PATH)
|
||||
except Exception as e:
|
||||
# Write an error metric so Prometheus can alert on it
|
||||
error_output = (
|
||||
"# HELP hermes_scrape_error 1 if the last scrape failed\n"
|
||||
f"# TYPE hermes_scrape_error gauge\n"
|
||||
f"hermes_scrape_error 1.0\n"
|
||||
)
|
||||
try:
|
||||
OUTPUT_PATH.parent.mkdir(parents=True, exist_ok=True)
|
||||
tmp = OUTPUT_PATH.with_suffix(".prom.tmp")
|
||||
tmp.write_text(error_output)
|
||||
tmp.rename(OUTPUT_PATH)
|
||||
except Exception:
|
||||
pass
|
||||
raise
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -0,0 +1,125 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Collecte les métriques PVE (RAM, swap, load, disque, état VMs/CTs) et les
|
||||
ajoute à un fichier CSV mensuel. Conçu pour être appelé toutes les 15 min
|
||||
via cron (no_agent=True)."""
|
||||
|
||||
import csv, os, subprocess, json, sys
|
||||
from datetime import datetime
|
||||
|
||||
OUTPUT_DIR = os.path.expanduser("~/.hermes/cron/output/pve-monitor")
|
||||
os.makedirs(OUTPUT_DIR, exist_ok=True)
|
||||
|
||||
# Fichier CSV mensuel (ex: metrics-2026-07.csv)
|
||||
now = datetime.now()
|
||||
MONTH_CSV = os.path.join(OUTPUT_DIR, f"metrics-{now:%Y-%m}.csv")
|
||||
|
||||
# Champs du CSV (même format que l'ancien système)
|
||||
FIELDS = [
|
||||
"timestamp", "epoch",
|
||||
"mem_total_mb", "mem_used_mb", "mem_avail_mb", "mem_pct",
|
||||
"swap_total_mb", "swap_used_mb", "swap_pct",
|
||||
"load_1", "load_5", "load_15",
|
||||
"disk_total_gb", "disk_used_gb", "disk_avail_gb", "disk_pct",
|
||||
"arc_size_mb",
|
||||
]
|
||||
|
||||
def ssh_cmd(host, cmd):
|
||||
"""Exécute une commande SSH et retourne stdout."""
|
||||
r = subprocess.run(
|
||||
["ssh", "-o", "ConnectTimeout=5", "-o", "StrictHostKeyChecking=no",
|
||||
f"root@{host}", cmd],
|
||||
capture_output=True, text=True, timeout=15
|
||||
)
|
||||
if r.returncode != 0:
|
||||
raise RuntimeError(f"SSH error on {host}: {r.stderr.strip()}")
|
||||
return r.stdout.strip()
|
||||
|
||||
def collect():
|
||||
row = {}
|
||||
row["timestamp"] = now.strftime("%Y-%m-%d %H:%M:%S")
|
||||
row["epoch"] = str(int(now.timestamp()))
|
||||
|
||||
try:
|
||||
# RAM / Swap
|
||||
out = ssh_cmd("192.168.0.20", r"free -m | awk '/^Mem:/{print $2,$3,$7} /^Swap:/{print $2,$3}'")
|
||||
lines = out.split("\n")
|
||||
mem_total, mem_used, mem_avail = lines[0].split()
|
||||
swap_total, swap_used = lines[1].split() if len(lines) > 1 else ("0", "0")
|
||||
row["mem_total_mb"] = mem_total
|
||||
row["mem_used_mb"] = mem_used
|
||||
row["mem_avail_mb"] = mem_avail
|
||||
row["mem_pct"] = f"{float(mem_used) / float(mem_total) * 100:.1f}" if float(mem_total) > 0 else "0"
|
||||
row["swap_total_mb"] = swap_total
|
||||
row["swap_used_mb"] = swap_used
|
||||
row["swap_pct"] = f"{float(swap_used) / float(swap_total) * 100:.1f}" if float(swap_total) > 0 else "0"
|
||||
|
||||
# Load average
|
||||
load = ssh_cmd("192.168.0.20", "cat /proc/loadavg | awk '{print $1,$2,$3}'")
|
||||
l1, l5, l15 = load.split()
|
||||
row["load_1"] = l1
|
||||
row["load_5"] = l5
|
||||
row["load_15"] = l15
|
||||
|
||||
# Disque /
|
||||
disk = ssh_cmd("192.168.0.20", r"df -BG / | awk 'NR==2{print $2,$3,$4,$5}'")
|
||||
parts = disk.replace("G", "").replace("%", "").split()
|
||||
row["disk_total_gb"] = parts[0]
|
||||
row["disk_used_gb"] = parts[1]
|
||||
row["disk_avail_gb"] = parts[2]
|
||||
row["disk_pct"] = parts[3]
|
||||
|
||||
# ARC ZFS
|
||||
arc = ssh_cmd("192.168.0.20", r"cat /proc/spl/kstat/zfs/arcstats 2>/dev/null | grep -E '^size ' | awk '{print $3}' || echo 0")
|
||||
row["arc_size_mb"] = f"{int(arc) // (1024*1024)}" if arc.isdigit() else "0"
|
||||
|
||||
# VMs
|
||||
vm_out = ssh_cmd("192.168.0.20", "qm list 2>/dev/null | awk 'NR>1{print $1,$3}'")
|
||||
for line in vm_out.split("\n"):
|
||||
if not line.strip():
|
||||
continue
|
||||
parts = line.split()
|
||||
vid = parts[0]
|
||||
vstatus = parts[1] if len(parts) > 1 else "unknown"
|
||||
# Récupérer la RAM allouée
|
||||
mem = ssh_cmd("192.168.0.20", f"qm config {vid} 2>/dev/null | grep -i '^memory:' | awk '{{print $2}}' || echo 0")
|
||||
row[f"vm_{vid}_mem_gb"] = f"{int(mem) // 1024}" if mem.isdigit() else "0"
|
||||
row[f"vm_{vid}_status"] = vstatus
|
||||
if f"vm_{vid}_mem_gb" not in FIELDS:
|
||||
FIELDS.append(f"vm_{vid}_mem_gb")
|
||||
if f"vm_{vid}_status" not in FIELDS:
|
||||
FIELDS.append(f"vm_{vid}_status")
|
||||
|
||||
# CTs
|
||||
ct_out = ssh_cmd("192.168.0.20", "pct list 2>/dev/null | awk 'NR>1{print $1,$2}'")
|
||||
for line in ct_out.split("\n"):
|
||||
if not line.strip():
|
||||
continue
|
||||
parts = line.split()
|
||||
cid = parts[0]
|
||||
cstatus = parts[1] if len(parts) > 1 else "unknown"
|
||||
mem = ssh_cmd("192.168.0.20", f"pct config {cid} 2>/dev/null | grep -i '^memory:' | awk '{{print $2}}' || echo 0")
|
||||
row[f"ct_{cid}_mem_gb"] = f"{int(mem) // 1024}" if mem.isdigit() else "0"
|
||||
row[f"ct_{cid}_status"] = cstatus
|
||||
if f"ct_{cid}_mem_gb" not in FIELDS:
|
||||
FIELDS.append(f"ct_{cid}_mem_gb")
|
||||
if f"ct_{cid}_status" not in FIELDS:
|
||||
FIELDS.append(f"ct_{cid}_status")
|
||||
|
||||
except Exception as e:
|
||||
print(f"ERREUR collecte: {e}", file=sys.stderr)
|
||||
# Écrire quand même une ligne avec timestamp pour marquer l'échec
|
||||
for f in FIELDS:
|
||||
row.setdefault(f, "ERROR")
|
||||
|
||||
# Écrire dans le CSV mensuel
|
||||
write_header = not os.path.isfile(MONTH_CSV)
|
||||
with open(MONTH_CSV, "a", newline="") as f:
|
||||
writer = csv.DictWriter(f, fieldnames=FIELDS)
|
||||
if write_header:
|
||||
writer.writeheader()
|
||||
writer.writerow(row)
|
||||
|
||||
print(f"OK {now:%H:%M} — {os.path.basename(MONTH_CSV)} ({len(row)} champs)")
|
||||
|
||||
if __name__ == "__main__":
|
||||
collect()
|
||||
@@ -0,0 +1,248 @@
|
||||
#!/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()
|
||||
Reference in New Issue
Block a user