310b409ae0
- 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>
391 lines
14 KiB
Markdown
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.
|