---
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)
