--- 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 ` | exploracion | Lista tablas, columnas y tipos de un database (`--samples` para 3 filas demo) | | `sql "QUERY"` | exploracion | Ejecuta SQL ad-hoc, NO crea card. `--limit 100` por defecto | | `remote ` | exploracion | Lista items en Metabase sin descargar nada | | `pull ` | sync | Trae UN item de Metabase a disco (per-item, nunca bulk) | | `validate ` | sync | Valida YAML local sin tocar Metabase. `--check-sql` ejecuta la query | | `push ` | 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 ` | sync | Restaura YAML local desde backup (no aplica a Metabase) | | `diff ` | 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: 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/.yaml python main.py push card --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/ ``` Cualquier cambio futuro: editar el YAML, `push-all --apply` (o `push --apply` si quieres aplicar solo uno). Cualquier cambio hecho desde la UI de Metabase: `pull `. La logica de freshness (R17) avisa si hubo cambios remotos no traidos antes de pushear local.