Initial import: monitoring, power-management, maintenance, web scripts

This commit is contained in:
2026-08-05 12:26:50 +02:00
commit 1936ded08f
10 changed files with 1306 additions and 0 deletions
+204
View File
@@ -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()
+125
View File
@@ -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()
+248
View File
@@ -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()