Carrusel de dashboards para TV
http://192.168.117.7:8888/ sirve un carrusel a pantalla completa con los 3
dashboards de Lightdash (Compras, Inventario y Movimientos,
Gastos y Facturación CFDI), rotando cada 5 segundos, pensado para mostrarse en
una pantalla/TV fija en la red local — sin login, sin interacción. Extiende el
patrón de Dashboard como imagen en la red local
(un solo dashboard, un solo archivo) al caso de varios dashboards en rotación,
que esa página ya dejaba anotado como pendiente.
Servidor efímero a propósito (proceso python3 -m http.server plano, sin
unidad systemd) — no sobrevive un reinicio del host ni el cierre de la sesión
que lo lanzó. Si en algún momento se necesita que sobreviva reinicios, el
patrón a seguir es el mismo .service que ya usa
lightdash-dashboard-pipeline.service.
Un scheduler por dashboard, no uno compartido
El pipeline de imagen anterior (dashboard-imagen-local.md) usaba un solo
scheduler para Compras y un solo archivo de salida (latest.png). Para el
carrusel hacía falta un archivo por dashboard, así que se reemplazó ese
scheduler por 3 nuevos, uno por dashboard, todos con el sufijo “carrusel TV”
y customViewportWidth: 1920 (ancho fijo tipo TV/monitor externo — sin este
campo el render usa el ancho de ventana default del headless browser, pensado
para pantallas de escritorio normales, no para una TV):
| Scheduler | Dashboard | schedulerUuid |
|---|---|---|
| Compras - carrusel TV | Compras | 657624a5-5412-471c-adce-a204a9b3e023 |
| Inventario y Movimientos - carrusel TV | Inventario y Movimientos | 660408e6-0360-48f7-9957-70ca3d903f2a |
| Gastos y Facturación CFDI - carrusel TV | Gastos y Facturación (CFDI) | 84d947cd-ac1c-45f5-a3bc-8fb8d8579fe5 |
Los 3 corren en el mismo cron horario (0 * * * *) que ya traía el scheduler
original.
Efecto secundario en lightdash-dashboard-pipeline.service: ese servicio
(puerto 9100, /latest.png) sigue activo y no se tocó, pero ya no muestra
siempre Compras — sirve el último .png que caiga en el bucket sin importar
qué scheduler lo subió, así que ahora rota entre los 3 dashboards según cuál
termine de renderizar último cada hora. Si se necesita que vuelva a mostrar
solo Compras de forma determinista, hay que filtrar por nombre de objeto o
mover ese dashboard a un bucket/prefijo separado — no se hizo porque el
carrusel de esta página ya cubre el caso de uso real (“quiero ver los 3”).
El verdadero motivo de que Inventario fallara: no era el timeout
El primer diagnóstico fue el sospechoso obvio — Browserless corta cada sesión
a los 30s por default (TIMEOUT env var) y el dashboard de Inventario, el más
pesado de los 3, no alcanzaba a terminar. Subir TIMEOUT=120000 en el
servicio headless-browser no lo arregló — el render seguía fallando, ahora
tardando varios minutos en vez de 30s antes de morir igual.
La causa real apareció al medir la query directamente contra Postgres en vez de asumir que era un problema del browser:
SELECT count(*) FROM analytics_marts.fct_movimientos;
-- 90,377,556 filas, 35.5sfct_movimientos (ver el modelo en
Dashboards demo con Lightdash § Proyecto dbt)
hace left join contra stg_almacen usando solo almacen_id. Pero
raw.almacen no es un catálogo 1:1 de almacenes — es una tabla de relación
almacén × sucursal (al_cve_almacen, sc_cve_sucursal): un mismo almacén
aparece hasta 40 veces, una fila por cada sucursal donde ese almacén tiene
presencia. El join generaba una fila de fct_movimientos por cada combinación
almacén-sucursal en vez de una por movimiento — infló las 4.28M filas reales
de stg_movimiento a 90.4M. Cualquier chart de Inventario, por simple que
fuera, escaneaba 21x más filas de las que debía.
Fix, en models/marts/fct_movimientos.sql — agregar sucursal_id (que
stg_movimiento ya trae) como segunda condición del join, porque
(almacen_id, sucursal_id) sí es clave única en raw.almacen (confirmado con
un GROUP BY antes de tocar el modelo):
left join {{ ref('stg_almacen') }} a
on mv.almacen_id = a.almacen_id
and mv.sucursal_id = a.sucursal_iddbt run --select fct_movimientos (0.24s, CREATE VIEW, ningún otro modelo
depende de este) y la misma query de conteo pasó de 90.4M filas / 35.5s a
4.28M filas / 4.7s. El render de Inventario que llevaba 4 intentos fallidos
terminó en 37s en el primer intento después del fix, con TIMEOUT=120000 de
sobra — el timeout más generoso no era la solución, solo escondía cuánto
tiempo real le tomaba a Postgres devolver 21x más datos de los correctos.
No es un bug introducido por este trabajo — el join estaba mal desde que se
escribió el modelo original (ver
Dashboards demo con Lightdash § Proyecto dbt);
simplemente nunca se había notado porque ningún chart anterior había forzado
un count(*) completo sobre la vista entera bajo presión de tiempo real como
sí lo hace un screenshot de scheduled delivery.
Disparar un render sin esperar el cron, sin sesión de API
Para probar el fix sin esperar a la siguiente hora en punto, hacía falta
disparar el scheduler de Inventario ya mismo. El camino normal
(POST /api/v1/schedulers/{uuid}/send, ver
sources/trivasa/lightdash-dashboards-demo/pipeline-imagen-webhook.md)
requiere sesión autenticada, y el login por curl estaba bloqueado por el
mismo gotcha de SECURE_COOKIES=true ya documentado en
Lightdash § gotcha SECURE_COOKIES
— la solución de siempre (bajar SECURE_COOKIES a false, recrear el
contenedor, hacer las llamadas, volver a subirlo) requiere recrear el
contenedor de producción, y esta vez el permiso para ese comando específico no
se concedió.
Alternativa encontrada sin recrear nada: Lightdash guarda sus jobs de
scheduler en una cola Postgres real (graphile-worker, en el propio
lightdash-db), no en un mecanismo externo. El cron interno de Lightdash no
hace más que insertar una fila en graphile_worker.jobs con
task_identifier = 'handleScheduledDelivery' y un payload con
schedulerUuid/organizationUuid/projectUuid/userUuid. Insertar esa
misma fila a mano, copiando el shape exacto de un job real ya encolado por el
cron, dispara el mismo procesamiento sin tocar el contenedor ni necesitar
sesión HTTP:
INSERT INTO graphile_worker.jobs (task_identifier, payload, run_at)
VALUES (
'handleScheduledDelivery',
'{"schedulerUuid":"660408e6-...","organizationUuid":"11f2bdfd-...",
"projectUuid":"82df3b0a-...","userUuid":"4ad61f89-..."}'::json,
now()
);El worker interno de Lightdash recoge la fila en segundos (usa
LISTEN/NOTIFY de Postgres, no polling) y corre exactamente el mismo código
que el cron real.
Véase también
- Dashboard como imagen en la red local — el pipeline de un solo dashboard que este carrusel extiende.
- Lightdash — la instancia y el gotcha de
SECURE_COOKIESpara setup/administración por API. - Dashboards demo con Lightdash — el proyecto dbt y los 3 dashboards que este carrusel muestra en rotación.
sources/trivasa/lightdash-dashboards-demo/carrusel-tv.md— cierre técnico completo: HTML/JS del carrusel, comandos exactos de MinIO, y elmc cpcorrecto para extraer un objeto (eldocker cpdirecto contra el volumen interno de MinIO copia la carpeta de metadatos, no el archivo).