Pipeline de imagen automática (scheduled delivery + webhook de MinIO) — cierre técnico
Fecha: 2026-08-09
Resultado: http://192.168.117.7:9100/latest.png sirve una captura del
dashboard Compras, actualizada sola cada hora, sin login. Verificado
end-to-end dos veces (webhook simulado a mano, y el pipeline real completo
disparado por POST /schedulers/:uuid/send) — en ambos casos el archivo se
actualizó sin intervención manual.
Headless browser: falta genuina, no config faltante
ghcr.io/browserless/chromium:v2.49.0 (el mismo Dockerfile de un renglón que
usa el compose oficial de Lightdash: FROM ghcr.io/browserless/chromium:v2.49.0,
sin build propio) no estaba en el docker-compose.yml de esta instancia —
confirmado con hasHeadlessBrowser: false en /api/v1/health antes de
agregarlo. El pull (3.84GB) falló dos veces por espacio en disco del host
antes de completar a la tercera — ver
despliegue-cierre.md para la saga completa de RAM/disco de este host, es el
mismo tipo de problema que ya había tumbado el primer intento de pull de la
imagen principal de Lightdash.
Env vars agregadas al servicio lightdash una vez que el contenedor
headless-browser existe en el compose (nombre de servicio = hostname
interno, resuelto por Docker DNS):
environment:
- HEADLESS_BROWSER_HOST=headless-browser
- HEADLESS_BROWSER_PORT=30003000 es el PORT default de la imagen browserless/chromium (confirmado
con docker inspect ... --format '{{json .Config.Env}}'), no hace falta
configurarlo explícitamente en el lado del headless browser.
API: crear un scheduled delivery en formato imagen
POST /api/v1/dashboards/{dashboardUuid}/schedulers
El endpoint tsoa no valida el body contra un schema tipado (el método del
controller usa req.body crudo, sin @Body() — a diferencia de casi todo lo
demás en esta API, ver api-referencia.md), así que el shape se sacó del
tipo TypeScript (CreateSchedulerAndTargetsWithoutIds en
@lightdash/common), no de routes.js:
{
"name": "Compras - export horario a Minio",
"cron": "0 * * * *",
"format": "image",
"options": {},
"targets": [{"recipient": "dashboards@ctunlinux.local"}],
"includeLinks": true,
"enabled": true
}format acepta csv | xlsx | image | gsheets | pdf (SchedulerFormat enum).
options es una unión discriminada por format — para image,
SchedulerImageOptions = { withPdf?: boolean; pagePerTab?: boolean }, ambos
opcionales, {} es válido.
No existe un target “S3 directo” — por qué se usó un email desechable
CreateSchedulerTarget es una unión cerrada de 4 shapes, ninguno es webhook
genérico ni S3:
type CreateSchedulerTarget =
| Pick<SchedulerSlackTarget, 'channel'>
| Pick<SchedulerMsTeamsTarget, 'webhook'>
| Pick<SchedulerGoogleChatTarget, 'googleChatWebhook'>
| Pick<SchedulerEmailTarget, 'recipient'>Y la validación en SchedulerService.sendScheduler rechaza targets: []
salvo para format: gsheets:
if (scheduler.targets.length === 0 && scheduler.format !== SchedulerFormat.GSHEETS) {
throw new ParameterError('You must specify at least 1 destination before sending a scheduled delivery');
}
Se usó {"recipient": "dashboards@ctunlinux.local"} — esta instancia no tiene
EMAIL_SMTP_HOST configurado (hasEmailClient: false), así que el envío de
email en sí probablemente falla o queda en cola sin salir nunca. No importa
para este pipeline: el render + upload a S3/MinIO pasa por
UnfurlService.unfurlImage → S3Client.uploadImage, un paso compartido
antes de la rama específica de envío por target (confirmado empíricamente:
el archivo llegó al bucket igual, sin que el email se enviara ni se
confirmara). Un target Slack/Teams tendría el mismo efecto práctico si algún
día se prefiere — no se probó, pero la arquitectura (upload primero, delivery
después) es la misma para los 4 tipos de target.
Nombre del objeto subido a MinIO
S3Client.uploadImage(image, imageId) sube a Key: ${imageId}.png, en la
raíz del bucket (sin prefijo/carpeta). Para dashboards, UnfurlService arma
imageId como slack-image-notification-${nanoid()} — el prefijo
slack-image- queda igual sin importar qué tipo de target se use (es solo
el nombre interno de la función, no indica que se use Slack). Ejemplos reales
observados: slack-image-notification-I6PuAB69s8w3wP0nqzoSC.png,
slack-image-notification-sIu4McPSwq4JQSSyS0ILr.png. El pipeline no depende
de este prefijo exacto — filtra solo por sufijo .png vía la notificación de
MinIO (ver abajo), así que sobrevive si el prefijo cambia en una versión
futura de Lightdash.
Enviar el scheduler ya mismo, sin esperar el cron
POST /api/v1/schedulers/{schedulerUuid}/send
Sin body. Devuelve {"jobId": "..."} de inmediato (asíncrono) — el render
real tarda ~15-20s en esta instancia (5 charts, warehouse Postgres local).
Usado para las dos pruebas end-to-end de este cierre.
Onboarding modal en las capturas — fix
Primera captura (slack-image-notification-I6PuAB69s8w3wP0nqzoSC.png) salió
con un modal semi-transparente tapando media pantalla: “Nearly there… Tell
us a bit more about yourself” — el admin de esta instancia se creó vía
POST /api/v1/user (ver api-referencia.md) sin pasar nunca por el
onboarding de la UI, entonces isSetupComplete quedaba en false
indefinidamente, y el headless browser renderiza la sesión real del usuario
tal cual, modal incluido.
Fix — el mismo endpoint que había fallado con
403 "User is not part of an organization" cuando se probó por primera vez
(antes de que existiera la organización, ver api-referencia.md) ahora sí
funciona porque la organización ya existe:
PATCH /api/v1/user/me/complete
{"jobTitle": "...", "howDidYouHearAboutUs": "...", "isMarketingOptedIn": false, "isTrackingAnonymized": true}
Respuesta confirma "isSetupComplete": true. Captura siguiente
(slack-image-notification-sIu4McPSwq4JQSSyS0ILr.png, 127KiB vs 143KiB de la
primera — la diferencia de tamaño es justo el modal ya no presente) salió
limpia.
Notificación de bucket en MinIO
MinIO trae mc empacado en su propia imagen (minio/minio:latest) —
confirmado con docker exec lightdash-minio mc --version, no hace falta un
contenedor minio/mc aparte para administrarlo en runtime.
# 1. Configurar el target del webhook
docker exec lightdash-minio mc admin config set local \
notify_webhook:dashboard endpoint="http://172.21.0.1:9100/minio-event"
# 2. Aplicar (mc admin service restart falla sin TTY dentro de docker exec:
# "Unable to initialize service restart UI: could not open a new TTY" —
# usar docker restart del contenedor en su lugar)
docker restart lightdash-minio
# 3. Enlazar el bucket al target, solo eventos ObjectCreated con sufijo .png
docker exec lightdash-minio mc event add local/lightdash \
arn:minio:sqs::dashboard:webhook --event put --suffix .png172.21.0.1 es el gateway de la red Docker lightdash_default
(docker network inspect lightdash_default --format '{{json .IPAM.Config}}')
— la forma en que un contenedor de esa red bridge llega a un proceso
escuchando en el host (0.0.0.0, no 127.0.0.1, para que el bridge pueda
alcanzarlo). Cada red Docker custom tiene su propio gateway; si el receiver
se mueve a otra red, este valor cambia.
Formato del evento que llega al webhook
MinIO hace POST al endpoint configurado con un body JSON, formato evento S3
estándar (compatible AWS), potencialmente varios eventos por línea (NDJSON)
en un solo POST:
{
"EventName": "s3:ObjectCreated:Put",
"Key": "lightdash/slack-image-notification-C5XQXrSY-L_FckbW-SBes.png",
"Records": [
{
"eventVersion": "2.0",
"eventSource": "minio:s3",
"eventName": "s3:ObjectCreated:Put",
"s3": {
"bucket": {"name": "lightdash"},
"object": {"key": "slack-image-notification-C5XQXrSY-L_FckbW-SBes.png"}
}
}
]
}El receiver (scripts/lightdash/receiver.py en este repo, deploy idéntico en
trivasa-bi-dev/lightdash/dashboard-pipeline/receiver.py en ctunlinux) lee
Records[].s3.bucket.name / Records[].s3.object.key, filtra por
eventName empezando con s3:ObjectCreated: y bucket == lightdash, y trae
el objeto con:
docker exec lightdash-minio mc cat local/lightdash/<key>capturando stdout a un archivo temporal y haciendo rename() atómico sobre
/var/www/dashboard/latest.png al terminar — para que un cliente nunca reciba
un archivo a medio escribir si el GET llega justo mientras se está
actualizando.
systemd
[Unit]
Description=Receptor de webhook de Minio + servidor HTTP para el ultimo dashboard de Lightdash (imagen)
After=docker.service
Requires=docker.service
[Service]
Type=simple
User=ealcocer
WorkingDirectory=/home/ealcocer/trivasa-bi-dev/lightdash/dashboard-pipeline
ExecStart=/usr/bin/python3 receiver.py --port 9100 --bucket lightdash --out /var/www/dashboard/latest.png
Restart=unless-stopped
RestartSec=5
[Install]
WantedBy=multi-user.targetRestart=unless-stopped + enable — aplicado desde el principio, a
diferencia de Metabase/Superset (ver ctunlinux.md en content/, causa raíz
del reboot del 2026-08-08), para no repetir el mismo tipo de incidente con un
servicio nuevo.
Verificación end-to-end realizada
- Webhook simulado a mano —
curl -X POST localhost:9100/minio-eventcon un payload de evento construido a mano apuntando a un objeto ya existente en el bucket → confirmó que el receiver copia y sirve correctamente, sin depender todavía de que MinIO mismo dispare el evento. - Pipeline real completo —
rm /var/www/dashboard/latest.png, luegoPOST /schedulers/{uuid}/send, luego esperar. El log del receiver mostró la IP del contenedor de MinIO (172.21.0.3, no127.0.0.1) llamando al webhook por su cuenta ~15s después del trigger, sin ningún comando manual de por medio. Esta es la prueba real de que el pipeline funciona en producción, no solo que las piezas sueltas funcionan por separado.
Véase también
despliegue-cierre.md— saga de RAM/disco de este mismo host, mismo tipo de problema que causó los dos primeros intentos fallidos del pull debrowserless/chromium.api-referencia.md— el resto de la API de Lightdash reversada (org, proyecto, dashboards+charts) que hizo posible construir todo esto porcurlen vez de la UI.- Dashboard como imagen en la red local — página de wiki con el resumen en prosa y la URL final para el equipo.