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:
schedulerUuid | dashboardUuid | nombre |
|---|---|---|
657624a5-5412-471c-adce-a204a9b3e023 | 91d3aeee-4b1e-4808-b23c-a59a95544f0a | Compras - carrusel TV |
660408e6-0360-48f7-9957-70ca3d903f2a | d070688f-1285-470f-bb6a-909514b580cc | Inventario y Movimientos - carrusel TV |
84d947cd-ac1c-45f5-a3bc-8fb8d8579fe5 | e50f04f6-2a04-45ed-ad41-ef7573565320 | Gastos 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 únicaFix 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_idcd ~/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 esteVerificació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>.pngConfirmado 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 &
disownSin 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 deployno corredbt run(el otro bug defct_movimientos, distinto de este).- Carrusel de dashboards para TV — página de wiki con el resumen en prosa y la URL final.