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):

SchedulerDashboardschedulerUuid
Compras - carrusel TVCompras657624a5-5412-471c-adce-a204a9b3e023
Inventario y Movimientos - carrusel TVInventario y Movimientos660408e6-0360-48f7-9957-70ca3d903f2a
Gastos y Facturación CFDI - carrusel TVGastos 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.5s

fct_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_id

dbt 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_COOKIES para 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 el mc cp correcto para extraer un objeto (el docker cp directo contra el volumen interno de MinIO copia la carpeta de metadatos, no el archivo).