f242441bb2
El split de dashboards YAML por tab ya esta implementado y committeado en
apps/auto_metabase (repo dataforge/auto_metabase, commit 47b5f89):
- dashboard_split.py: helpers puros split_dashboard_payload() y merge_dashboard_parts()
- dashboard_split_test.py: 23 tests passing (round-trip, edge cases, real aurgi YAML)
- sync_pull.py: escribe directorio {slug}/_dashboard.yaml + tab_*.yaml
- sync_push.py: lee directorio o legacy monolitico, reconstruye payload unificado
- sync_validate.py: valida parent_slug, tab_ids y detecta archivos huerfanos
- payload.py: resolve dashboards/{slug}/_dashboard.yaml preferentemente
- app.md: documenta nuevo layout con seccion "Dashboard split por tab"
- Backward-compat con legacy dashboards/{slug}.yaml
- Aplicado a aurgi (dashboard id=734) con 9 tabs separados
Las apps viven en repos Gitea independientes (dataforge/auto_metabase), por
lo que los cambios de codigo no quedan reflejados en el historial de
fn_registry, solo el cierre del issue.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
12 KiB
12 KiB
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()escribeprojects/{name}/dashboards/{slug}.yamlcon todo: metadata + parametros + tabs + 90+ dashcards en un solo archivo.- Aurgi (proyecto real) tiene un dashboard
bi_ventas_portfolio_producto_en_construccionque genera 11.282 lineas YAML. Para editar una dashcard en el tab "Foto Categorias" hay que encontrarla entre 90 entries viadashboard_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.
validatecorre 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. Ensync_pull.pyo en un nuevo modulodashboard_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 ensync_pull._slugify().
- Impure (I/O):
pull_dashboard()— escribe directorio en disco tras el split.load_dashboard_from_disk(dir_path)— lee todos lostab_*.yaml+_dashboard.yamly pasa amerge_dashboard_payload().
Formato de _dashboard.yaml (directory-based)
_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
_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) consplit_dashboard_payload()ymerge_dashboard_payload(). Input/output puros, sin I/O. - 1.2 Implementar
tab_file_slug(tab_name: str) -> strreusando_slugify()desync_pull.py. Prefijotab_obligatorio para reconocer archivos. - 1.3 Tests unitarios en
dashboard_split_test.py: round-tripsplit → 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 construirbody, llamarsplit_dashboard_payload(body)y escribir:dashboards/{slug}/_dashboard.yamldashboards/{slug}/tab_{tab_slug}.yamlpor cada tab
- 2.2 Si existe el legacy
dashboards/{slug}.yamlen 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 existedashboards/{slug}/_dashboard.yamlusar directory-based, sino fallback adashboards/{slug}.yaml. - 3.2 Cargar todos los
tab_*.yamldel directorio. Para cada uno:- Verificar
_meta.parent_slug == slug - Leer
payload.dashcards, inyectardashboard_tab_iddesde el mapeo en_dashboard.yaml::payload.tabs[*].id
- Verificar
- 3.3 Llamar
merge_dashboard_payload()con_dashboard.yaml+ todos lostab_*.yaml→ payload unificado igual al del pull monolitico. - 3.4 Todo lo demas (freshness R17, count R18, R6 backup,
--patchdiff) sigue funcionando sin cambios.
Fase 4: Validate + diff
- 4.1
sync_validate.pyycmd_diffresuelven el mismo path unificado via un helperresolve_dashboard_path(project, slug) -> tuple[str, Path]("legacy"|"split", path). - 4.2 Si es
split, validar:_dashboard.yamlexiste y tiene_meta.kind == 'dashboard'- Cada
tab_*.yamlreferenciado entabs[*].fileexiste en el directorio - Cada archivo de tab tiene
_meta.parent_slug == slugy_meta.tab_idcoincide con_dashboard.yaml - No hay archivos
tab_*.yamlhuerfanos (sin entrada entabs)
Fase 5: Backward-compat + migracion
- 5.1 Flag opcional en
pull:--split(default true en configs nuevos). Variable de configsplit_dashboards: trueenconfig.yamldel proyecto. Sifalse, sigue escribiendo monolitico (solo para casos legacy). - 5.2 Script
scripts/migrate_dashboards_to_split.py: iteradashboards/*.yamlde un proyecto y los convierte a directory-based (usandosplit_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.mdseccion "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::tabssin archivo correspondiente".
Ejemplo de uso
# 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 quels dashboards/{slug}/tab_*.yamlliste exactamente los tabs, separandolos de_dashboard.yaml. Sin prefijo seria ambiguo. _dashboard.yamlcon guion bajo: se ordena primero alfabeticamente al hacerls, lo cual ayuda a la navegacion._meta.split_version: 1en_dashboard.yaml: abre la puerta a evoluciones del formato sin romper dashboards antiguos. Pull puede detectar versiones antiguas y migrar al vuelo.- Mapping
tabs[*].fileen_dashboard.yamlen 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 sindashboard_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.yamlsigue pushable. La migracion es opcional / script-assisted.
Prerequisitos
- Commit actual de
sync_pull.pyque preservaiden tabs (ya aplicado en esta sesion). state/index.jsoncon 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 diffde una edicion en 1 tab solo muestra cambios en ese archivo- Push roundtrip: pull → push (sin cambios) → pull → diff vacio
- Legacy monolitico (
dashboards/x.yamlantiguo) sigue validable y pushable app.mdactualizado con el nuevo layout + ejemplo- Tests de
dashboard_splitpasan (round-trip + casos edge)