--- id: "0024" title: "Split dashboard YAMLs por tab" status: completado type: feature domain: [] scope: multi-app priority: alta depends: [] blocks: [] related: [] created: 2026-05-17 updated: 2026-05-17 tags: [] --- # 0024 — Split dashboard YAMLs por tab ## APP Metadata | Campo | Valor | |-------|-------| | **ID** | 0024 | | **Estado** | pendiente | | **Prioridad** | alta | | **Tipo** | mejora — devex de auto_metabase | ## Dependencias Ninguna. Modifica `apps/auto_metabase/sync_pull.py`, `sync_push.py`, `sync_validate.py` y `app.md`. **Desbloquea:** edicion fluida de dashboards grandes (aurgi id=734: 11.282 lineas, 90 dashcards, 11 tabs). Actualmente cada cambio obliga a scrollear el monolitico; tras esta issue cada tab es un archivo independiente. --- ## Objetivo Dividir el YAML monolitico de cada dashboard en un directorio `{slug}/` con un archivo `_dashboard.yaml` (metadata, parametros, tabs) y un archivo por tab (`tab_{slug_tab}.yaml`) con sus dashcards. El push compone todo de vuelta a un unico body JSON antes de enviar a Metabase — cambio 100% cosmetico del sync local, el API no se entera. ## Contexto **Situacion actual:** - `pull_dashboard()` escribe `projects/{name}/dashboards/{slug}.yaml` con todo: metadata + parametros + tabs + 90+ dashcards en un solo archivo. - Aurgi (proyecto real) tiene un dashboard `bi_ventas_portfolio_producto_en_construccion` que genera **11.282 lineas YAML**. Para editar una dashcard en el tab "Foto Categorias" hay que encontrarla entre 90 entries via `dashboard_tab_id` (que recientemente volvimos a preservar — ver commit de sync_pull que re-inyecta ids de tabs tras el strip). - Los diffs de git son ilegibles cuando se reordenan dashcards: cambiar 1 dashcard puede mover 40 bloques de YAML y romper el review. - `validate` corre sobre el archivo completo aunque solo hayas editado una tab. **Alternativas consideradas:** - **One file per dashcard** (muy granular) — descartado: demasiados archivos para un dashboard mediano, el tab se pierde como unidad semantica. - **One file per tab** — elegido: coincide con la unidad semantica que un humano edita ("trabajar en la tab 5 Min View"), diffs acotados a una tab, tamaño manejable (~1000 lineas por tab en el peor caso). - **Mantener monolitico + jq/yq helpers** — descartado: no resuelve el problema del diff ni de la navegacion cognitiva. ## Arquitectura ``` apps/auto_metabase/ ├── sync_pull.py # MOD: pull_dashboard -> split en directorio ├── sync_push.py # MOD: load_dashboard -> compose desde directorio ├── sync_validate.py # MOD: resolver path a dashboard (file o directory) ├── payload.py # MOD si hace falta (builders actuales) ├── app.md # MOD: documentar nuevo layout └── projects/{name}/ └── dashboards/ ├── {slug}.yaml # LEGACY: sigue soportado (backward-compat) └── {slug}/ # NEW: nueva estructura directory-based ├── _dashboard.yaml # NEW: _meta + _refs + name + description + parameters + tabs (solo names/ids) + dashcards_globales (sin tab_id) ├── tab_5_min_view.yaml # NEW: dashcards con dashboard_tab_id=191 ├── tab_foto_centros_compara_semana.yaml # NEW └── ... ``` ### Pure core / impure shell - **Pure (testable sin Metabase):** - `split_dashboard_payload(payload: dict) -> dict[str, dict]` — toma el dict de un dashboard tal y como lo escribe el pull actual y devuelve `{"_dashboard": {...}, "tab_xxx": {...}, ...}`. Input → output puro. En `sync_pull.py` o en un nuevo modulo `dashboard_split.py`. - `merge_dashboard_payload(parts: dict[str, dict]) -> dict` — inverso: toma el dict de partes y reconstruye el payload unico para push. Tambien puro. - `tab_slug(tab_name: str) -> str` — slugify, reusar la que ya existe en `sync_pull._slugify()`. - **Impure (I/O):** - `pull_dashboard()` — escribe directorio en disco tras el split. - `load_dashboard_from_disk(dir_path)` — lee todos los `tab_*.yaml` + `_dashboard.yaml` y pasa a `merge_dashboard_payload()`. ### Formato de `_dashboard.yaml` (directory-based) ```yaml _meta: kind: dashboard id: 734 slug: bi_ventas_portfolio_producto_en_construccion synced_at: '2026-04-13T16:04:34Z' remote_updated_at: '2026-04-09T15:11:20Z' dashcards_count: 90 tabs_count: 11 parameters_count: 26 split_version: 1 # NEW: marcador de formato splitteado _refs: collection: repositorio_central payload: archived: false enable_embedding: false name: "🚧BI - VENTAS - PORTFOLIO PRODUCTO 🚧 En construccion" description: ... width: full parameters: [...] # 26 parametros tabs: # lista con nombre + id (para mapear a files) - id: 191 name: 5 Min View file: tab_5_min_view.yaml - id: 192 name: Foto Centros - Compara Semana file: tab_foto_centros_compara_semana.yaml - ... dashcards_globales: [] # dashcards SIN dashboard_tab_id (headings root-level) ``` ### Formato de `tab_{slug}.yaml` ```yaml _meta: kind: dashboard_tab parent_slug: bi_ventas_portfolio_producto_en_construccion tab_id: 191 tab_name: 5 Min View payload: dashcards: - size_x: 6 size_y: 2 col: 0 row: 0 card: ventas_totales_2 # slug resuelto (como ya lo hace R15) parameter_mappings: [...] visualization_settings: {} series: [] - ... ``` ## Tareas ### Fase 1: Helpers puros - [ ] **1.1** Crear `dashboard_split.py` (modulo nuevo) con `split_dashboard_payload()` y `merge_dashboard_payload()`. Input/output puros, sin I/O. - [ ] **1.2** Implementar `tab_file_slug(tab_name: str) -> str` reusando `_slugify()` de `sync_pull.py`. Prefijo `tab_` obligatorio para reconocer archivos. - [ ] **1.3** Tests unitarios en `dashboard_split_test.py`: round-trip `split → merge == original`, caso con tabs, sin tabs, con dashcards globales, con parameter_mappings, con series. ### Fase 2: Pull (escribe directorio) - [ ] **2.1** En `sync_pull.py::pull_dashboard()`, tras construir `body`, llamar `split_dashboard_payload(body)` y escribir: - `dashboards/{slug}/_dashboard.yaml` - `dashboards/{slug}/tab_{tab_slug}.yaml` por cada tab - [ ] **2.2** Si existe el legacy `dashboards/{slug}.yaml` en disco, dejarlo sin tocar pero preferir siempre el directory-based al leer (warning de migracion). - [ ] **2.3** Log de pull imprime los archivos escritos (cantidad de tabs + globales). ### Fase 3: Push (lee directorio y compone) - [ ] **3.1** En `sync_push.py`, resolver path del item dashboard: si existe `dashboards/{slug}/_dashboard.yaml` usar directory-based, sino fallback a `dashboards/{slug}.yaml`. - [ ] **3.2** Cargar todos los `tab_*.yaml` del directorio. Para cada uno: - Verificar `_meta.parent_slug == slug` - Leer `payload.dashcards`, inyectar `dashboard_tab_id` desde el mapeo en `_dashboard.yaml::payload.tabs[*].id` - [ ] **3.3** Llamar `merge_dashboard_payload()` con `_dashboard.yaml` + todos los `tab_*.yaml` → payload unificado igual al del pull monolitico. - [ ] **3.4** Todo lo demas (freshness R17, count R18, R6 backup, `--patch` diff) sigue funcionando sin cambios. ### Fase 4: Validate + diff - [ ] **4.1** `sync_validate.py` y `cmd_diff` resuelven el mismo path unificado via un helper `resolve_dashboard_path(project, slug) -> tuple[str, Path]` (`"legacy"|"split"`, path). - [ ] **4.2** Si es `split`, validar: - `_dashboard.yaml` existe y tiene `_meta.kind == 'dashboard'` - Cada `tab_*.yaml` referenciado en `tabs[*].file` existe en el directorio - Cada archivo de tab tiene `_meta.parent_slug == slug` y `_meta.tab_id` coincide con `_dashboard.yaml` - No hay archivos `tab_*.yaml` huerfanos (sin entrada en `tabs`) ### Fase 5: Backward-compat + migracion - [ ] **5.1** Flag opcional en `pull`: `--split` (default true en configs nuevos). Variable de config `split_dashboards: true` en `config.yaml` del proyecto. Si `false`, sigue escribiendo monolitico (solo para casos legacy). - [ ] **5.2** Script `scripts/migrate_dashboards_to_split.py`: itera `dashboards/*.yaml` de un proyecto y los convierte a directory-based (usando `split_dashboard_payload()` sobre el YAML cargado — no toca Metabase). - [ ] **5.3** Probar migracion sobre aurgi: `dashboards/bi_ventas_portfolio_producto_en_construccion.yaml` → directorio. ### Fase 6: Tests end-to-end + docs - [ ] **6.1** Test e2e con dashboard 734 (aurgi): pull (nueva estructura) → commit → editar una celda en `tab_5_min_view.yaml` (p.ej. renombrar description de una dashcard) → `push --patch --apply` → re-pull → diff debe mostrar SOLO la edicion deliberada. - [ ] **6.2** Actualizar `app.md` seccion "Crear cards y dashboards desde cero" con el nuevo layout de directorio. - [ ] **6.3** Añadir troubleshooting row a la tabla del readme: "archivo tab_xxx.yaml huerfano" / "tab en `_dashboard.yaml::tabs` sin archivo correspondiente". ## Ejemplo de uso ```bash # Pull bajo nuevo formato ./main.py -p aurgi pull dashboard bi_ventas_portfolio_producto_en_construccion # [aurgi] pull dashboard bi_ventas_portfolio_producto_en_construccion (id=734) -> # projects/aurgi/dashboards/bi_ventas_portfolio_producto_en_construccion/ # _dashboard.yaml (26 parametros, 11 tabs) # tab_5_min_view.yaml (7 dashcards) # tab_foto_centros_compara_semana.yaml (7 dashcards) # tab_foto_categorias_compara_semana.yaml (9 dashcards) # ... (8 mas) # Edicion quirurgica: abrir solo la tab $EDITOR projects/aurgi/dashboards/bi_ventas_portfolio_producto_en_construccion/tab_5_min_view.yaml # Diff acotado git diff --stat # projects/aurgi/dashboards/bi_ventas_portfolio_producto_en_construccion/tab_5_min_view.yaml | 12 ++++++------ # Push atomic, solo lo que cambio ./main.py -p aurgi push dashboard bi_ventas_portfolio_producto_en_construccion --patch --apply # (patch) enviando 1 keys: ['dashcards'] # aplicado. ``` ## Decisiones de diseno - **Prefijo `tab_` obligatorio** en archivos de tab: permite que `ls dashboards/{slug}/tab_*.yaml` liste exactamente los tabs, separandolos de `_dashboard.yaml`. Sin prefijo seria ambiguo. - **`_dashboard.yaml` con guion bajo**: se ordena primero alfabeticamente al hacer `ls`, lo cual ayuda a la navegacion. - **`_meta.split_version: 1`** en `_dashboard.yaml`: abre la puerta a evoluciones del formato sin romper dashboards antiguos. Pull puede detectar versiones antiguas y migrar al vuelo. - **Mapping `tabs[*].file`** en `_dashboard.yaml` en vez de deducirlo del nombre: protege contra renames manuales y permite nombres de tab con caracteres raros. - **Dashcards globales en `_dashboard.yaml::payload.dashcards_globales`**: algunos dashboards (sobre todo legacy) tienen dashcards sin `dashboard_tab_id`. Separarlos del array de tabs evita perderlos. - **NO cambiar el schema del API de Metabase**: merge reconstruye el mismo JSON que el pull monolitico actual. Byte-a-byte equivalente. - **Legacy YAML monolitico sigue soportado**: un proyecto viejo con `dashboards/x.yaml` sigue pushable. La migracion es opcional / script-assisted. ## Prerequisitos - Commit actual de `sync_pull.py` que preserva `id` en tabs (ya aplicado en esta sesion). - `state/index.json` con mapeos de slug→id intactos. ## Riesgos | Riesgo | Impacto | Mitigacion | |---|---|---| | Dashboard con 2 tabs del mismo nombre | Colision de archivo | Slugify + sufijo `_2`, `_3` como en `_slug_for()`; registrar el file mapping en `_dashboard.yaml::tabs[*].file`, no deducirlo | | Usuario edita `_dashboard.yaml::tabs` y borra una entrada pero no el archivo `tab_*.yaml` | Archivo huerfano queda en disco | Validate avisa "archivo tab_xxx.yaml huerfano" en fase 4.2 | | Orden de dashcards en YAML no determinista entre pulls | Diffs falsos en git | Ordenar dashcards por `(row, col)` antes de escribir — idempotente | | Migracion masiva rompe dashboards de otros proyectos | Push falla en `push-all` | Migracion opcional via script, no automatica. Legacy sigue funcionando | | Dashboard con 0 tabs (solo dashcards root) | Solo `_dashboard.yaml` sin tabs | Soportado via `dashcards_globales`; validate acepta `tabs: []` | | Rename de tab en Metabase entre pulls | `tab_slug` cambia, archivo anterior queda huerfano | Detectar por `tab_id` en remoto: si coincide pero nombre cambio, renombrar archivo local (no borrar) | ## Verificacion final - [ ] Pull de dashboard 734 (aurgi) genera el directorio con 11 tabs + `_dashboard.yaml` - [ ] `git diff` de una edicion en 1 tab solo muestra cambios en ese archivo - [ ] Push roundtrip: pull → push (sin cambios) → pull → diff vacio - [ ] Legacy monolitico (`dashboards/x.yaml` antiguo) sigue validable y pushable - [ ] `app.md` actualizado con el nuevo layout + ejemplo - [ ] Tests de `dashboard_split` pasan (round-trip + casos edge)