Files
fn_registry/apps/auto_metabase/app.md
T
egutierrez 310b409ae0 feat(auto_metabase): push-all + describe/sql + auto-inject de dashcards
- 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>
2026-04-13 13:14:05 +02:00

391 lines
14 KiB
Markdown

---
name: auto_metabase
lang: py
domain: analytics
description: "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."
tags: [metabase, sync, declarative, yaml, dashboards, gitops]
uses_functions:
- metabase_auth_py_infra
- metabase_list_databases_py_infra
- metabase_get_database_py_infra
- metabase_add_database_py_infra
- metabase_list_dashboards_py_infra
- metabase_get_dashboard_py_infra
- metabase_create_dashboard_py_infra
- metabase_update_dashboard_py_infra
- metabase_delete_dashboard_py_infra
- metabase_list_cards_py_infra
- metabase_get_card_py_infra
- metabase_create_card_py_infra
- metabase_update_card_py_infra
- metabase_delete_card_py_infra
- metabase_execute_query_py_infra
uses_types: []
framework: httpx
entry_point: "main.py"
dir_path: "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:
```yaml
default_project: test_local
projects_dir: projects
```
**Por proyecto** `projects/{name}/config.yaml` define la URL y reglas de sync:
```yaml
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:
```json
{
"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/`:
```json
"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:
```yaml
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 (no `DATE_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:
```yaml
_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:
```bash
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:
```bash
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 `pull` automatico → 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:
```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:
```bash
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 trae `id` (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
```bash
# 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-all` captura el `SystemExit`,
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
en `state/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.
```bash
# 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`:
1. `--limit N` (default **100**) → se envia como `max-results` constraint a Metabase. Metabase corta server-side, no transferimos filas de mas.
2. **Hard ceiling 10 000 filas** — incluso `--limit 999999` se cappea.
3. **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:**
```bash
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.