Carrusel de dashboards para TV — cierre técnico

Fecha: 2026-08-10 Resultado: http://192.168.117.7:8888/ sirve un carrusel HTML/JS con los 3 dashboards (Compras, Inventario y Movimientos, Gastos y Facturación CFDI), rotando cada 5s, 1920px de ancho. Servidor Python plano (http.server), sin systemd — efímero a propósito, ver Carrusel de dashboards para TV para el resumen en prosa.

Schedulers: reemplazo del original por 3, con customViewportWidth

El scheduler original de dashboard-imagen-local.md (schedulerUuid 17fb027e-d2b4-4e68-8600-28931afc6b3d, solo Compras) se borró (DELETE /api/v1/schedulers/{uuid}) y se reemplazó por 3 nuevos, uno por dashboard, mismo body que documenta pipeline-imagen-webhook.md más el campo customViewportWidth:

{
  "name": "Inventario y Movimientos - carrusel TV",
  "cron": "0 * * * *",
  "format": "image",
  "options": {},
  "targets": [{"recipient": "dashboards@ctunlinux.local"}],
  "includeLinks": true,
  "enabled": true,
  "customViewportWidth": 1920
}

customViewportWidth es un campo de DashboardScheduler en @lightdash/common — no está documentado en ningún lado público, se encontró leyendo el .d.ts compilado del propio contenedor. Sin este campo, el headless browser renderiza con el ancho de ventana default (pensado para verse bien en una notificación de Slack/email, no en una TV). Los 3 quedaron con:

schedulerUuiddashboardUuidnombre
657624a5-5412-471c-adce-a204a9b3e02391d3aeee-4b1e-4808-b23c-a59a95544f0aCompras - carrusel TV
660408e6-0360-48f7-9957-70ca3d903f2ad070688f-1285-470f-bb6a-909514b580ccInventario y Movimientos - carrusel TV
84d947cd-ac1c-45f5-a3bc-8fb8d8579fe5e50f04f6-2a04-45ed-ad41-ef7573565320Gastos y Facturación CFDI - carrusel TV

El bug real de Inventario: fanout de join, no timeout

Ver la narrativa completa en Carrusel de dashboards para TV § El verdadero motivo — aquí solo los comandos exactos de diagnóstico, en orden:

# 1. Confirmar que no era config de browser: medir la query real
docker exec postgres-dw psql -U trivasa -d trivasa_dw -c "\timing" \
  -c "SELECT count(*) FROM analytics_marts.fct_movimientos;"
# 90377556 filas, 35541ms — sospechoso: stg_movimiento tiene 4.28M
 
# 2. Aislar cuál tabla del join causa el fanout
docker exec postgres-dw psql -U trivasa -d trivasa_dw -t -c "
SELECT 'stg_movimiento', count(*) FROM analytics_staging.stg_movimiento
UNION ALL SELECT 'dim_producto', count(*) FROM analytics_marts.dim_producto
UNION ALL SELECT 'dim_producto_distinct_id', count(distinct producto_id)
  FROM analytics_marts.dim_producto;"
# stg_movimiento 4282833 | dim_producto 14668 | distinct 14668 -- limpio
 
docker exec postgres-dw psql -U trivasa -d trivasa_dw -t -c "
SELECT 'stg_sucursal_total', count(*) FROM analytics_staging.stg_sucursal
UNION ALL SELECT 'stg_sucursal_distinct', count(distinct sucursal_id)
  FROM analytics_staging.stg_sucursal
UNION ALL SELECT 'stg_almacen_total', count(*)
  FROM analytics_staging.stg_almacen
UNION ALL SELECT 'stg_almacen_distinct', count(distinct almacen_id)
  FROM analytics_staging.stg_almacen;"
# stg_sucursal 41/41 -- limpio. stg_almacen 464 total / 99 distinct -- ahí está
 
# 3. Confirmar la clave real (almacen_id, sucursal_id) es única
docker exec postgres-dw psql -U trivasa -d trivasa_dw -c "
SELECT al_cve_almacen, sc_cve_sucursal, count(*)
FROM raw.almacen GROUP BY 1,2 HAVING count(*)>1;"
# 0 rows -- (almacen_id, sucursal_id) sí es clave única

Fix en models/marts/fct_movimientos.sql:

 left join {{ ref('stg_sucursal') }} s on mv.sucursal_id = s.sucursal_id
-left join {{ ref('stg_almacen') }} a on mv.almacen_id = a.almacen_id
+left join {{ ref('stg_almacen') }} a
+    on mv.almacen_id = a.almacen_id
+    and mv.sucursal_id = a.sucursal_id
 left join {{ ref('dim_producto') }} p on mv.producto_id = p.producto_id
cd ~/trivasa-bi-dev/dbt/trivasa_dbt
source ~/trivasa-bi-dev/dbt/venv/bin/activate
dbt run --select fct_movimientos   # CREATE VIEW, 0.24s — ningún otro modelo depende de este

Verificación post-fix: count(*) → 4282833 filas, 4736ms (antes 90377556 / 35541ms). Render de Inventario en el primer intento tras el fix: 37s (UnfurlService saveScreenshot took 37335 ms), contra 4 intentos fallidos seguidos antes del fix incluso con TIMEOUT=120000 en headless-browser.

Disparar un scheduler sin sesión HTTP: insertar el job directo en graphile_worker

El login por curl seguía bloqueado por el gotcha de SECURE_COOKIES=true (ver api-referencia.md y Lightdash § gotcha SECURE_COOKIES), y el comando para recrear el contenedor con SECURE_COOKIES=false no se autorizó en esa sesión puntual. En vez de forzarlo, se inspeccionó cómo Lightdash encola sus propios jobs de scheduler:

-- lightdash-db tiene su propio schema graphile_worker (además de public)
\dn
--  graphile_worker
--  public
 
-- un job real, insertado por el cron interno de Lightdash cada hora:
SELECT id, task_identifier, payload, run_at
FROM graphile_worker.jobs ORDER BY id DESC LIMIT 5;
-- task_identifier = 'handleScheduledDelivery'
-- payload = {"schedulerUuid":"...", "organizationUuid":"...",
--            "projectUuid":"...", "userUuid":"...", "traceHeader":"...", ...}

Los campos traceHeader/baggageHeader/otelBaggage/sentryMessageId son solo instrumentación (Sentry/OpenTelemetry) — opcionales, el worker los ignora si faltan. Insertar la misma fila a mano con solo los 4 campos obligatorios dispara el mismo procesamiento:

INSERT INTO graphile_worker.jobs (task_identifier, payload, run_at)
VALUES (
  'handleScheduledDelivery',
  '{"schedulerUuid":"660408e6-0360-48f7-9957-70ca3d903f2a",
    "organizationUuid":"11f2bdfd-3ba4-4cea-92db-5f878689c262",
    "projectUuid":"82df3b0a-4b56-4dee-837b-20706bae801b",
    "userUuid":"4ad61f89-b3bd-4945-b3be-4e8dfe9a103a"}'::json,
  now()
);

graphile-worker usa LISTEN/NOTIFY de Postgres (trigger _900_notify_worker AFTER INSERT ON graphile_worker.jobs), no polling — el worker de Lightdash recogió el job en menos de un segundo, visible en docker logs lightdash como una ejecución idéntica a la del cron real (mismas queries, mismo UnfurlService, mismo upload a MinIO). No quedó ningún job a medias ni duplicado — graphile-worker borra la fila al completar exitosamente, igual que con los jobs del cron.

Este método sirve para cualquier scheduler, no solo el de Inventario — útil en general para probar un cambio sin esperar al cron y sin pelear con SECURE_COOKIES, siempre que se tengan los 4 UUIDs (los tres de proyecto/org son fijos para esta instancia, el userUuid es el del admin, schedulerUuid es el único que cambia por caso).

Extraer un objeto de MinIO: mc cp, no docker cp directo al volumen

Intento fallido inicial: docker cp lightdash-minio:/data/lightdash/<key>.png destino — el volumen de datos de MinIO no guarda los objetos como archivos planos con ese nombre; internamente usa una carpeta por objeto con metadata (xl.meta) y el contenido versionado adentro. El resultado de ese docker cp es un directorio, no un PNG, sin ningún error visible (docker cp no distingue). Esto causó que gastos.png e inventario.png quedaran como directorios en /var/www/dashboard-carousel/ en vez de archivos, sin que ls -la lo delatara a simple vista (solo file o el tamaño en total mostraba el problema).

Fix — usar mc cp (el cliente S3 que ya trae la imagen de MinIO) para que MinIO arme el objeto real antes de sacarlo del contenedor:

docker exec lightdash-minio mc cp local/lightdash/<key>.png /tmp/<nombre>.png
docker cp lightdash-minio:/tmp/<nombre>.png /var/www/dashboard-carousel/<nombre>.png

Confirmado con file: PNG image data, 1920 x 1570, 8-bit/color RGB, non-interlaced en los 3 archivos finales.

index.html del carrusel

<!DOCTYPE html>
<html lang="es">
<head>
<meta charset="UTF-8">
<title>Trivasa — Carrusel de Dashboards</title>
<meta name="viewport" content="width=device-width, initial-scale=1">
<style>
  * { margin: 0; padding: 0; box-sizing: border-box; }
  html, body { width: 100%; height: 100%; background: #0a0a0a; overflow: hidden; }
  .slide {
    position: absolute; top: 0; left: 0; width: 100%; height: 100%;
    display: flex; align-items: center; justify-content: center;
    opacity: 0; transition: opacity 0.6s ease-in-out;
  }
  .slide.active { opacity: 1; }
  .slide img { max-width: 100%; max-height: 100%; width: auto; height: auto; object-fit: contain; }
  #clock { position: fixed; bottom: 12px; right: 20px; color: #666; font-family: monospace; font-size: 14px; z-index: 10; }
</style>
</head>
<body>
  <div class="slide active"><img src="compras.png" alt="Compras"></div>
  <div class="slide"><img src="gastos.png" alt="Gastos"></div>
  <div class="slide"><img src="inventario.png" alt="Inventario y Movimientos"></div>
  <div id="clock"></div>
<script>
  const slides = document.querySelectorAll('.slide');
  let current = 0;
  const intervalMs = 5000;
  function showNext() {
    slides[current].classList.remove('active');
    current = (current + 1) % slides.length;
    slides[current].classList.add('active');
  }
  setInterval(showNext, intervalMs);
  function refreshImages() {
    document.querySelectorAll('.slide img').forEach(img => {
      const base = img.src.split('?')[0];
      img.src = base + '?t=' + Date.now();
    });
  }
  setInterval(refreshImages, 5 * 60 * 1000);
  function tick() { document.getElementById('clock').textContent = new Date().toLocaleString('es-MX'); }
  setInterval(tick, 1000);
  tick();
</script>
</body>
</html>

Fade cruzado vía CSS (opacity + transition, sin librería), object-fit: contain sobre fondo negro para que un PNG de 1920×1570 se vea centrado sin recortarse en cualquier resolución de TV. El refreshImages() cada 5 minutos es defensivo — el navegador podría cachear las 3 imágenes indefinidamente sin él, mostrando siempre la primera captura aunque el archivo en disco ya se haya actualizado por el cron horario.

Servidor

mkdir -p /var/www/dashboard-carousel   # requirió sudo + chown ealcocer:ealcocer, no existía
cd /var/www/dashboard-carousel
nohup python3 -m http.server 8888 --bind 0.0.0.0 > /tmp/carousel-server.log 2>&1 &
disown

Sin unidad systemd — a diferencia de lightdash-dashboard-pipeline.service, este se pidió explícitamente efímero. Muere si se reinicia el host o se cierra la sesión que lo lanzó (disown lo desprende del job control de esa shell, pero no de la sesión de login/systemd-logind del usuario). Si se decide dejarlo permanente, el patrón a copiar es el .service completo de pipeline-imagen-webhook.md.

Véase también

  • pipeline-imagen-webhook.md — el pipeline de un solo dashboard sobre el que este carrusel se construyó (mismo mecanismo de scheduler + MinIO, ahora por triplicado).
  • despliegue-cierre.md — contexto de RAM/disco del host, y por qué lightdash deploy no corre dbt run (el otro bug de fct_movimientos, distinto de este).
  • Carrusel de dashboards para TV — página de wiki con el resumen en prosa y la URL final.