Script per a generar informes PDF/HTML d’estat del sistema
Generar informes d’ estat del sistema que es puguin consultar ràpidament o enviar a altres equips sense haver d’ explicar cada vegada el mateix és una cosa que es torna necessària en qualsevol entorn amb més d’ un servidor i més d’ una persona involucrada. Tant se val si estàs a un datacenter, al cloud o a una xarxa híbrida: quan hi ha alertes, canvis de configuració, auditories o simples revisions de manteniment, comptar amb un informe automatitzat que resumeixi l’essencial ajuda a reduir friccions i errors.
Això no és nou, però continua estant mal resolt en molts llocs. Sovint, el sistema de reporting queda en segon pla o es fa com un nyap puntual. En altres casos, es complica de més: s’introdueix Prometheus, s’exporta a Grafana, es munta una cadena CI/CD només per acabar generant un HTML amb informació que podries haver extret amb 3 comandaments ben posats.
Aquest article parteix d’una necessitat concreta: generar un informe periòdic (diari/setmanal) de l’estat del sistema que es pugui consultar a HTML o PDF, de forma local o centralitzada, amb dades rellevants, sense dependre d’una infraestructura de monitoratge extern, i que es pugui executar des de cron sense generar escombraries innecessàries ni alertes falses.
Requisits
Abans d’entrar en detalls tècnics, val la pena acotar l’abast. L’ objectiu de l’ script és generar un informe amb dades com:
- Ús de disc per mountpoint
- Ús de CPU i memòria
- Estat de serveis crítics
- Esdeveniments rellevants del journal/syslog
- Temps des de l’últim reinici
- Estat dels backups (si n’hi ha)
- Comprovació d’integritat bàsica (checksums, processos en zombie, etc.)
Tot això sense requerir dependències externes complexes. Python i Bash estan bé. El que no volem és haver d’aixecar contenidors per a treure un informe.
Llenguatge i stack base
El més pràctic és una barreja de:
- Python 3.8 +
- Jinja2 per generar HTML de forma llegible
- WeasyPrint per convertir HTML en PDF si es vol
- Shell per extreure mètriques amb comandaments estàndard (df, uptime, systemctl, etc.)
Python perquè permet estructurar el codi i tractar errors amb més claredat. Jinja2 perquè separar presentació i dades fa més fàcil mantenir els informes. I WeasyPrint perquè evita haver d’instal·lar wkhtmltopdf, que sempre dona algun problema amb versions o fonts.
Estructura de l’ script
L’ estructura que millor ha funcionat és la següent:
Informe del sistema/
├── report.py # Script principal
├── templates/
│ └── report.html.j2 # Template base
├── static/
│ └── style.css # Estils per a HTML i PDF
└── data_collectors/
├── disk.py
├── memory.py
├── services.py
└── logs.py
Separar els “data collectors” en mòduls diferents evita el típic script-monstre que es converteix en il·legible als sis mesos. Cada mòdul exposa una funció collect() que retorna un diccionari. El report.py central els anomena tots, afegeix les dades i renderitza.
Una crida típica en report.py seria una cosa així:
from data_collectors import disk, memory, services, logs
report_data = {
'disk': disk.collect(),
'memory': memory.collect(),
'services': services.collect(),
'logs': logs.collect(),
...
}
Errors comuns que convé evitar
1. Dependre de l’entorn d’execució sense fixar-lo
No falla: scripts que funcionen bé en una terminal interactiva i fallen en executar-se des de cron perquè esperen una variable d’entorn que ja no està ($PATH, $LANG, etc.). Solució simple: al wrapper de l’ script, fixar un entorn mínim conegut.
#!/bin/bash
export PATH="/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin"
export LANG="C.UTF-8"
exec /usr/bin/python3 /opt/sysreport/report.py
2. Generar HTML amb concatentiacions
No construeixis HTML a mà amb print()s i cadenes. Tard o d’hora algú fica un > en un missatge del log i trenca el render. Amb Jinja2 pots escapar correctament les dades, mantenir l’estructura neta i reutilitzar estils. A més, si el template falla, l’error és més clar.
3. Subestimar la sortida dels comandaments
No assumeixes que df o free sempre retornen el que esperes. En servidors amb noms llargs, volums muntats sobre rutes estranyes o sistemes tipus overlayfs, les columnes canvien. El més segur és parsejar sortides amb psutil (a Python), o fer servir –output per a columnes fixes en comandaments com df.
Exemple amb df a Bash:
df -h --output=source,size,used,avail,pcent,target
I a Python:
import psutil
def collect():
data = []
for part in psutil.disk_partitions(all=False):
usage = psutil.disk_usage(part.mountpoint)
data.append({
'mount': part.mountpoint,
'total': usage.total,
'used': usage.used,
'free': usage.free,
'percent': usage.percent
})
return data
PDF sí, però només si ho necessites
Convé tenir el PDF com a opció, però no generar-lo sempre. Triga més, depèn de fonts i fins i tot pot fallar si hi ha problemes de xarxa (per exemple, si el CSS apunta a fonts externes). Una forma segura és empaquetar el CSS i generar el PDF només si es passa un flag.
python3 report.py --pdf
I dins l’ script:
from weasyprint import HTML
if args.pdf:
HTML('output/report.html').write_pdf('output/report.pdf')
Què incloure en l’informe
El contingut que sovint resulta més útil:
- Taula de particions amb color per llindar (>85% en groc, >95% en vermell)
- Load average i ús de CPU (últims 15 min) comparat amb el nombre de cores
- RAM total, feta servir, swap i buffers/cache per separat (no agrupar)
- Estat de serveis clau (sshd, nginx, backup, fail2ban, etc.)
- Últims N logs crítics (journalctl -p 3 -n 20)
- Temps d’ uptime
- Data de l’ últim boot
- Resultat de l’últim backup o tasca crítica (si hi ha logs o arxius d’estat)
Eviteu incloure:
- Tots els processos corrent (és soroll)
- IPs o MACs si no hi ha canvis rellevants
- Logs complets de serveis (millor enllaçar o mostrar només errors recents)
- Temperatures o sensors, llevat que el maquinari ho justifiqui
Suggeriments operatius
- Logs independents: Cada execució de l’ script genera un log amb timestamp. Així pots revisar errors sense haver d’anar al journal.
- Color semàntic per CSS: Fes servir classes com .ok, .warn, .fail en lloc d’acolorir des de l’script. Així pots canviar llindars o colors sense tocar el codi.
- Variables en un YAML extern: Llindars, llista de serveis, rutes. No hi posis res hardcoded que puguis necessitar ajustar des de fora.
- Sortida amb resum en text pla: Per a sistemes sense interfície gràfica (p. ex., enviar un resum per correu), un bloc de text amb el més bàsic sempre és ben rebut. Una cosa tipus:
[Sysreport] node1.example.net - 2025-07-20
Disk /var at 92% [WARNING]
CPU load 7.8 / 8 cores [OK]
Memory 83% used (14.3GB / 16GB) [OK]
Services: sshd [OK], backup [FAIL], fail2ban [OK]
Last errors:
- Jul 20 03:12:54 kernel: ata2.00: status: { DRDY ERR }
- Jul 20 03:12:54 kernel: ata2.00: error: { UNC }
Això es pot enganxar a qualsevol part i no necessita visor web.
report.py: Script principal
Anem al nucli. Aquest és l’arxiu que orquestra tot. S’ encarrega de:
- Carregar la configuració (YAML o JSON)
- Executar els col·lectors
- Renderitzar la plantilla HTML
- Generar el PDF (opcional)
- Registrar errors
Versió simplificada però completa:
#!/usr/bin/env python3
import argparse
import os
import sys
import logging
from datetime import datetime
from jinja2 import Environment, FileSystemLoader, select_autoescape
from weasyprint import HTML
import importlib
import yaml
BASE_DIR = os.path.dirname(os.path.abspath(__file__))
OUTPUT_DIR = os.path.join(BASE_DIR, 'output')
TEMPLATE_DIR = os.path.join(BASE_DIR, 'templates')
CONFIG_PATH = os.path.join(BASE_DIR, 'config.yaml')
LOG_FILE = os.path.join(BASE_DIR, 'logs', f"report_{datetime.now().strftime('%Y%m%d')}.log")
logging.basicConfig(filename=LOG_FILE, level=logging.INFO)
def load_config():
with open(CONFIG_PATH, 'r') as f:
return yaml.safe_load(f)
def collect_all(config):
data = {}
modules = config.get('modules', [])
for module_name in modules:
try:
mod = importlib.import_module(f"data_collectors.{module_name}")
data[module_name] = mod.collect(config.get(module_name, {}))
except Exception as e:
logging.error(f"Error collecting from {module_name}: {e}")
data[module_name] = {'error': str(e)}
return data
def render_html(data, output_path):
env = Environment(
loader=FileSystemLoader(TEMPLATE_DIR),
autoescape=select_autoescape(['html', 'xml'])
)
template = env.get_template("report.html.j2")
html_content = template.render(data=data, generated=datetime.now())
with open(output_path, 'w') as f:
f.write(html_content)
return output_path
def generate_pdf(html_path, pdf_path):
HTML(html_path).write_pdf(pdf_path)
def main():
parser = argparse.ArgumentParser()
parser.add_argument('--pdf', action='store_true', help="Generate PDF version")
args = parser.parse_args()
os.makedirs(OUTPUT_DIR, exist_ok=True)
os.makedirs(os.path.join(BASE_DIR, 'logs'), exist_ok=True)
config = load_config()
data = collect_all(config)
html_file = os.path.join(OUTPUT_DIR, f"sysreport_{datetime.now().strftime('%Y%m%d')}.html")
render_html(data, html_file)
if args.pdf:
pdf_file = html_file.replace(".html", ".pdf")
generate_pdf(html_file, pdf_file)
if __name__ == "__main__":
try:
main()
except Exception as e:
logging.exception("Fatal error in report generation")
sys.exit(1)
Aquest script és extensible sense tocar la seva estructura. Cada col·lector és un mòdul aïllat. Si un mòdul falla, logueja l’error però no atura l’informe. Això és clau si s’executa per cron: no vols que falli tot per culpa d’un df trencat.
Exemple de mòdul: disk.py
Un mòdul simple per a obtenir ús de disc amb psutil:
import psutil
def collect(config=None):
mountpoints = config.get('mountpoints', []) if config else []
data = []
for part in psutil.disk_partitions(all=False):
if mountpoints and part.mountpoint not in mountpoints:
continue
try:
usage = psutil.disk_usage(part.mountpoint)
data.append({
'mount': part.mountpoint,
'fstype': part.fstype,
'total': round(usage.total / (1024**3), 2),
'used': round(usage.used / (1024**3), 2),
'percent': usage.percent,
'status': (
'fail' if usage.percent >= 95 else
'warn' if usage.percent >= 85 else
'ok'
)
})
except Exception as e:
data.append({
'mount': part.mountpoint,
'error': str(e),
'status': 'fail'
})
return sorted(data, key=lambda x: x['mount'])
Aquest mòdul filtra si vols controlar quin mountpoints revisar. Marca un estatus segons llindars. Així es poden aplicar colors a la plantilla sense lògica extra.
Plantilla HTML (report.html.j2)
Base mínima per a què funcioni:
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<title>Informe del sistema</title>
<link rel="stylesheet" href="../static/style.css">
</head>
<body>
<h1>Informe d' estat del sistema</h1>
<p>Generat: {{ generated.strftime('%Y-%m-%d %H:%M:%S') }}</p>
{% if data.disk %}
<h2>Ús de disc</h2>
<table>
<thead>
<tr><th>Mount</th><th>Tipo</th><th>Total (GB)</th><th>En ús (GB)</th><th>Ús (%)</th></tr>
</thead>
<tbody>
{% for d in data.disk %}
<tr class="{{ d.status }}">
<td>{{ d.mount }}</td>
<td>{{ d.fstype }}</td>
<td>{{ d.total }}</td>
<td>{{ d.used }}</td>
<td>{{ d.percent }}%</td>
</tr>
{% endfor %}
</tbody>
</table>
{% endif %}
<!-- Més seccions: memòria, serveis, logs -->
</body>
</html>
Y style.css:
body {
font-family: sans-serif;
margin: 2em;
background-color: #f9f9f9;
}
h1, h2 {
color: #333;
}
table {
width: 100%;
border-collapse: collapse;
margin-bottom: 2em;
}
th, td {
border: 1px solid #ccc;
padding: 0.5em;
text-align: left;
}
.ok { background-color: #e6ffe6; }
.warn { background-color: #fff8e6; }
.fail { background-color: #ffe6e6; }
config.yaml: llista de mòduls i paràmetres
Aquest arxiu permet controlar què s’ executa, sense tocar codi.
modules:
- disk
- memory
- services
- logs
disk:
mountpoints:
- /
- /var
- /home
services:
critical:
- sshd
- backup
- nginx
Cron, logs i errors silenciosos
L’ script es pot executar des de cron. Recomanació bàsica:
0 6 * * * /opt/sysreport/wrapper.sh
wrapper.sh:
#!/bin/bash
export PATH="/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin"
export LANG="C.UTF-8"
/usr/bin/python3 /opt/sysreport/report.py --pdf
Si falla, el log amb l’error estarà en logs/report_YYYYMMDD.log. Convé revisar aquests logs un cop a la setmana, o afegir una línia que notifiqui si hi ha errors.
Què més es pot afegir
El que pot funcionar sense sobrecarregar el sistema:
- Darrers paquets instal·lats (via apt list –installed | tail)
- Verificació de hash d’arxius sensibles (/etc/passwd, /etc/ssh/sshd_config)
- Estat del RAID (amb mdadm o lvm)
- Resultats de fail2ban-client status
- Últim backup detectat (ls -lt /var/backups | head -1)
- Diferència horària amb el NTP (timedatectl status)
Tot això pot anar en mòduls separats. No tot ha d’anar en tots els nodes. A la pràctica, tenir l’ script executant-se amb la mateixa estructura en tots els servidors, i que cadascú tingui activats només els mòduls que necessita, permet tenir consistència sense rigidesa.

