Jenkins + Minio + Prometheus + Grafana v1

This commit is contained in:
2026-09-01 13:42:14 +00:00
commit 05a197b0d0
21 changed files with 2256 additions and 0 deletions
+14
View File
@@ -0,0 +1,14 @@
FROM mcr.microsoft.com/playwright/python:v1.45.0-jammy
WORKDIR /app
# copiamos e instalamos dependencias
COPY requirements.txt .
RUN pip install --no-cache-dir -r requirements.txt
# copiamos el resto del código
COPY . .
# gunicorn con 2 workers y 4 threads por worker
# gunicorn -k gthread -w 2 --threads 4 -b 0.0.0.0:5000 central_api:app
CMD ["gunicorn", "-k", "gthread", "-w", "2", "--threads", "4", "-b", "0.0.0.0:5000", "central_api:app"]
+660
View File
@@ -0,0 +1,660 @@
import os
import io
import requests
import logging
from pathlib import Path
from flask import Flask, request, jsonify, send_file, after_this_request
from pydantic import BaseModel, ValidationError, Field
from typing import Optional, List
from datetime import datetime
import mysql.connector
from mysql.connector import Error
from mysql.connector import pooling
import copy
import uuid
import concurrent.futures
import threading
import time
from flask import render_template_string
import json
from playwright.sync_api import sync_playwright
app = Flask(__name__)
# auxpara compartir multiples workers de Gunicorn
def set_job_status(job_id, status_dict):
try:
with open(f"/tmp/{job_id}.json", "w") as f:
json.dump(status_dict, f)
except Exception as e:
logger.error(f"Error guardando status JSON: {e}")
def get_job_status(job_id):
try:
path = f"/tmp/{job_id}.json"
if os.path.exists(path):
with open(path, "r") as f:
return json.load(f)
except:
pass
return {'status': 'not_found'}
logging.basicConfig(level=logging.INFO, format='%(asctime)s - %(name)s - %(levelname)s - %(message)s')
logger = logging.getLogger(__name__)
# Pydantic para validar los campos
class TransactionSchema(BaseModel):
cliente: str
sco_number: str
sucursal: str
pos_transaction_id: int
started_at: datetime
end_transaction_at: Optional[datetime] = None
closed_at: datetime
status: str
item_count: int = 0
total_amount: float = 0.0
paid_amount: float = 0.0
change_amount: float = 0.0
amounts_reconstructed: bool = False
payment_type: Optional[str] = None
split_payment: bool = False
age_verification_requested: bool = False
age_verification_value: Optional[int] = Field(default=None, gt=0)
bag_item_count: int = 0
# config mysql con .env general
DB_HOST = os.environ.get("DB_HOST")
DB_PORT = int(os.environ.get("DB_PORT", "3306"))
DB_USER = os.environ.get("DB_USER")
DB_PASSWORD = os.environ.get("DB_PASSWORD")
DB_NAME = os.environ.get("DB_NAME")
if not all([DB_HOST, DB_USER, DB_PASSWORD, DB_NAME]):
raise ValueError("Faltan credenciales de MySQL.")
# manejo de db pool dentro de la instancia
db_pool = None
dbconfig = {
"host": DB_HOST,
"port": DB_PORT,
"user": DB_USER,
"password": DB_PASSWORD,
"database": DB_NAME
}
max_retries = 5
for attempt in range(max_retries):
try:
db_pool = pooling.MySQLConnectionPool(
pool_name="sco_pool",
pool_size=10,
pool_reset_session=True,
**dbconfig
)
logger.info("Connection pool de MySQL creado con éxito.")
break
except Error as e:
logger.error(f"Error al crear el pool de conexiones (Intento {attempt + 1}/{max_retries}): {e}")
if attempt < max_retries - 1:
time.sleep(3)
else:
logger.error("No se pudo conectar a MySQL después de varios intentos.")
def get_db_pool():
return db_pool
# Pantalla de carga HTML (PRUEBA grafica)
LOADING_HTML = """
<!DOCTYPE html>
<html lang="es">
<head>
<meta charset="UTF-8">
<title>Generando Reporte...</title>
<style>
body { font-family: 'Segoe UI', Tahoma, Geneva, Verdana, sans-serif; background-color: #111217; color: #fff; display: flex; flex-direction: column; align-items: center; justify-content: center; height: 100vh; margin: 0; }
.spinner { border: 4px solid rgba(255,255,255,0.1); width: 60px; height: 60px; border-radius: 50%; border-left-color: #3274d9; animation: spin 1s linear infinite; margin-bottom: 25px; }
@keyframes spin { 0% { transform: rotate(0deg); } 100% { transform: rotate(360deg); } }
.text { font-size: 1.4rem; font-weight: 400; text-align: center; }
.subtext { font-size: 1rem; color: #8e8e8e; margin-top: 15px; text-align: center; max-width: 400px;}
</style>
</head>
<body>
<div class="spinner"></div>
<div class="text">Construyendo su reporte PDF...</div>
<div class="subtext">Estamos recolectando y procesando los datos de Grafana. Esto tomará entre 1 y 4 minutos dependiendo del tamaño. Por favor, <b>no cierre esta ventana</b>.</div>
<script>
const jobId = "{{job_id}}";
const checkStatus = async () => {
try {
const res = await fetch(`/api/v1/export/status/${jobId}`);
const data = await res.json();
if (data.status === 'done') {
// Redirigir a la descarga real
window.location.href = `/api/v1/export/download/${jobId}`;
// Actualizamos el mensaje
document.body.innerHTML = '<div class="spinner" style="border-left-color: #4caf50; animation: none; transform: rotate(45deg);"></div><div class="text" style="color:#4caf50;">¡Reporte generado con éxito!</div><div class="subtext">Tu descarga ha comenzado. Esta pestaña se cerrará automáticamente...</div>';
// Esperamos 2.5 segundos para asegurar la descarga y cerramos la ventana
setTimeout(() => {
window.close(); // Intenta cerrar la ventana actual
// Fallback de seguridad: si Chrome bloquea el cierre (por no haber sido abierta vía target="_blank" JS), regresamos a Grafana.
setTimeout(() => {
if (document.referrer) {
window.location.href = document.referrer;
} else {
window.history.back();
}
}, 1500);
}, 2500);
} else if (data.status === 'error') {
document.body.innerHTML = '<div class="text" style="color:#e02f44;">Ocurrió un error al generar el reporte:</div><div class="subtext">' + (data.error || 'Fallo desconocido') + '</div>';
} else {
setTimeout(checkStatus, 5000); // Polling cada 5 seg
}
} catch (e) {
setTimeout(checkStatus, 5000);
}
};
setTimeout(checkStatus, 2000);
</script>
</body>
</html>
"""
def background_export_task(job_id, dashboard_uid, dashboard_name, cliente, grafana_vars, time_from, time_to, tz, grafana_session):
logger.info(f"[{job_id}] Iniciando hilo de trabajo en segundo plano (Modo Playwright Headless)...")
set_job_status(job_id, {'status': 'processing'})
try:
# URL hacia la UI real de Grafana
target_url = f"http://grafana:3000/d/{dashboard_uid}?orgId=1"
# Inyectamos dinámicamente TODAS las variables que empiezan con var-
for var_key, var_values in grafana_vars.items():
for val in var_values:
target_url += f"&{var_key}={val}"
# Forzamos timezone
target_url += "&kiosk=1&hideNav=1&timezone=browser"
if time_from: target_url += f"&from={time_from}"
if time_to: target_url += f"&to={time_to}"
with sync_playwright() as p:
# Apagamos GPU y WebGL para evitar limites de hardware
browser = p.chromium.launch(headless=True, args=['--disable-gpu', '--disable-dev-shm-usage', '--disable-webgl'])
context = browser.new_context(
viewport={'width': 1920, 'height': 1080},
ignore_https_errors=True,
timezone_id=tz,
)
if grafana_session:
context.add_cookies([{
'name': 'grafana_session',
'value': grafana_session,
'domain': 'grafana', # host interno del contenedor
'path': '/'
}])
logger.info(f"[{job_id}] Clonación de Cookie exitosa.")
else:
logger.warning(f"[{job_id}] ERROR: No se recibió cookie de sesión desde. Petición rechazada por Grafana.")
page = context.new_page()
# Bloquear Websockets
page.route("**/*api/live/ws*", lambda route: route.abort())
page.on("pageerror", lambda err: logger.error(f"[{job_id}] Playwright Page Error: {err}"))
# escuchamos dinamicamente las querys
active_queries = [0]
def handle_request(request):
if "api/ds/query" in request.url:
active_queries[0] += 1
def handle_request_finished(request):
if "api/ds/query" in request.url:
active_queries[0] = max(0, active_queries[0] - 1)
def handle_request_failed(request):
if "api/ds/query" in request.url:
active_queries[0] = max(0, active_queries[0] - 1)
page.on("request", handle_request)
page.on("requestfinished", handle_request_finished)
page.on("requestfailed", handle_request_failed)
page.goto(target_url, timeout=0, wait_until='load')
logger.info(f"[{job_id}] Validando permisos de acceso al dashboard...")
# Dar tiempo a que React termine sus peticiones
try:
page.wait_for_load_state("networkidle", timeout=10000)
except:
pass
page.wait_for_timeout(2000)
if "/login" in page.url or page.get_by_placeholder("email or username").is_visible():
error_msg = "Acceso Denegado - No tienes permisos para exportar este Dashboard o tu sesion caducó."
logger.error(f"[{job_id}] {error_msg}")
raise Exception(error_msg)
# check "Forbidden" o "Access denied" en el contenido de la página
page_text = page.locator("body").inner_text().lower()
if "failed to load dashboard" in page_text or "forbidden" in page_text or "access denied" in page_text or "not found" in page_text:
error_msg = "Error 404 - El dashboard solicitado no existe o fue eliminado."
logger.error(f"[{job_id}] Intento de acceso a dashboard sin permisos.")
raise Exception(error_msg)
logger.info(f"[{job_id}] Acceso concedido. Esperando 10s para la primera fila de paneles...")
page.wait_for_timeout(10000)
try:
# # Localizamos ROWS colapsadas y las abrimos
page.evaluate("""
const buttons = document.querySelectorAll('[aria-expanded="false"]');
buttons.forEach(btn => {
const testId = btn.getAttribute('data-testid') || '';
const className = btn.className || '';
const parentClass = (btn.parentElement && btn.parentElement.className) || '';
const ariaLabel = btn.getAttribute('aria-label') || '';
// Es una fila si cumple alguna de estas heurísticas de Grafana:
const isRow = testId.includes('dashboard-row') ||
className.includes('row') ||
parentClass.includes('row') ||
ariaLabel.toLowerCase().includes('row') ||
ariaLabel.toLowerCase().includes('fila') ||
btn.offsetWidth > 500; // Las barras de fila ocupan mucho ancho
if (isRow) {
btn.setAttribute('data-pdf-row', 'true');
}
});
// Soporte legacy para Grafana 8
document.querySelectorAll('.dashboard-row--collapsed').forEach(div => {
div.setAttribute('data-pdf-row', 'true');
});
""")
# Recolectamos todas las filas
row_elements = page.locator('[data-pdf-row="true"]').all()
rows_to_click = []
for el in row_elements:
if el.is_visible():
box = el.bounding_box()
if box:
rows_to_click.append({
'el': el,
'y': box['y'],
'text': el.inner_text().strip()[:40]
})
# ORDENAMOS de mayor a menor (important)
rows_to_click.sort(key=lambda item: item['y'], reverse=True)
logger.info(f"[{job_id}] Se encontraron {len(rows_to_click)} filas dinamicas para expandir.")
for item in rows_to_click:
logger.info(f"[{job_id}] Expandiendo fila interactiva: {item['text']}")
item['el'].scroll_into_view_if_needed()
page.wait_for_timeout(500)
item['el'].click(force=True)
page.wait_for_timeout(1000)
except Exception as e:
logger.warning(f"[{job_id}] Error secundario al hacer click en filas: {e}")
# 5 segundos para que Grafana renderize
page.wait_for_timeout(5000)
total_height = page.evaluate("document.body.scrollHeight")
# salto de 1080px para que las querys carguen tranquilas
viewport_height = 1080
current_y = total_height
while current_y > 0:
page.evaluate(f"window.scrollTo(0, {current_y})")
logger.info(f"[{job_id}] Scrolleamos a {current_y}px...")
# 2s iniciales para que grafana/react reaccione
page.wait_for_timeout(2000)
# Espera reactiva ---> vigila hasta que las queries activas terminen.
wait_time = 0
while active_queries[0] > 0 and wait_time < 15000:
page.wait_for_timeout(1000)
wait_time += 1000
if wait_time % 5000 == 0:
logger.info(f"[{job_id}] Waiting DB... Queries actives: {active_queries[0]}")
current_y -= viewport_height
page.evaluate("window.scrollTo(0, 0)")
logger.info(f"[{job_id}] Tope alcanzado. Esperando ultima resolución.")
page.wait_for_timeout(2000)
wait_time = 0
# tiempo de seguridad por precaucion
while active_queries[0] > 0 and wait_time < 45000:
page.wait_for_timeout(1000)
wait_time += 1000
if wait_time % 5000 == 0:
logger.info(f"[{job_id}] Tope - Still waiting DB... Queries actives: {active_queries[0]}")
logger.info(f"[{job_id}] Cambiando a Viewport original de la pagina de: {total_height}px.")
final_height = min(total_height, 25000)
page.set_viewport_size({'width': 1920, 'height': final_height})
page.evaluate("window.scrollTo(0, 0)")
logger.info(f"[{job_id}] 15000ms para que grafana procese...")
page.wait_for_timeout(15000)
# checkeamos si NO ESTAMOS EN LOGIN == tenemos cookies validas?
if "/login" in page.url or page.get_by_placeholder("email or username").is_visible():
error_msg = "Acceso Denegado - Sesión caducada o rechazada en el último momento."
logger.error(f"[{job_id}] {error_msg}")
raise Exception(error_msg)
# sacamos titulo para que no diga: " - Dashboards - Grafana"
page.evaluate("document.title = document.title.replace(' - Dashboards - Grafana', '');")
# Eliminar todos los (href) para que no sean clickeables en el PDF generado
logger.info(f"[{job_id}] Eliminando enlaces interactivos del HTML...")
page.evaluate("""
document.querySelectorAll('a[href]').forEach(el => {
el.removeAttribute('href');
});
""")
logger.info(f"[{job_id}] Generando PDF...")
pdf_filepath = f"/tmp/{job_id}.pdf"
# Forzamos a Chrome a creer que sigue en una pantalla usando CSS @media print
# Esto evita que Grafana oculte el reloj + botones
page.emulate_media(media="screen")
page.pdf(
path=pdf_filepath,
print_background=True,
width="1920px",
height=f"{final_height}px",
margin={"top": "0", "right": "0", "bottom": "0", "left": "0"},
page_ranges="1"
)
browser.close()
logger.info(f"[{job_id}] Work Done! Guardado en: {pdf_filepath}")
import re
# convertimos a lowercase el 'dashboard_name' para que el archivo salga mejor
safe_dashboard_name = re.sub(r'[^a-z0-9]+', '-', dashboard_name.lower()).strip('-')
filename_final = f"{safe_dashboard_name}.pdf"
set_job_status(job_id, {'status': 'done', 'filepath': pdf_filepath, 'filename': filename_final})
except Exception as e:
logger.error(f"[{job_id}] FATAL ERROR en Playwright: {str(e)}")
set_job_status(job_id, {'status': 'error', 'error': str(e)})
@app.route('/api/v1/export/image', methods=['GET'])
def export_image_trigger():
# Capturar TODAS las variables (empiezan con var-)
grafana_vars = {}
for key in request.args.keys():
if key.startswith('var-'):
grafana_vars[key] = request.args.getlist(key)
cliente = request.args.get('cliente')
if not cliente and 'var-cliente' in grafana_vars:
cliente = grafana_vars['var-cliente'][0]
dashboard_uid = request.args.get('dashboard_uid')
dashboard_name = request.args.get('dashboard_name', 'Masivo')
time_from = request.args.get('from')
time_to = request.args.get('to')
tz = request.args.get('tz', 'America/Argentina/Buenos_Aires')
if "${" in tz or not tz:
tz = 'America/Argentina/Buenos_Aires'
grafana_session = request.cookies.get('grafana_session')
if not cliente or not dashboard_uid:
return jsonify({'error': 'Faltan parametros obligatorios'}), 400
# Crear el ticket asincrono
job_id = uuid.uuid4().hex
set_job_status(job_id, {'status': 'starting'})
# Lanzar el thread
thread = threading.Thread(
target=background_export_task,
args=(job_id, dashboard_uid, dashboard_name, cliente, grafana_vars, time_from, time_to, tz, grafana_session)
)
thread.start()
# Responder INMEDIATAMENTE a el usuario asi no falla cloudflare
html_content = LOADING_HTML.replace("{{job_id}}", job_id)
return render_template_string(html_content)
@app.route('/api/v1/export/status/<job_id>', methods=['GET'])
def export_status(job_id):
status_info = get_job_status(job_id)
return jsonify(status_info)
@app.route('/api/v1/export/download/<job_id>', methods=['GET'])
def export_download(job_id):
status_info = get_job_status(job_id)
if status_info.get('status') != 'done':
return "Archivo no encontrado o no está listo", 404
filepath = status_info.get('filepath')
download_name = status_info.get('filename', 'reporte.pdf')
try:
# delete del archivo after download
@after_this_request
def remove_file(response):
try:
os.remove(filepath)
os.remove(f"/tmp/{job_id}.json")
logger.info(f"[{job_id}] Limpieza temporal exitosa tras descarga.")
except Exception as e:
logger.error(f"[{job_id}] Error borrando temp files: {e}")
return response
return send_file(
filepath,
mimetype='application/pdf',
as_attachment=True,
download_name=download_name
)
except Exception as e:
return str(e), 500
@app.route('/api/v1/transactions', methods=['POST'])
def receive_transaction():
data = request.get_json()
if not data or not isinstance(data, list):
return jsonify({"error": "Se esperaba un array JSON de transacciones"}), 400
# Pydantic validation
valid_transactions = []
failed_messages = []
for i, item in enumerate(data):
try:
valid_transactions.append(TransactionSchema(**item))
except ValidationError as e:
logger.warning(f"JSON rechazado en índice {i}: {e.errors()}")
tx_id = item.get("pos_transaction_id", f"índice {i}")
failed_messages.append(f"La transaccion con id {tx_id} no cumple el formato esperado: {str(e.errors())}")
if not valid_transactions:
return jsonify({
"status": "error",
"data": "Todas las transacciones fallaron la validación de formato.\n" + "\n".join(failed_messages)
}), 400
pool = get_db_pool()
if not pool:
return jsonify({"error": "Error interno del servidor"}), 500
# Extraemos una conexion disponible del pool
try:
connection = pool.get_connection()
except Error as e:
logger.error(f"Error obteniendo conexión del pool: {e}")
return jsonify({"error": "Error de base de datos"}), 500
cursor = connection.cursor()
insert_query = """
INSERT INTO transactions (
cliente, sco_number, sucursal, pos_transaction_id, started_at, end_transaction_at,
closed_at, status, item_count, total_amount, paid_amount, change_amount,
amounts_reconstructed, payment_type, split_payment, age_verification_requested,
age_verification_value, bag_item_count
) VALUES (
%s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s
) ON DUPLICATE KEY UPDATE
started_at = VALUES(started_at),
end_transaction_at = VALUES(end_transaction_at),
closed_at = VALUES(closed_at),
status = VALUES(status),
item_count = VALUES(item_count),
total_amount = VALUES(total_amount),
paid_amount = VALUES(paid_amount),
change_amount = VALUES(change_amount),
amounts_reconstructed = VALUES(amounts_reconstructed),
payment_type = VALUES(payment_type),
split_payment = VALUES(split_payment),
age_verification_requested = VALUES(age_verification_requested),
age_verification_value = VALUES(age_verification_value),
bag_item_count = VALUES(bag_item_count)
"""
values_list = []
for transaction in valid_transactions:
# Forzamos lower case en Python también como doble capa de seguridad
values_list.append((
transaction.cliente.lower(),
transaction.sco_number,
transaction.sucursal,
transaction.pos_transaction_id,
transaction.started_at,
transaction.end_transaction_at,
transaction.closed_at,
transaction.status,
transaction.item_count,
transaction.total_amount,
transaction.paid_amount,
transaction.change_amount,
transaction.amounts_reconstructed,
transaction.payment_type,
transaction.split_payment,
transaction.age_verification_requested,
transaction.age_verification_value,
transaction.bag_item_count
))
try:
cursor.executemany(insert_query, values_list)
connection.commit()
logger.info(f"Bulk Upsert exitoso: {len(valid_transactions)} guardadas. {len(failed_messages)} fallidas.")
response_data = "Batch procesado correctamente."
if failed_messages:
response_data = "\n".join(failed_messages)
return jsonify({
"status": "ok",
"data": response_data
}), 200
except Error as e:
logger.error(f"Error ejecutando query MySQL: {e}")
connection.rollback()
return jsonify({"error": "Error al guardar en la base de datos"}), 500
finally:
if cursor:
cursor.close()
if connection:
# no cerramos la conexión - la devolvemos al pool
connection.close()
ALERTS_LOG_FILE = os.environ.get("ALERTS_LOG_FILE", "/data/alerts/alerts.txt")
@app.route('/api/v1/alerts/webhook', methods=['POST'])
def receive_grafana_alert():
try:
data = request.get_json(force=True)
if not data:
return jsonify({"status": "error", "message": "No JSON payload received"}), 400
now_str = datetime.now().strftime("%Y-%m-%d %H:%M:%S")
status = data.get("status", "unknown").upper()
title = data.get("title", data.get("groupLabels", {}).get("alertname", "Sin Titulo"))
alerts = data.get("alerts", [])
# Asegurar que el directorio exista
log_path = Path(ALERTS_LOG_FILE)
log_path.parent.mkdir(parents=True, exist_ok=True)
with open(str(log_path), "a", encoding="utf-8") as f:
f.write(f"\n{'='*75}\n")
f.write(f"[{now_str}] ALERTA GRAFANA - ESTADO: {status}\n")
f.write(f"Título: {title}\n")
if alerts:
for idx, alert in enumerate(alerts, 1):
labels = alert.get("labels", {})
annotations = alert.get("annotations", {})
starts_at = alert.get("startsAt", "")
ends_at = alert.get("endsAt", "")
alert_status = alert.get("status", status)
f.write(f"\n [Alerta #{idx}] Status: {alert_status}\n")
f.write(f" - Nombre : {labels.get('alertname', 'N/A')}\n")
f.write(f" - Cliente : {labels.get('cliente', 'N/A')}\n")
f.write(f" - Sucursal : {labels.get('sucursal', 'N/A')}\n")
f.write(f" - SCO : {labels.get('sco_number', labels.get('instance', 'N/A'))}\n")
f.write(f" - Severidad : {labels.get('severity', 'N/A')}\n")
f.write(f" - Resumen : {annotations.get('summary', annotations.get('description', 'N/A'))}\n")
if starts_at:
f.write(f" - Inicio : {starts_at}\n")
if ends_at and ends_at != "0001-01-01T00:00:00Z":
f.write(f" - Fin : {ends_at}\n")
else:
f.write(f" Detalle: {data.get('message', 'Sin descripción adicional')}\n")
f.write(f"\nRAW JSON: {json.dumps(data, ensure_ascii=False)}\n")
f.flush()
logger.info(f"Alerta de Grafana guardada en {ALERTS_LOG_FILE} [{status}] {title}")
return jsonify({"status": "ok", "message": "Alerta registrada correctamente"}), 200
except Exception as e:
logger.error(f"Error procesando webhook de alerta: {e}")
return jsonify({"status": "error", "message": str(e)}), 500
if __name__ == '__main__':
app.run(host='0.0.0.0', port=5000, debug=True)
+7
View File
@@ -0,0 +1,7 @@
Flask==3.1.3
mysql-connector-python==26.7.0
gunicorn==21.2.0
pydantic>=2.0.0
requests==2.31.0
Pillow==10.2.0
playwright==1.45.0