Resumen del sistema
Estado global de la plataforma Lyzard
Documentación del panel
Referencia completa de todas las secciones, funcionalidades y procesos del panel de administración. Mantén esta página actualizada cada vez que se añada algo nuevo.
Índice
- Visión general del panel
- Login, sesión y seguridad
- Resumen
- Tenants
- Funcionalidades por tenant (feature flags)
- Administradores
- Modelo canónico
- Sources
- Mapeos (panel inline en Sources)
- Staging logs
- Motor de staging (cómo se mueven los datos)
- Enlaces del bloque "Sistema"
- Arquitectura técnica resumida
- Metabase: instalación y arquitectura completa
- Librería de queries (SQL multi-tenant)
- Cómo añadir una funcionalidad nueva al panel
Este panel (admin.lyzard.es) es el centro de operaciones del equipo Lyzard. NO es para clientes finales — los clientes acceden al dashboard en lyzard.es.
Desde aquí se gestionan tres cosas:
- Quién usa Lyzard — alta y baja de tenants (clientes) y administradores internos.
- Qué ve cada cliente — feature flags por tenant.
- Cómo se mueven sus datos — desde el ingestion (Airbyte) hasta el data warehouse canonicalizado.
El sidebar agrupa las secciones en cuatro bloques: General, Gestión, Data Warehouse y Sistema. Las dos primeras viven dentro del propio panel; las últimas dos abren herramientas externas (pgweb, Airbyte) o son secciones de operación.
Login
Entras con email + contraseña. Solo aceptan login los usuarios marcados como is_system_admin = true en la BBDD lyzard. Si un cliente con role owner intenta entrar, el panel le rechaza con "Acceso restringido al equipo de Lyzard".
Token JWT
Tras el login el backend devuelve un JWT firmado con JWT_SECRET (caduca en 8h). El token se guarda en sessionStorage del navegador con clave lyzard_admin_token y se envía en cada petición del panel como Authorization: Bearer ….
Cookie de sesión (HttpOnly)
Además del JWT, tras el login el panel llama a POST /api/admin-session y el backend establece una cookie HttpOnly llamada lyzard_admin_session con dominio .lyzard.es. Esta cookie es crítica: nginx la usa con auth_request para proteger /db/ y /dw/ (los visores de BBDD) sin pedir un segundo login.
Logout
Al pulsar el icono de salida se borra la cookie (DELETE /api/admin-session) y se elimina el token de sessionStorage. Si no haces logout, la cookie expira sola a las 8h.
HttpOnly, Secure y SameSite=Strict, pero conviene cerrar sesión en máquinas compartidas.Vista de aterrizaje. Muestra tres contadores globales:
- Tenants — total de filas en la tabla
tenants(incluye inactivos). - Usuarios — total en la tabla
users. - Admins — usuarios con
is_system_admin = true.
Datos en vivo desde GET /api/admin/stats. Se recargan cada vez que abres la página.
Un tenant es un cliente de Lyzard (un workspace aislado). Esta página tiene tres bloques.
4.1 Crear un tenant
Formulario con dos partes: el tenant en sí (nombre + slug) y el primer usuario owner dentro de él (email, contraseña, nombre opcional).
- Nombre — display name del workspace (ej.
Tienda García). - Slug — identificador URL-safe. Se autocompleta al escribir el nombre (sin acentos, solo a-z 0-9 y guiones). Si lo editas a mano, deja de autocompletar.
- Email del owner — único en todo el sistema. No puede repetirse en otro tenant.
- Contraseña — mínimo 8 caracteres. Se hashea con bcrypt cost 12.
- Nombre completo — opcional, solo para mostrar.
Llama a POST /api/tenants, que en una transacción crea la fila en tenants y la fila en users con role owner.
4.2 Lista y activación
Tabla con todos los tenants, su slug, el contador de usuarios y la fecha de creación. La columna Estado es un botón clicable: alterna entre Activo e Inactivo (PATCH /api/admin/tenants/:id). Un tenant inactivo no se elimina — solo se marca is_active = false y los usuarios de ese tenant no pueden hacer login.
4.3 Botón "Funcionalidades"
Abre el panel de feature flags para ese tenant (sección 5). El panel se muestra debajo de la tabla y se cierra con el botón "Cerrar".
Cada tenant ve solo las secciones del dashboard que tenga activadas. La lógica es por capas:
- La tabla
featurescontiene el catálogo global conenabled_by_default. - La tabla
tenant_featurescontiene overrides: solo se inserta una fila cuando el admin cambia el default. - Estado efectivo = override del tenant si existe, si no
enabled_by_default.
Cómo usar el panel
En la fila de un tenant, pulsa Funcionalidades. Aparecen toggles agrupados por categoría (Dashboard, etc.). Cada toggle hace PATCH /api/admin/tenants/:id/features/:key con UPSERT en tenant_features.
Cómo añadir una feature nueva al catálogo
Hoy se hace por SQL: INSERT INTO features (key, name, description, category, sort_order, enabled_by_default) VALUES (...). Convención de key: dashboard.<seccion>. El nombre y descripción aparecen tal cual en el toggle.
Lista los usuarios del equipo Lyzard (is_system_admin = true). Estos son los únicos que pueden entrar en este panel.
6.1 Crear admin
Formulario con email, nombre completo y contraseña. POST /api/admin/admins crea un usuario con role='owner', is_system_admin=true y tenant_id = el tenant del admin que lo crea (el "tenant interno" de Lyzard, no se ve en producto).
6.2 Lista
Email, nombre, workspace, último acceso y estado. Hoy no hay botón para desactivar/borrar admins — se haría manualmente vía SQL.
users.is_active = false o is_system_admin = false directamente en la BBDD.Define qué columnas tiene cada entidad estable del data warehouse (products, orders, customers, …). Es el contrato que verá el dashboard del cliente, independiente de qué source provee los datos.
7.1 Tabla maestra
Las filas viven en config.canonical_columns (en la BBDD lyzard_warehouse). Cada fila tiene entity, column_name, data_type (text/numeric/timestamp/boolean) y required.
7.2 Añadir una columna canónica
Formulario con entidad (autocompletada con las que ya existen, pero puedes escribir una nueva), nombre de columna, tipo y checkbox "Requerida". Al guardar:
- Inserta la fila en
config.canonical_columns. - Llama a
syncEntityTable(entity): sicore.<entity>no existía la crea con todas sus columnas +id,source_raw_id,sourceyUNIQUE(source_raw_id, source). Si ya existía, solo haceALTER TABLE ADD COLUMN. - La columna física aparece al instante en
core.<entity>, vacía.
Para que se rellene con datos hay que mapearla a alguna raw_column en alguna source y esperar al próximo run del motor (sección 11).
7.3 Eliminar una columna canónica
Borra la fila de config.canonical_columns pero NO borra la columna física en core.<entity>. Esto es a propósito: nunca perdemos histórico. La columna queda "huérfana": existe pero el motor deja de actualizarla.
7.4 Tipos válidos y mapeo a Postgres
| data_type | Tipo Postgres |
|---|---|
text | TEXT |
numeric | NUMERIC |
timestamp | TIMESTAMPTZ |
boolean | BOOLEAN |
data_type de una columna ya existente NO ejecuta ALTER COLUMN TYPE. Si necesitas cambiarlo, hazlo a mano en SQL o borra y re-crea la columna.Lista los schemas RAW de Airbyte detectados en lyzard_warehouse. Convención obligatoria de naming:
tenant_<tenant>_<source>_raw
Ejemplos: tenant_test_faker_raw, tenant_cocacola_woo_raw. Cualquier schema que no siga este patrón se ignora.
8.1 Botón "Detectar sources"
Llama a POST /api/admin/sources/detect. El backend busca con regex ^tenant_[^_]+_[^_]+_raw$, parsea cada nombre y hace UPSERT en config.sources. Cualquier source antigua cuyo schema haya desaparecido se marca active=false.
8.2 Tabla
Cada source detectada se muestra con su tenant, source, schema_name, fecha de detección y estado (Activa/Inactiva). El botón Configurar mapeos abre el panel inline (sección 9).
detectSources() antes de cada tick — no necesitas pulsar el botón si das tiempo.El panel se abre debajo de la tabla cuando pulsas "Configurar mapeos" en una source. Define cómo cada columna RAW se vierte a una columna canónica del core.
9.1 Selector de tabla y entidad
Eliges una tabla RAW (las que existan en el schema de la source) y una entidad canónica destino. Si la tabla ya tenía mapeos, la entidad se preselecciona automáticamente.
9.2 Tabla de columnas RAW
Para cada columna de la tabla RAW se muestra:
- Nombre de la columna RAW.
- Tipo origen (lo que dice
information_schema). - 3 ejemplos reales de la BBDD para que sepas qué contiene.
- Selector "→ Columna canónica": las opciones son las columnas canónicas de la entidad seleccionada. Por defecto va en "— No mapear —".
Las columnas _airbyte_* nunca aparecen en este selector.
9.3 Auto-inyección
El motor pone solo cuatro columnas, NO las mapees:
tenant_id← tomado del nombre del schema.source← tomado del nombre del schema.synced_at←_airbyte_extracted_at.source_raw_id←_airbyte_raw_id(clave de deduplicación).
9.4 Botones
- Guardar mapeos —
PUT /api/admin/sources/:id/mappings. Borra todos los mapeos existentes para (source, raw_table) y guarda el set actual. Las columnas en "No mapear" se omiten. - Ejecutar staging para esta source —
POST /api/admin/sources/:id/staging/run. Procesa todas las (raw_table, entity) de esta source ahora mismo, sin esperar al cron.
Histórico y estado del motor. Datos en vivo desde config.staging_runs.
10.1 Última sincronización por source
Tarjetas con la ejecución más reciente por (source, entidad). Útil para ver de un vistazo si algo lleva sin actualizarse.
10.2 Histórico
Tabla con las últimas 200 ejecuciones. Por cada una: inicio, source, entidad, filas procesadas/insertadas/actualizadas, duración y estado (success / error / running). Hover sobre el badge error muestra el mensaje completo.
10.3 Botón "Ejecutar staging ahora"
POST /api/admin/staging/run. Lanza el motor para TODAS las sources activas que tengan mapeos. Útil para no esperar al cron horario.
"Staging" es un proceso, no un schema. Convierte los datos crudos de Airbyte en tablas canónicas. Vive en private/src/services/staging/.
11.1 Pipeline
- Airbyte sincroniza fuentes externas →
tenant_<t>_<s>_raw.<tabla>. - El motor lee
config.column_mappings, agrupa por (source, raw_table, canonical_entity). - Por cada grupo genera UN SQL:
INSERT … SELECT … FROM raw … ON CONFLICT (source_raw_id, source) DO UPDATE SET …. - Filtra siempre:
WHERE _airbyte_meta->>'changes' = '[]'(descarta filas con errores de Airbyte). - Inserta/actualiza en
core.<entity>con casts a los tipos canónicos. - Registra cada ejecución en
config.staging_runscon filas procesadas/insertadas/actualizadas y duración.
11.2 Idempotencia
La clave UNIQUE(source_raw_id, source) permite ejecutar el motor mil veces sin duplicar nada. Las filas existentes se actualizan, las nuevas se insertan.
11.3 Cron
node-cron en el proceso de la API. Frecuencia controlada por la env var STAGING_CRON (default 0 * * * *, cada hora en :00, TZ Europe/Madrid). Si una tick aún corre cuando llega la siguiente, la nueva se salta para no solapar.
11.4 Triggers manuales
- Botón "Ejecutar staging ahora" en Staging logs (todas las sources).
- Botón "Ejecutar staging para esta source" en el panel de mapeos.
11.5 Cuándo se actualiza el core
Solo cuando el motor corre. Cambiar un mapping no actualiza el core hasta el próximo tick. Añadir una columna canónica SÍ refleja en el core inmediatamente (es ALTER TABLE, no datos), pero la columna queda vacía hasta que la mapees y corra el motor.
Los tres ítems del bloque Sistema abren herramientas externas en una pestaña nueva. NO son páginas internas del panel.
12.1 Base de datos (/db/)
Visor read-only de la BBDD operativa lyzard. Implementado con pgweb en 127.0.0.1:5433. nginx lo proxea bajo /db/ con auth_request que valida la cookie lyzard_admin_session. Sin sesión activa → 302 al login.
12.2 Warehouse (/dw/)
Igual que el anterior pero apuntando a lyzard_warehouse (pgweb en :5434, prefijo /dw/). Aquí ves los schemas RAW de Airbyte, config.* y core.*.
12.3 Airbyte (airbyte.lyzard.es)
UI propia de Airbyte (versión 0.63.14 fijada). Aquí se configuran sources, destinations y connections. La autenticación reutiliza la cookie lyzard_admin_session de este panel — no pide segundo login.
12.4 Metabase (metabase.lyzard.es)
UI de Metabase (versión 0.52.13). Es la herramienta de Business Intelligence — aquí construyes dashboards y queries sobre los datos canonicalizados en core.* (o sobre cualquier otra BBDD que conectes desde la propia UI).
- Dónde corre: docker compose en
/opt/metabase/.network_mode: hostcon bind127.0.0.1:3020— nunca expuesto al WAN directamente. - Internal store: Postgres del propio host, BBDD
metabase_internal, usuariometabase_user. NO usa H2 — toda la configuración (dashboards, queries, usuarios de Metabase) persiste en Postgres. - Auth: el subrequest a
/api/admin-sessionprotege el acceso ANTES de llegar a la UI de Metabase. Sin cookie de admin Lyzard → 302 aadmin.lyzard.es. Después Metabase pide su propio login interno la primera vez (paso de setup). Ese login interno se gestiona desde la UI de Metabase, no desde aquí. - Recursos:
JAVA_OPTS=-Xmx512m. Si las queries son lentas, subir gradualmente vigilando memoria del VPS. - Conexión a las BBDD del proyecto: al añadir una database en Metabase usar
Host: 127.0.0.1(porque está en network_mode: host), puerto 5432, y la BBDD que toque (lyzard_warehousees la útil para BI; idealmente con un usuario de solo lectura).
MB_ENCRYPTION_SECRET_KEY en /opt/metabase/.env cifra las contraseñas de las DBs que Metabase guarda. Si se pierde, Metabase no puede leer las credenciales y hay que reconfigurar todas las conexiones a mano. Backup de ese fichero junto con los demás secretos.- Frontend admin: HTML estático en
/var/www/lyzard-admin/index.html. Sin build step, vanilla JS. - API: Node 18 + Express en
/var/www/lyzard/private/, escucha en127.0.0.1:3010, proxiada por nginx. Arrancada víastart.sh(nodemon). - Postgres: una instancia, dos BBDD:
lyzard(operativa) ylyzard_warehouse(DW + staging). - Pools de conexión:
db/pool.js→lyzard;db/warehousePool.js→lyzard_warehouse. - Auth: JWT (8h) + cookie HttpOnly. Middleware
requireAuth+requireSystemAdmin. - Logs: Winston a
private/logs/combined.logyprivate/logs/error.log.
Endpoints actuales del panel
| Método | Ruta | Función |
|---|---|---|
| GET | /api/admin/stats | Contadores del Resumen |
| GET / PATCH | /api/admin/tenants[/:id] | Listar / activar tenants |
| POST | /api/tenants | Crear tenant + owner |
| GET / PATCH | /api/admin/tenants/:id/features[/:key] | Feature flags por tenant |
| GET | /api/admin/features | Catálogo global de features |
| GET / POST | /api/admin/admins | Listar / crear administradores |
| GET / POST / DELETE | /api/admin/canonical-columns | Modelo canónico |
| POST | /api/admin/core/sync | Re-sincroniza todas las core.<entity> |
| GET / POST | /api/admin/sources[/detect] | Listar / detectar sources |
| GET | /api/admin/sources/:id/raw-tables | Tablas dentro de un schema RAW |
| GET | /api/admin/sources/:id/raw-tables/:table/preview | Columnas + 3 filas reales |
| GET / PUT | /api/admin/sources/:id/mappings | Mapeos por source |
| POST | /api/admin/staging/run | Ejecutar motor (todas) |
| POST | /api/admin/sources/:id/staging/run | Ejecutar motor (una source) |
| GET | /api/admin/staging/runs | Histórico de ejecuciones |
| GET / POST / PUT / DELETE | /api/admin/queries[/:id] | CRUD de la librería de queries (ver sección 15) |
| GET | /api/admin/queries/meta | Catálogos de categorías / entidades / chart_types |
| POST | /api/admin/queries/adapt | Adapta una query a multi-tenant (sin guardar) |
| POST | /api/admin/queries/:id/preview | Ejecuta contra tenant=test, máx 5 filas |
| POST / GET / DELETE | /api/admin-session | Cookie HttpOnly para nginx auth_request |
Metabase es la capa de Business Intelligence del stack. Lo desplegamos el 2026-05-07 reutilizando el mismo patrón de protección que Airbyte. Esta sección documenta a fondo cómo está montado, las decisiones de arquitectura, los dos bugs que pisamos durante la instalación, y las operaciones cotidianas. Si en el futuro hay que reinstalar, debugear o actualizar Metabase, este es el documento.
14.1 Resumen de una línea
Contenedor Docker de metabase/metabase:v0.52.13 en network_mode: host, escuchando solo en 127.0.0.1:3020, con su internal store en una BBDD metabase_internal dentro del Postgres del propio VPS, accesible desde fuera vía https://metabase.lyzard.es con el mismo gate de cookie que protege Airbyte y los visores pgweb.
14.2 Arquitectura
browser │ │ https://metabase.lyzard.es ▼ nginx :443 (TLS termination + auth_request) │ ├─ subrequest interno → 127.0.0.1:3010/api/admin-session │ │ (valida la cookie lyzard_admin_session ↔ JWT con isSystemAdmin) │ │ OK 200 → continúa · NOK → 302 a admin.lyzard.es │ │ │ └─ proxy_pass http://127.0.0.1:3020 ▼ Metabase (container, network_mode: host) ← Jetty en 127.0.0.1:3020 │ │ internal store (todo lo que Metabase guarda: dashboards, │ queries, usuarios de Metabase, configuraciones, etc.) ▼ Postgres (proceso del HOST, puerto 5432) └─ DB metabase_internal (owner: metabase_user)
14.3 Postgres (internal store)
Metabase necesita una BBDD persistente para sí mismo. NO usamos H2 (la default) porque H2 vive en un fichero local del contenedor y se pierde si reinstalamos. Usamos Postgres del propio VPS, así toda la configuración de Metabase persiste con los demás datos.
MB_DB_* apuntando a Postgres, así que toda la cadena Liquibase corrió directamente contra metabase_internal. No existe ni existió un metabase.db.mv.db. Si en el futuro alguien sugiere "migrar H2 a Postgres" en esta instancia, NO hace falta hacer nada — ya está. El procedimiento load-from-h2 solo aplica cuando se ha arrancado Metabase ANTES sin esas env vars.- BBDD:
metabase_internal - Usuario:
metabase_user(owner de la BBDD) - Password: guardada en
/opt/metabase/.envcomoMB_DB_PASS - pg_hba: añadida una línea idéntica al patrón del warehouse:
host metabase_internal metabase_user samehost scram-sha-256
La keywordsamehostmatchea cualquier IP que pertenezca a una interfaz local del servidor (loopback, docker0, eth0/WAN). Es restrictiva por DB+usuario para que ningún otro contenedor pueda intentar autenticarse contra esa BBDD.
14.4 docker-compose.yaml — la decisión clave: network_mode: host
El compose vive en /opt/metabase/docker-compose.yaml, separado del de Airbyte. La configuración es minimalista:
services:
metabase:
image: metabase/metabase:v0.52.13
container_name: metabase
restart: always
network_mode: host
env_file: .env
healthcheck: ...
La línea network_mode: host es la decisión NO obvia. Por qué la elegimos:
Si dejas que docker compose cree su propia red bridge (lo normal), tu contenedor recibe una IP en una subred custom (en este caso 172.21.0.0/16). Cuando ese contenedor intenta conectar al Postgres del host vía 172.17.0.1, los paquetes cruzan dos bridges Docker. Docker NO hace SNAT en ese cruce — el paquete sale del kernel con la IP raw del contenedor (172.21.0.2). Postgres recibe la conexión y ejecuta la regla samehost: 172.21.0.2 NO es una IP de ninguna interfaz del host (es del contenedor), así que la regla no matchea y Postgres rechaza con no pg_hba.conf entry for host "172.21.0.2".
Con network_mode: host el contenedor comparte el namespace de red del propio VPS. La conexión a Postgres ya no tiene que cruzar bridges: MB_DB_HOST=127.0.0.1 sale por loopback, Postgres ve 127.0.0.1 como source, samehost matchea, scram-sha-256 valida la password y entra. Cero magia de NAT.
Riesgo de host network: el contenedor podría intentar bindear a cualquier puerto del host. Lo mitigamos con dos env vars que fuerzan a Jetty a escuchar SOLO en loopback:
MB_JETTY_HOST=127.0.0.1 MB_JETTY_PORT=3020
Así Metabase queda colgado en 127.0.0.1:3020, exactamente como si tuviéramos un ports: "127.0.0.1:3020:3000" tradicional pero sin la complicación de bridges.
samehost. Para Metabase no podíamos confiar en esa rareza, así que elegimos host network.14.5 .env — secretos y JAVA_OPTS
El fichero /opt/metabase/.env contiene:
MB_DB_*— coordenadas de la BBDD interna (host, port, dbname, user, pass).MB_ENCRYPTION_SECRET_KEY— clave AES con la que Metabase cifra las contraseñas que guarde de las DBs que conectes desde su UI. Si la pierdes, todas esas credenciales quedan ilegibles y hay que reconfigurar conexiones manualmente. Backup obligatorio.MB_SITE_URL=https://metabase.lyzard.es— para que los enlaces que Metabase genere (por ejemplo en emails) apunten al hostname público.MB_JETTY_HOST,MB_JETTY_PORT— el bind a 127.0.0.1:3020.JAVA_OPTS—-Xmx512m -Xms256m -Dorg.quartz.jobStore.isClustered=false. Esto último merece su propia subsección.
14.6 La trampa del Quartz scheduler (segundo bug pisado)
Una vez resuelto el problema del bridge, Metabase entró en bucle de reinicio con un error críptico durante las migraciones de Liquibase:
Migration failed for changeset migrations/001_update_migrations.yaml::v46.00-086::calherries Reason: Cannot run without an instance id.
Diagnóstico paso a paso:
- Liquibase aplica las migraciones del internal store en orden cronológico.
- Algunas changesets son "custom migrations" en código Clojure, no SQL. Por ejemplo
DeleteAbandonmentEmailTask, que limpia un job antiguo del scheduler de Quartz. - Para limpiar ese job, la migración instancia un Quartz scheduler temporal vía
clojurewerkz.quartzite.scheduler/initialize. - El scheduler lee su configuración del
quartz.propertiesempaquetado enmetabase.jar. Ese fichero declara:org.quartz.jobStore.class = org.quartz.impl.jdbcjobstore.JobStoreTX org.quartz.jobStore.dataSource = db org.quartz.jobStore.isClustered = true
- Con
isClustered=true, Quartz ejecuta una validación al arrancar: si elinstanceIdresulta nulo o vacío, lanzaIllegalStateException("Cannot run without an instance id"). - En el flujo normal de Metabase (runtime) la dataSource y el instanceId los inyecta el código de la app antes de instanciar el scheduler. Pero durante una migración Liquibase ese código aún no se ha ejecutado, así que el scheduler arranca con dataSource sin configurar e instanceId vacío. Crash.
Fix: añadir -Dorg.quartz.jobStore.isClustered=false en JAVA_OPTS. Las propiedades del sistema con prefijo org.quartz. sobreescriben las del fichero, y al desactivar el modo cluster la validación deja de ejecutarse. Como Lyzard corre una sola instancia de Metabase, no perdemos nada al desactivar el clustering.
databasechangelog. El bug está en el quartz.properties del JAR (presente en todas las versiones desde la introducción de la migración custom). La causa raíz es isClustered=true shippeado por defecto. Solo el fix por JAVA_OPTS resolvió el problema definitivamente.14.7 Nginx — el gate de autenticación
Vhost en /etc/nginx/conf.d/metabase.lyzard.es.conf. Réplica del pattern de Airbyte. Lo importante:
- Puerto 80 redirige todo a HTTPS, excepto
/.well-known/acme-challenge/(para renovaciones de certbot). - Puerto 443 termina TLS y aplica el gate.
- Subrequest interno
location = /__metabase_auth:auth_requestenvía un GET al backend de Lyzard (127.0.0.1:3010/api/admin-session) que valida la cookielyzard_admin_sessiony devuelve 200 (allow) o 401 (deny). Se desactiva CORS borrando los headersOriginyRefererantes del subrequest, porque el middleware CORS del backend rechazaría la petición interna como cross-origin. - Bloque
location /aplicaauth_request /__metabase_authy proxypassa ahttp://127.0.0.1:3020. - Caso 401/403: redirige a
https://admin.lyzard.es/?metabase=unauthorizedpara que el operador haga login y vuelva. - Timeouts:
proxy_read_timeout 600sporque Metabase a veces tarda mucho en queries largas (long-poll en la UI).
14.8 SSL via Let's Encrypt
Mismo patrón que el resto del proyecto:
- Pre-creamos
/var/www/lyzard-metabase-webroot/. - Subimos un nginx vhost mínimo solo con HTTP y la location
/.well-known/acme-challenge/apuntando al webroot. - Lanzamos
certbot certonly --webroot. Emite el cert. - Reemplazamos el vhost por el completo (HTTPS + auth_request).
- Renovación automática via el timer de certbot (sin intervención).
Cert válido hasta 2026-08-05. Renueva ~30 días antes.
14.9 Doble nivel de autenticación
Metabase tiene su propio sistema de login. Esto significa que para entrar tienes que pasar DOS puertas:
- Puerta 1 — nginx: la cookie
lyzard_admin_session(administrador del sistema Lyzard). Sin ella, redirección a admin.lyzard.es. - Puerta 2 — Metabase: usuario y contraseña del propio Metabase. Se crea la primera vez vía wizard. Es independiente de los usuarios de Lyzard — pueden coincidir en email pero las contraseñas son separadas.
La primera puerta protege contra accesos externos no autorizados. La segunda gestiona permisos finos dentro de Metabase (quién ve qué dashboard, quién puede crear queries, etc.).
14.10 El wizard inicial (a hacer una sola vez)
- Visita
https://metabase.lyzard.esautenticado como admin Lyzard. - Idioma → Español.
- Crea el usuario admin de Metabase: email + nombre + contraseña. Recomendación: misma cuenta de email pero contraseña distinta para que no haya confusión entre la sesión de Lyzard y la de Metabase.
- "Add your data" → puedes saltarlo y conectarlo después desde Settings → Databases. Las credenciales para conectar al warehouse están en la sub-sección 14.10.1.
- Usage tracking → desactivar si no quieres telemetría hacia Metabase Inc.
14.10.1 Usuario read-only para conectar el warehouse
Existe un usuario Postgres dedicado solo a leer desde Metabase: lyzard_warehouse_reader. Tiene SELECT en core.* y config.*, NO ve los schemas tenant_*_*_raw y cualquier intento de INSERT/UPDATE/DELETE es rechazado por Postgres. Las default privileges están configuradas para que tablas/secuencias futuras creadas por lyzard_warehouse_user en esos dos schemas hereden el SELECT automáticamente.
Valores para el formulario "Add Database" en Metabase:
| Campo | Valor |
|---|---|
| Database type | PostgreSQL |
| Display name | Lyzard Warehouse |
| Host | 127.0.0.1 |
| Port | 5432 |
| Database name | lyzard_warehouse |
| Username | lyzard_warehouse_reader |
| Password | guardada en /opt/metabase/warehouse-reader.env (línea WAREHOUSE_READER_PASS=...) |
| SSL | desactivado (loopback, no sale del host) |
| SSH tunnel | no |
Después de añadir la BBDD, Metabase escanea las tablas y guarda metadata. Verás core.products, core.orders, core.customers, las tablas de config, etc.
14.10.2 Dónde están guardadas las credenciales del reader
- Fichero:
/opt/metabase/warehouse-reader.env(chmod 600, root:root). - Contiene:
WAREHOUSE_READER_HOST,WAREHOUSE_READER_PORT,WAREHOUSE_READER_DBNAME,WAREHOUSE_READER_USER,WAREHOUSE_READER_PASS. - NO se carga en docker-compose (el compose solo lee
.env). Es una nota persistente para que cuando configures la conexión en la UI de Metabase no tengas que adivinar la password. - Para verla rápido:
cat /opt/metabase/warehouse-reader.env(necesitas root). - Si la pierdes, regenera password en Postgres y reescribe el fichero:
sudo -u postgres psql -c \\ "ALTER ROLE lyzard_warehouse_reader PASSWORD '$(openssl rand -hex 32)';" # luego reflejar el nuevo valor en /opt/metabase/warehouse-reader.env # y actualizar la conexión existente en Metabase Settings → Databases
14.11 Operaciones del día a día
| Acción | Comando |
|---|---|
| Ver estado | docker inspect metabase --format '{{.State.Health.Status}}' |
| Logs en vivo | docker logs -f metabase |
| Reiniciar | cd /opt/metabase && docker compose restart |
| Apagar | cd /opt/metabase && docker compose down |
| Subir versión | editar docker-compose.yaml → cambiar tag → docker compose pull && docker compose up -d. Las migraciones del internal store corren solas. Mantener isClustered=false en JAVA_OPTS por si hay nuevas custom migrations afectadas. |
| Backup del internal store | pg_dump -U metabase_user -h 127.0.0.1 metabase_internal > metabase_$(date +%F).sql |
| Restore | psql -U metabase_user -d metabase_internal < metabase_FECHA.sql |
14.12 Recursos y límites
- Memoria reservada:
-Xmx512m -Xms256m. En idle Metabase consume ~600-800 MB resident. - El VPS tiene 3.8 GB y Airbyte ya usa ~2 GB. El swap (16 GB) absorbe picos.
- Si el dashboard empieza a renderizar lento o las queries fallan por OOM, subir
-Xmxen pasos de 256 MB. No subir más allá de 1 GB sin ampliar la RAM del VPS.
14.13 Pipeline de datos completo
┌──────────┐ ┌─────────────────────┐ ┌──────────────┐ ┌──────────┐ │ Fuentes │ ─▶ │ Airbyte (sync) │ ─▶ │ Motor stagi. │ ─▶ │ Metabase │ │ externas │ │ → tenant_X_Y_raw.* │ │ → core.* │ │ → BI/UI │ └──────────┘ └─────────────────────┘ └──────────────┘ └──────────┘ APIs cron Airbyte cron Lyzard tu trabajo DBs (cada N horas) cada hora de análisis
Metabase es la última pieza: lee core.* (o cualquier otra BBDD) y la convierte en preguntas y respuestas visuales. NO escribe nunca en esos schemas, solo SELECT.
14.14 Ficheros que existen gracias a este deploy
/opt/metabase/docker-compose.yaml/opt/metabase/.env(secretos)/etc/nginx/conf.d/metabase.lyzard.es.conf/var/www/lyzard-metabase-webroot//etc/letsencrypt/live/metabase.lyzard.es/*/etc/postgresql/15/main/pg_hba.conf(línea añadida para metabase_user)- BBDD
metabase_internalen Postgres + rolemetabase_user
Sistema para guardar queries SQL parametrizables que se ejecutan filtrando por tenant_id. Las queries se construyen visualmente en Metabase, se pegan aquí, el sistema las adapta para multi-tenancy y luego cada dashboard de cliente las renderiza filtradas por el tenant correspondiente.
15.1 Tabla en BBDD
config.queries en lyzard_warehouse. Columnas:
id,name,descriptionsql_template— el SQL con{{tenant_id}}como placeholder.category— uno deessential,ecommerce,saas,restaurant(CHECK).entity— opcional:orders,products,customers,other.chart_type— opcional:line,bar,kpi,table.active— soft delete; el botón "Desactivar" solo cambia este flag, NO borra la fila.created_at,updated_at(este último mantenido por trigger).
15.2 Lógica de adaptación multi-tenant (library.adaptForTenant)
Recibe un SQL de Metabase y devuelve la versión con tenant_id inyectado.
- Trim + quita
;finales. - Valida que sea un SELECT/WITH (sección 15.4).
- Si la query ya menciona
tenant_iden código (fuera de strings y comentarios) → la deja sin tocar y reporta no modificada. - Si NO contiene
tenant_id, escanea la query a profundidad de paréntesis 0 (ignora subqueries) y localiza:- la posición de la
WHEREouter (si existe); - el primer terminador outer entre
GROUP BY,ORDER BY,LIMIT,HAVING,OFFSET,FETCH,UNION,INTERSECT,EXCEPT,WINDOW,FOR.
- la posición de la
- Si hay
WHEREouter: añadeAND tenant_id = {{tenant_id}}justo antes del terminador (o al final). - Si NO hay
WHEREouter: insertaWHERE tenant_id = {{tenant_id}}antes del terminador (o al final).
El parser respeta strings ('...', "") y comentarios (--, /* */), así que WHERE dentro de un string nunca se interpreta como cláusula real.
Limitaciones conocidas:
- UNION: la inyección se aplica al final, no a cada SELECT del UNION. Si tu query usa UNION, escribe el
tenant_idmanualmente en cada rama y deja que el sistema detecte que ya está presente. - JOINs con alias: si la outer query tiene
FROM t1 JOIN t2, el sistema añadetenant_id = ...sin alias. Postgres rechazará sitenant_ides ambiguo. En ese caso edita el resultado para usart1.tenant_idantes de guardar. - CTEs: las CTEs proyectan tenant_id transparentemente solo si los SELECT internos lo seleccionan. Si filtras dentro de la CTE y proyectas tenant_id al exterior, perfecto; si no, el tenant_id se filtra solo en el outer SELECT (que para queries simples basta).
{{tenant_id}} resaltado. Si la heurística no acertó (caso UNION, JOIN ambiguo, CTE rara), edita a mano antes de guardar.15.3 Ejecución parametrizada
Al ejecutar (preview o desde el dashboard del tenant) NO se concatenan strings. El flujo es:
- Reemplaza
{{tenant_id}}por$1a nivel textual. - Envuelve la query con
SELECT * FROM (...) AS _preview LIMIT 5para acotar el resultado. - Abre transacción →
SET LOCAL statement_timeout = '5000ms'. - Ejecuta
client.query(wrapped, [tenantId])— el driverpgescapa el valor. ROLLBACKSIEMPRE (también en éxito) para garantizar que cualquier efecto colateral no persiste.
En consecuencia, aunque alguien se las apañe para colar una query con función con efectos secundarios, el rollback la deshace.
15.4 Validaciones de seguridad
- La query DEBE empezar por
SELECToWITH(tras quitar comentarios y espacios iniciales). - Palabras reservadas prohibidas:
INSERT,UPDATE,DELETE,DROP,TRUNCATE,ALTER,GRANT,REVOKE,CREATE,COMMENT,COPY,CALL,MERGE,EXECUTE,DO,PREPARE,DEALLOCATE,VACUUM,REINDEX,CLUSTER,LOCK,NOTIFY,LISTEN,UNLISTEN. Detección con tokenización que ignora strings/comentarios para que palabras dentro de un literal no disparen falsos positivos. - Múltiples sentencias bloqueadas: cualquier
;seguido de algo que no sea whitespace o comentario falla la validación. - El placeholder único permitido es
{{tenant_id}}. La query DEBE incluirlo antes de guardar — el sistema lo verifica. - El preview corre exclusivamente con
tenant=test, hardcodeado en el endpoint. El frontend no puede enviarle otro tenant.
15.5 Endpoints (montados bajo /api/admin/queries)
| Método | Ruta | Función |
|---|---|---|
| GET | / | Lista queries (params: ?active=true, ?category=...) |
| GET | /meta | Devuelve catálogos: categorías, entidades, tipos de gráfico, placeholder, tenant de preview |
| POST | /adapt | Recibe { sql }, devuelve { sql, modified, note } sin guardar nada. Sirve para el botón "Adaptar" |
| GET | /:id | Detalle |
| POST | / | Crea |
| PUT | /:id | Edita |
| DELETE | /:id | Soft delete (active=false) |
| POST | /:id/preview | Ejecuta contra tenant=test, devuelve hasta 5 filas |
15.6 Panel — vista lista
Tabla con todas las queries, filtro por categoría, botones "Editar" y "Desactivar" por fila, botón "Nueva query" arriba. Las queries inactivas siguen apareciendo (con badge gris) — para activarlas hay que entrar a editar y marcar el checkbox.
15.7 Panel — vista form (new / edit)
- Campos: nombre, descripción, categoría, entidad, tipo de gráfico, SQL, checkbox activa.
- Botón "Adaptar para multi-tenant": llama a
POST /adapt, muestra el resultado en un bloque readonly con{{tenant_id}}resaltado en lima. También sobreescribe el textarea con la versión adaptada para que sea exactamente lo que se guarda. - Botón "Preview": solo se habilita después de guardar (porque el endpoint
/previewtrabaja sobre elid). Muestra hasta 5 filas contenant=testhardcodeado. - Botón "Guardar": si pulsas guardar antes de adaptar, falla con un mensaje pidiéndolo.
- Editar y guardar repetidamente actualiza la misma fila en BBDD; el trigger
queries_set_updated_atmantieneupdated_atal día.
15.8 Soft delete vs purga
El botón "Desactivar" del panel lanza DELETE /api/admin/queries/:id que internamente hace UPDATE config.queries SET active = false. NO borra la fila. Razón: dashboards de tenants pueden estar referenciando el id de la query, y un DELETE real rompería esos dashboards. Si quieres purgar de verdad, hazlo manualmente vía SQL después de comprobar que ningún dashboard la usa.
15.9 Cómo lo usa el dashboard del tenant (futuro)
Pendiente de implementar pero el contrato es: el dashboard del cliente, al renderizar, llama a un endpoint del backend pasándole el id de la query y su tenantId (extraído del JWT). El backend usa library.executeWithTenant(template, tenantId) con ese tenantId. La capa de feature flags decide qué queries se ofrecen a cada tenant según su categoría/plan.
Cada vez que añadas algo, sigue este checklist:
- Implementa la lógica (backend + frontend).
- Si requiere un toggle por tenant, registra la feature en la tabla
features(ver sección 5). - Documenta en esta misma página: añade una sección nueva al final con su ID anclado, su entrada en el índice y un explainer en el mismo tono que las demás (qué hace, cómo se usa, gotchas).
- Si tocaste schemas
config.*ocore.*, añade migración enprivate/src/db/migrations/y ejecútala contralyzard_warehouse. - Si añadiste un nuevo endpoint, agrégalo a la tabla de la sección 13.
- Si añadiste una query SQL nueva, hazlo desde la propia página "Librería de queries" (sección 15) — NO toques la tabla a mano salvo que sea una migración estructural.
- Si el deploy implicó decisiones técnicas no obvias (network mode, workarounds, secretos), abre una sección dedicada con el patrón de la sección 14 de Metabase: arquitectura, decisión clave + por qué, trampas pisadas, operaciones del día a día.
Tenants
Gestión de workspaces de clientes
| Nombre | Slug | Usuarios | Creado | Estado | |
|---|---|---|---|---|---|
Cargando... | |||||
Administradores
Usuarios con acceso al panel de administración
| Nombre | Workspace | Último acceso | Estado | |
|---|---|---|---|---|
Cargando... | ||||
Modelo canónico del core
Define qué columnas tiene cada entidad del data warehouse. Al añadir una nueva, la tabla core.{entidad} se actualiza automáticamente.
Sources
Sources detectadas automáticamente desde los schemas de Airbyte (tenant_{tenant}_{source}_raw).
| Tenant | Source | Schema | Detectada | Estado | |
|---|---|---|---|---|---|
Cargando... | |||||
Staging logs
Histórico de ejecuciones del motor de staging.
| Inicio | Source | Entidad | Filas | Insertadas | Actualizadas | Duración | Estado |
|---|---|---|---|---|---|---|---|
Cargando... | |||||||
Librería de queries
Plantillas SQL multi-tenant. Construye la query visualmente en Metabase, pégala aquí, deja que el sistema le añada tenant_id, y guárdala. El dashboard de cada cliente las renderiza filtrando por su tenant.
| Nombre | Categoría | Entidad | Gráfico | Estado | Actualizada | |
|---|---|---|---|---|---|---|
Cargando... | ||||||
Nueva query
Pega el SQL desde Metabase, dale a "Adaptar para multi-tenant" para que el sistema añada tenant_id, revisa el resultado y guarda.
{{tenant_id}}. La sustitución por el valor real se hace via parámetros del driver pg, nunca por concatenación.