fad4006f60
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
241 lines
13 KiB
Markdown
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)
|