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

13 KiB

id, title, status, type, domain, scope, priority, depends, blocks, related, created, updated, tags
id title status type domain scope priority depends blocks related created updated tags
0024 Split dashboard YAMLs por tab completado feature
multi-app alta
2026-05-17 2026-05-17

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)