Files
fn_registry/dev/issues/completed/0024-dashboard-yaml-split-por-tab.md
T

241 lines
13 KiB
Markdown

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