Files
fn_registry/dev/issues/completed/0024-dashboard-yaml-split-por-tab.md
T
egutierrez f242441bb2 docs: cerrar issue 0024
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>
2026-04-18 17:10:36 +02:00

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

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

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