- push_all(): pushea todos los YAMLs de un proyecto (cards primero,
dashboards despues), solo CREATE/UPDATE, resiliente a fallos por item
- explore.py: comandos describe (schema de DB) y sql (query ad-hoc con
limite, cap 5MB, bloqueo de escrituras destructivas)
- payload.py: auto-inyecta id:-N, visualization_settings:{} y
parameter_mappings:[] en dashcards nuevas para evitar 500 en push
- test_local: 11 cards + 3 dashboards sobre Sample Database de Metabase
- registry.db regenerado con auto_metabase_py_analytics indexada
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
14 KiB
name, lang, domain, description, tags, uses_functions, uses_types, framework, entry_point, dir_path
| name | lang | domain | description | tags | uses_functions | uses_types | framework | entry_point | dir_path | |||||||||||||||||||||
|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
| auto_metabase | py | analytics | Sincronizacion bidireccional entre archivos YAML locales y una instancia Metabase. Cada dashboard, card, database, collection y document es un archivo editable; pull/push mantiene Metabase y disco en sintonia. Inspirado en rapid_dashboards. |
|
|
httpx | main.py | apps/auto_metabase |
Idea
Cada artefacto de Metabase (dashboard, card, database, collection, document) vive como un archivo YAML editable. Los archivos son la fuente de verdad.
Multi-proyecto: cada entorno Metabase (local, prod, staging, ...) es un
proyecto aislado bajo projects/{name}/ con su propio config.yaml, .env,
state/ y carpetas de YAMLs. El proyecto activo se elige con -p NAME
o por defecto desde config.yaml top-level (default_project).
Comandos
| Comando | Categoria | Que hace |
|---|---|---|
projects |
proyectos | Lista proyectos disponibles y marca el default |
init-project NAME --base-url URL |
proyectos | Crea proyecto nuevo (config + carpetas + index vacio) |
login |
sesion | Autentica contra Metabase y guarda token en state/session.json |
status |
sesion | Muestra estado del proyecto (sesion, items en index, archivos en disco) |
describe <db> |
exploracion | Lista tablas, columnas y tipos de un database (--samples para 3 filas demo) |
sql <db> "QUERY" |
exploracion | Ejecuta SQL ad-hoc, NO crea card. --limit 100 por defecto |
remote <kind> |
exploracion | Lista items en Metabase sin descargar nada |
pull <kind> <ref> |
sync | Trae UN item de Metabase a disco (per-item, nunca bulk) |
validate <kind> <slug> |
sync | Valida YAML local sin tocar Metabase. --check-sql ejecuta la query |
push <kind> <slug> |
sync | Aplica UN item a Metabase. Dry-run por defecto. --apply para enviar |
push-all |
sync | Pushea TODOS los YAMLs del proyecto. Solo CREATE/UPDATE, nunca DELETE |
restore <kind> <slug> |
sync | Restaura YAML local desde backup (no aplica a Metabase) |
diff <kind> <slug> |
sync | Alias temporal de validate --show-payload |
Layout
apps/auto_metabase/
config.yaml # default_project + projects_dir
main.py # CLI entrypoint
payload.py # builders: YAML -> payload Metabase (resuelve refs, auto-inyecta)
sync_pull.py # pull per-item desde Metabase
sync_push.py # push per-item + push_all (R1-R20)
sync_validate.py # validacion estructural + SQL check
sync_restore.py # restore desde backups locales
explore.py # describe + sql ad-hoc
scripts/seed_test_data.py # seed inicial de db+cards+dashboard de prueba
projects/
{name}/ # un directorio por entorno Metabase
config.yaml # base_url + auth + sync rules
.env # credenciales (gitignored)
state/
session.json # token de sesion (gitignored)
index.json # slug local <-> metabase_id por tipo
push.log # JSONL append-only de cada push
backups/{ts}/{kind}/... # backup automatico antes de UPDATE
databases/{slug}.yaml # connections
collections/{slug}.yaml # collections (carpetas)
cards/{slug}.yaml # cards reusables
dashboards/{slug}.yaml # dashboards con dashcards
documents/{slug}.yaml # [pendiente] Metabase >= v0.51
Configuracion
Top-level config.yaml (en la raiz del app) selecciona el proyecto por defecto:
default_project: test_local
projects_dir: projects
Por proyecto projects/{name}/config.yaml define la URL y reglas de sync:
name: test_local
description: "Metabase de prueba en Docker local"
base_url: http://localhost:3000
auth:
email_env: METABASE_EMAIL
password_env: METABASE_PASSWORD
sync:
ignore_collections: [] # IDs de colecciones a no sincronizar
ignore_databases: [1] # 1 = "Sample Database" interno de Metabase
prefer_archive: true # archivar en vez de borrar al hacer push
Credenciales en projects/{name}/.env (gitignored):
METABASE_EMAIL=admin@example.com
METABASE_PASSWORD=changeme
Estado
state/index.json mantiene el mapeo slug local <-> id de Metabase para no
duplicar al hacer push. Estructura:
{
"databases": { "registry": 2, "ops_demo": 3 },
"collections": { "auto_metabase": 5 },
"cards": { "totals_by_domain": 12 },
"dashboards": { "fn_overview": 4 }
}
Pendiente
- Soporte de Metabase Documents (a investigar — endpoint nuevo en versiones recientes).
- Soporte de Pulses / Alerts / Subscriptions.
- Soporte de Permissions (groups + permission_graph).
- Diff con render colorizado en TUI.
- Recovery automatico cuando un CREATE de dashboard falla a medias (POST ok + PUT fail deja un dashboard huerfano sin entrar al index).
Crear cards y dashboards desde cero (workflow probado)
1. Referenciar una database que no se sincroniza
Si quieres apuntar cards a la Sample Database de Metabase (db 1, ignore_databases: [1] por defecto), basta con anadir el slug al state/index.json — no hace falta YAML en databases/:
"databases": {
"metabase_internal_pg": 2,
"sample_database": 1
}
Las cards luego usan _refs.database: sample_database y dataset_query.database: sample_database, y el builder lo resuelve a 1 via index.
2. Formato del dataset_query (MBQL v2 con SQL nativo)
Metabase moderno usa MBQL stages, no la forma vieja {type: native, native: {query: ...}}. La forma correcta para cards SQL:
dataset_query:
lib/type: mbql/query
database: <slug_o_id>
stages:
- lib/type: mbql.stage/native
native: |-
SELECT ...
query_type: native se mantiene en el payload top-level (Metabase lo usa internamente).
3. SQL del Sample Database = H2
Tablas en MAYUSCULAS (PEOPLE, ORDERS, PRODUCTS...) y funciones H2:
FORMATDATETIME(col, 'yyyy-MM')para truncar fechas (noDATE_TRUNC).ROUND(SUM(x), 2)igual que en Postgres.- Joins y agregaciones SQL-92 estandar.
4. Crear cards nuevas
YAML minimo de una card nueva:
_meta:
kind: card
id: null # null = nueva, push hara CREATE
slug: clientes_total # debe coincidir con el filename
_refs:
database: sample_database
collection: null
payload:
name: clientes_total
description: ...
type: question
query_type: native
display: scalar # o table, bar, line, pie, area, ...
archived: false
enable_embedding: false
collection_preview: true
visualization_settings: {}
parameters: []
parameter_mappings: []
dataset_query:
lib/type: mbql/query
database: sample_database
stages:
- lib/type: mbql.stage/native
native: SELECT COUNT(*) AS total FROM PEOPLE
Validar antes de pushear:
python main.py validate card clientes_total --check-sql
--check-sql ejecuta la query contra Metabase (solo para cards native). Pillar errores de sintaxis aqui es mas barato que en push.
Push:
python main.py push card clientes_total --apply
Sin --apply es dry-run. Tras un CREATE exitoso:
- Se asigna id real, se actualiza
state/index.json. - Se hace
pullautomatico → el YAML se reescribe con_meta.id,synced_at,remote_updated_at. - Hay que pushear las cards UNA POR UNA (no hay batch).
5. Crear dashboards nuevos
Las cards referenciadas deben existir antes (ya en el index). Estructura minima del YAML:
_meta:
kind: dashboard
id: null
slug: compras_y_clientes
_refs:
collection: null
payload:
name: Compras y Clientes
description: ...
archived: false
enable_embedding: false
width: fixed # o "full"
auto_apply_filters: true
parameters: []
tabs: []
dashcards:
- card: clientes_total # slug, se resuelve a card_id
row: 0
col: 0
size_x: 8 # grid de 24 columnas
size_y: 3
- card: compras_total
row: 0
col: 8
size_x: 8
size_y: 3
Push:
python main.py push dashboard compras_y_clientes --apply
Auto-inyeccion (build_dashboard_payload) — para cada dashcard sin id,
inyecta automaticamente:
id: negativo unico (-1, -2, ...) en orden de aparicion. Metabase lo necesita para distinguir dashcards nuevas (id < 0) de las existentes (id > 0). Si la dashcard ya traeid(positivo o negativo), se respeta tal cual.visualization_settings: {}si falta.parameter_mappings: []si falta.
Esto resuelve el escollo historico: antes, omitir esos campos provocaba que el
POST creara el dashboard pero el PUT siguiente devolviera 500 — quedando un
dashboard huerfano en Metabase que no entraba al index local. Ahora los YAMLs
de dashboards son tan simples como los de cards: solo card, row, col,
size_x, size_y por dashcard.
6. push-all — proyecto entero en un comando
# Dry-run de todo el proyecto (cards primero, dashboards despues)
python main.py push-all
# Aplicar
python main.py push-all --apply
# Solo cards (saltar dashboards)
python main.py push-all --apply --kinds card
# Forzar saltando R17 (freshness) y R18 (count) en cada item
python main.py push-all --apply --force-overwrite
# Permitir warnings estructurales
python main.py push-all --apply --allow-warnings
Garantias:
- Solo CREATE o UPDATE — nunca DELETE. Si un YAML local desaparece, el item correspondiente sigue intacto en Metabase. La unica via para borrar algo en Metabase es manualmente via UI o API.
- Cards primero, dashboards despues. Asi cualquier card recien creada ya esta en el index cuando se construye el payload del dashboard que la referencia.
- Resiliente a fallos por item. Si una card falla (validation error, SQL
invalido, conflicto de freshness),
push-allcaptura elSystemExit, lo registra en el resumen final y continua con el siguiente item. - Reusa toda la logica de
push_one: backup obligatorio antes de UPDATE (R6), freshness check (R17), count check para dashboards (R18), log JSONL enstate/push.log(R13).
Resumen final mostrado en stdout:
=== resumen push all ===
OK: 11 ['card:clientes_total', 'card:compras_total', ...]
FAILED: 0 []
Exit code: 1 si hubo fallos, 0 si todo OK.
7. Grid de Metabase
24 columnas, alturas en filas de ~30px. Layout que funciona bien para 6 cards:
| Fila | Cards | Filas (size_y) |
|---|---|---|
| 0 | 3 KPIs scalar (8 cols c/u) | 3 |
| 3 | 2 graficos (12 cols c/u) | 6 |
| 9 | 1 tabla ancha (24 cols) | 7 |
8. Exploracion rapida: describe + sql
Dos comandos para no escribir cards a ciegas. Read-only, no tocan nada en disco ni en Metabase.
# Schema del database (tablas + columnas + tipos)
python main.py describe sample_database
python main.py describe sample_database --tables-only # solo nombres + descripcion
python main.py describe sample_database --filter products # una sola tabla
python main.py describe sample_database --samples # +3 filas de ejemplo por tabla
# SQL ad-hoc — ideal para iterar antes de guardar como card
python main.py sql sample_database "SELECT CATEGORY, COUNT(*) FROM PRODUCTS GROUP BY CATEGORY"
python main.py sql sample_database "SELECT * FROM ORDERS" --limit 5
Tres barreras anti-explosion en sql:
--limit N(default 100) → se envia comomax-resultsconstraint a Metabase. Metabase corta server-side, no transferimos filas de mas.- Hard ceiling 10 000 filas — incluso
--limit 999999se cappea. - 5 MB de payload — si las filas son anchas y exceden, se recorta antes de imprimir.
Queries que empiezan por INSERT/UPDATE/DELETE/DROP/TRUNCATE/ALTER/CREATE se bloquean. --allow-write las deja pasar (Metabase normalmente las bloquea igualmente en /api/dataset).
Errores SQL devuelven el mensaje de Metabase limpio:
$ python main.py sql sample_database "SELECT NOPE FROM PEOPLE"
ERROR (400): Column "NOPE" not found; SQL statement: SELECT NOPE FROM PEOPLE [42122-214]
Workflow recomendado para card nueva:
python main.py describe sample_database --filter orders # 1. ver columnas
python main.py sql sample_database "SELECT ... FROM ORDERS" # 2. iterar SQL en stdout
# 3. cuando la query funciona, copiarla a cards/<slug>.yaml
python main.py push card <slug> --apply # 4. guardarla como card
9. Resumen del flujo
1. Editar YAMLs de cards en projects/{name}/cards/
2. Editar YAML del dashboard con dashcards (solo card slug + row/col/size — auto-inject hace el resto)
3. python main.py push-all --apply
4. Abrir http://localhost:3000/dashboard/<id>
Cualquier cambio futuro: editar el YAML, push-all --apply (o push <kind> <slug> --apply si quieres aplicar solo uno). Cualquier cambio hecho desde la UI de Metabase: pull <kind> <slug>. La logica de freshness (R17) avisa si hubo cambios remotos no traidos antes de pushear local.