fix(ucepsa): improve Shop Floor health dashboard presentation

This commit is contained in:
Victor Fraile Garcia 2026-07-20 12:19:41 +02:00
parent 2afc19105b
commit 10e5582b2c
3 changed files with 836 additions and 79 deletions

View File

@ -0,0 +1,120 @@
# UCEPSA — Presentación dashboard Shop Floor v0.3.6.2
## Objetivo
Corregir exclusivamente la presentación del dashboard:
```text
MESAVAULT — UCEPSA · Salud y contexto Shop Floor
```
No modifica PostgreSQL, Odoo, sesiones, paros, sincronizadores ni el Ledger.
## Correcciones
### Indicadores superiores
```text
Salud general
HEALTHY → verde
WARNING → naranja
CRITICAL → rojo
Sincronizador
HEALTHY → verde
DELAYED → naranja
STALE/ERROR → rojo
Advertencias
0 → verde
>= 1 → naranja
Críticas
0 → verde
>= 1 → rojo
```
Los paneles de texto se alimentan mediante códigos numéricos para evitar el
`No data` que Grafana mostraba al reducir valores de texto.
### Estado por máquina
Columnas visibles:
```text
Máquina
Marcha
Telemetría
Orden
Workorder
Operario
Estado
Severidad
```
Se eliminan de esta tabla `Sync` y `Acción`, ya disponibles en otros paneles.
Los estados técnicos se traducen a lenguaje operativo:
```text
RUNNING_WITHOUT_SHOPFLOOR_SESSION
→ En marcha sin sesión Shop Floor
TEMPORARY_STOP_INSIDE_SESSION
→ Parada dentro de sesión
IDLE_NO_ACTIVE_CONTEXT
→ Inactiva sin contexto
```
### Incidencias activas
Columnas:
```text
Hora
Severidad
Máquina
Situación
Orden
Operario
Acción
```
Los códigos técnicos quedan traducidos a frases operativas.
## Despliegue
```bash
read -rsp "Token temporal de Grafana: " GRAFANA_API_TOKEN
echo
export GRAFANA_API_TOKEN
SCRIPT=/srv/mesavault/40-clients/ucepsa/edge-oee-demo/ops/deploy_shopfloor_context_health_dashboard_v0362.sh
"$SCRIPT" deploy
"$SCRIPT" validate
unset GRAFANA_API_TOKEN
```
El despliegue reutiliza el mismo UID:
```text
ucepsa-shopfloor-context-health
```
Por tanto, actualiza el dashboard existente sin crear duplicados.
## Validación esperada
```text
status = ok
panel_count = 10
datasource_uids = [bfnbcasbm6hhca]
numeric_health_stat = true
numeric_sync_stat = true
compact_machine_table = true
friendly_incident_text = true
errors = []
```

View File

@ -15,7 +15,7 @@
} }
] ]
}, },
"description": "Supervisión SHADOW de máquina física, orden Odoo, sesión Shop Floor, sincronizador y calidad temporal. No alimenta el Ledger oficial.", "description": "Supervisión SHADOW de máquina física, orden Odoo, sesión Shop Floor, sincronizador y calidad temporal. Presentación operativa v0.3.6.2. No alimenta el Ledger oficial.",
"editable": true, "editable": true,
"fiscalYearStartMonth": 0, "fiscalYearStartMonth": 0,
"graphTooltip": 1, "graphTooltip": 1,
@ -35,24 +35,29 @@
}, },
"mappings": [ "mappings": [
{ {
"type": "value",
"options": { "options": {
"HEALTHY": { "0": {
"text": "HEALTHY",
"color": "green", "color": "green",
"index": 0, "index": 0
"text": "HEALTHY"
}, },
"WARNING": { "1": {
"text": "WARNING",
"color": "orange", "color": "orange",
"index": 1, "index": 1
"text": "WARNING"
}, },
"CRITICAL": { "2": {
"text": "CRITICAL",
"color": "red", "color": "red",
"index": 2, "index": 2
"text": "CRITICAL" },
"3": {
"text": "UNKNOWN",
"color": "red",
"index": 3
} }
}, }
"type": "value"
} }
], ],
"thresholds": { "thresholds": {
@ -61,6 +66,14 @@
{ {
"color": "green", "color": "green",
"value": null "value": null
},
{
"color": "orange",
"value": 1
},
{
"color": "red",
"value": 2
} }
] ]
}, },
@ -89,7 +102,7 @@
"values": false "values": false
}, },
"showPercentChange": false, "showPercentChange": false,
"textMode": "auto", "textMode": "value",
"wideLayout": true "wideLayout": true
}, },
"pluginVersion": "11.0.0", "pluginVersion": "11.0.0",
@ -102,7 +115,7 @@
"editorMode": "code", "editorMode": "code",
"format": "table", "format": "table",
"rawQuery": true, "rawQuery": true,
"rawSql": "SELECT overall_health_status AS \"Estado\"\n FROM mv_reports_ucepsa_prod.li_shopfloor_context_health_summary_v1", "rawSql": "SELECT\n CASE overall_health_status\n WHEN 'HEALTHY' THEN 0\n WHEN 'WARNING' THEN 1\n WHEN 'CRITICAL' THEN 2\n ELSE 3\n END AS \"Estado\"\nFROM\n mv_reports_ucepsa_prod\n .li_shopfloor_context_health_summary_v1",
"refId": "A" "refId": "A"
} }
], ],
@ -121,34 +134,34 @@
}, },
"mappings": [ "mappings": [
{ {
"type": "value",
"options": { "options": {
"HEALTHY": { "0": {
"text": "HEALTHY",
"color": "green", "color": "green",
"index": 0, "index": 0
"text": "HEALTHY"
}, },
"DELAYED": { "1": {
"text": "DELAYED",
"color": "orange", "color": "orange",
"index": 1, "index": 1
"text": "DELAYED"
}, },
"STALE": { "2": {
"text": "STALE",
"color": "red", "color": "red",
"index": 2, "index": 2
"text": "STALE"
}, },
"ERROR": { "3": {
"text": "ERROR",
"color": "red", "color": "red",
"index": 3, "index": 3
"text": "ERROR"
}, },
"NOT_INITIALIZED": { "4": {
"text": "NOT INITIALIZED",
"color": "red", "color": "red",
"index": 4, "index": 4
"text": "NOT INITIALIZED"
} }
}, }
"type": "value"
} }
], ],
"thresholds": { "thresholds": {
@ -157,6 +170,14 @@
{ {
"color": "green", "color": "green",
"value": null "value": null
},
{
"color": "orange",
"value": 1
},
{
"color": "red",
"value": 2
} }
] ]
}, },
@ -185,7 +206,7 @@
"values": false "values": false
}, },
"showPercentChange": false, "showPercentChange": false,
"textMode": "auto", "textMode": "value",
"wideLayout": true "wideLayout": true
}, },
"pluginVersion": "11.0.0", "pluginVersion": "11.0.0",
@ -198,7 +219,7 @@
"editorMode": "code", "editorMode": "code",
"format": "table", "format": "table",
"rawQuery": true, "rawQuery": true,
"rawSql": "SELECT sync_health_status AS \"Estado\"\n FROM mv_reports_ucepsa_prod.li_shopfloor_context_health_summary_v1", "rawSql": "SELECT\n CASE sync_health_status\n WHEN 'HEALTHY' THEN 0\n WHEN 'DELAYED' THEN 1\n WHEN 'STALE' THEN 2\n WHEN 'ERROR' THEN 3\n WHEN 'NOT_INITIALIZED' THEN 4\n ELSE 4\n END AS \"Estado\"\nFROM\n mv_reports_ucepsa_prod\n .li_shopfloor_context_health_summary_v1",
"refId": "A" "refId": "A"
} }
], ],
@ -222,6 +243,14 @@
{ {
"color": "green", "color": "green",
"value": null "value": null
},
{
"color": "orange",
"value": 45
},
{
"color": "red",
"value": 120
} }
] ]
}, },
@ -250,7 +279,7 @@
"values": false "values": false
}, },
"showPercentChange": false, "showPercentChange": false,
"textMode": "auto", "textMode": "value",
"wideLayout": true "wideLayout": true
}, },
"pluginVersion": "11.0.0", "pluginVersion": "11.0.0",
@ -263,7 +292,7 @@
"editorMode": "code", "editorMode": "code",
"format": "table", "format": "table",
"rawQuery": true, "rawQuery": true,
"rawSql": "SELECT seconds_since_sync_success AS \"Segundos\"\n FROM mv_reports_ucepsa_prod.li_shopfloor_context_health_summary_v1", "rawSql": "SELECT\n seconds_since_sync_success AS \"Segundos\"\nFROM\n mv_reports_ucepsa_prod\n .li_shopfloor_context_health_summary_v1",
"refId": "A" "refId": "A"
} }
], ],
@ -315,7 +344,7 @@
"values": false "values": false
}, },
"showPercentChange": false, "showPercentChange": false,
"textMode": "auto", "textMode": "value",
"wideLayout": true "wideLayout": true
}, },
"pluginVersion": "11.0.0", "pluginVersion": "11.0.0",
@ -328,7 +357,7 @@
"editorMode": "code", "editorMode": "code",
"format": "table", "format": "table",
"rawQuery": true, "rawQuery": true,
"rawSql": "SELECT open_session_count AS \"Sesiones\"\n FROM mv_reports_ucepsa_prod.li_shopfloor_context_health_summary_v1", "rawSql": "SELECT\n open_session_count AS \"Sesiones\"\nFROM\n mv_reports_ucepsa_prod\n .li_shopfloor_context_health_summary_v1",
"refId": "A" "refId": "A"
} }
], ],
@ -352,6 +381,10 @@
{ {
"color": "green", "color": "green",
"value": null "value": null
},
{
"color": "orange",
"value": 1
} }
] ]
}, },
@ -380,7 +413,7 @@
"values": false "values": false
}, },
"showPercentChange": false, "showPercentChange": false,
"textMode": "auto", "textMode": "value",
"wideLayout": true "wideLayout": true
}, },
"pluginVersion": "11.0.0", "pluginVersion": "11.0.0",
@ -393,7 +426,7 @@
"editorMode": "code", "editorMode": "code",
"format": "table", "format": "table",
"rawQuery": true, "rawQuery": true,
"rawSql": "SELECT warning_incident_count AS \"Incidencias\"\n FROM mv_reports_ucepsa_prod.li_shopfloor_context_health_summary_v1", "rawSql": "SELECT\n warning_incident_count AS \"Advertencias\"\nFROM\n mv_reports_ucepsa_prod\n .li_shopfloor_context_health_summary_v1",
"refId": "A" "refId": "A"
} }
], ],
@ -417,6 +450,10 @@
{ {
"color": "green", "color": "green",
"value": null "value": null
},
{
"color": "red",
"value": 1
} }
] ]
}, },
@ -445,7 +482,7 @@
"values": false "values": false
}, },
"showPercentChange": false, "showPercentChange": false,
"textMode": "auto", "textMode": "value",
"wideLayout": true "wideLayout": true
}, },
"pluginVersion": "11.0.0", "pluginVersion": "11.0.0",
@ -458,7 +495,7 @@
"editorMode": "code", "editorMode": "code",
"format": "table", "format": "table",
"rawQuery": true, "rawQuery": true,
"rawSql": "SELECT critical_incident_count AS \"Incidencias\"\n FROM mv_reports_ucepsa_prod.li_shopfloor_context_health_summary_v1", "rawSql": "SELECT\n critical_incident_count AS \"Críticas\"\nFROM\n mv_reports_ucepsa_prod\n .li_shopfloor_context_health_summary_v1",
"refId": "A" "refId": "A"
} }
], ],
@ -498,24 +535,60 @@
{ {
"matcher": { "matcher": {
"id": "byName", "id": "byName",
"options": "Estado" "options": "Máquina"
}, },
"properties": [ "properties": [
{ {
"id": "custom.width", "id": "custom.width",
"value": 260 "value": 95
} }
] ]
}, },
{ {
"matcher": { "matcher": {
"id": "byName", "id": "byName",
"options": "Acción" "options": "Marcha"
}, },
"properties": [ "properties": [
{ {
"id": "custom.width", "id": "custom.width",
"value": 420 "value": 75
}
]
},
{
"matcher": {
"id": "byName",
"options": "Telemetría"
},
"properties": [
{
"id": "custom.width",
"value": 95
}
]
},
{
"matcher": {
"id": "byName",
"options": "Orden"
},
"properties": [
{
"id": "custom.width",
"value": 145
}
]
},
{
"matcher": {
"id": "byName",
"options": "Workorder"
},
"properties": [
{
"id": "custom.width",
"value": 95
} }
] ]
}, },
@ -527,7 +600,19 @@
"properties": [ "properties": [
{ {
"id": "custom.width", "id": "custom.width",
"value": 220 "value": 235
}
]
},
{
"matcher": {
"id": "byName",
"options": "Estado"
},
"properties": [
{
"id": "custom.width",
"value": 300
} }
] ]
}, },
@ -541,29 +626,29 @@
"id": "mappings", "id": "mappings",
"value": [ "value": [
{ {
"type": "value",
"options": { "options": {
"OK": { "OK": {
"text": "OK",
"color": "green", "color": "green",
"index": 0, "index": 0
"text": "OK"
}, },
"INFO": { "INFO": {
"text": "INFO",
"color": "blue", "color": "blue",
"index": 1, "index": 1
"text": "INFO"
}, },
"WARNING": { "WARNING": {
"text": "WARNING",
"color": "orange", "color": "orange",
"index": 2, "index": 2
"text": "WARNING"
}, },
"CRITICAL": { "CRITICAL": {
"text": "CRITICAL",
"color": "red", "color": "red",
"index": 3, "index": 3
"text": "CRITICAL"
} }
}, }
"type": "value"
} }
] ]
}, },
@ -616,7 +701,7 @@
"editorMode": "code", "editorMode": "code",
"format": "table", "format": "table",
"rawQuery": true, "rawQuery": true,
"rawSql": "SELECT\n machine_id AS \"Máquina\",\n machine_running AS \"Marcha\",\n telemetry_fresh AS \"Telemetría fresca\",\n COALESCE(published_production_order, '—') AS \"Orden publicada\",\n COALESCE(published_workorder_id, '—') AS \"WO publicado\",\n COALESCE(odoo_workorder_id::text, '—') AS \"WO sesión\",\n COALESCE(operator_name, operator_candidate_name, '—') AS \"Operario\",\n sync_health_status AS \"Sync\",\n live_context_status AS \"Estado\",\n live_severity AS \"Severidad\",\n recommended_action AS \"Acción\"\nFROM mv_reports_ucepsa_prod.li_shopfloor_machine_live_context_v1\nORDER BY machine_id", "rawSql": "SELECT\n machine_id AS \"Máquina\",\n CASE\n WHEN machine_running THEN 'Sí'\n ELSE 'No'\n END AS \"Marcha\",\n CASE\n WHEN telemetry_fresh THEN 'Sí'\n ELSE 'No'\n END AS \"Telemetría\",\n COALESCE(\n published_production_order,\n '—'\n ) AS \"Orden\",\n COALESCE(\n odoo_workorder_id::text,\n NULLIF(published_workorder_id, ''),\n '—'\n ) AS \"Workorder\",\n COALESCE(\n operator_name,\n operator_candidate_name,\n '—'\n ) AS \"Operario\",\n CASE live_context_status\n WHEN 'HEALTHY_OPEN_SESSION'\n THEN 'Produciendo con sesión Shop Floor'\n WHEN 'TEMPORARY_STOP_INSIDE_SESSION'\n THEN 'Parada dentro de sesión'\n WHEN 'RUNNING_WITHOUT_SHOPFLOOR_SESSION'\n THEN 'En marcha sin sesión Shop Floor'\n WHEN 'RUNNING_WITH_ORDER_NO_SHOPFLOOR_SESSION'\n THEN 'En marcha con orden, sin sesión'\n WHEN 'ORDER_WITHOUT_SHOPFLOOR_SESSION'\n THEN 'Orden preparada, sesión no iniciada'\n WHEN 'RUNNING_SESSION_WITHOUT_PUBLISHED_ORDER'\n THEN 'Sesión activa sin orden publicada'\n WHEN 'OPEN_SESSION_WITHOUT_PUBLISHED_ORDER'\n THEN 'Sesión abierta sin orden publicada'\n WHEN 'OPEN_SESSION_ORDER_MISMATCH'\n THEN 'Orden y sesión no coinciden'\n WHEN 'MULTIPLE_OPEN_SESSIONS'\n THEN 'Varias sesiones abiertas'\n WHEN 'OPEN_SESSION_DATA_QUALITY_ERROR'\n THEN 'Error de calidad en sesión'\n WHEN 'IDLE_NO_ACTIVE_CONTEXT'\n THEN 'Inactiva sin contexto'\n ELSE live_context_status\n END AS \"Estado\",\n CASE live_severity\n WHEN 'OK' THEN 'OK'\n WHEN 'INFO' THEN 'INFO'\n WHEN 'WARNING' THEN 'WARNING'\n WHEN 'CRITICAL' THEN 'CRITICAL'\n ELSE live_severity\n END AS \"Severidad\"\nFROM\n mv_reports_ucepsa_prod\n .li_shopfloor_machine_live_context_v1\nORDER BY machine_id",
"refId": "A" "refId": "A"
} }
], ],
@ -650,6 +735,18 @@
} }
}, },
"overrides": [ "overrides": [
{
"matcher": {
"id": "byName",
"options": "Hora"
},
"properties": [
{
"id": "custom.width",
"value": 175
}
]
},
{ {
"matcher": { "matcher": {
"id": "byName", "id": "byName",
@ -660,19 +757,19 @@
"id": "mappings", "id": "mappings",
"value": [ "value": [
{ {
"type": "value",
"options": { "options": {
"WARNING": { "WARNING": {
"text": "WARNING",
"color": "orange", "color": "orange",
"index": 0, "index": 0
"text": "WARNING"
}, },
"CRITICAL": { "CRITICAL": {
"text": "CRITICAL",
"color": "red", "color": "red",
"index": 1, "index": 1
"text": "CRITICAL"
} }
}, }
"type": "value"
} }
] ]
}, },
@ -688,6 +785,18 @@
} }
] ]
}, },
{
"matcher": {
"id": "byName",
"options": "Máquina"
},
"properties": [
{
"id": "custom.width",
"value": 95
}
]
},
{ {
"matcher": { "matcher": {
"id": "byName", "id": "byName",
@ -700,6 +809,30 @@
} }
] ]
}, },
{
"matcher": {
"id": "byName",
"options": "Orden"
},
"properties": [
{
"id": "custom.width",
"value": 145
}
]
},
{
"matcher": {
"id": "byName",
"options": "Operario"
},
"properties": [
{
"id": "custom.width",
"value": 220
}
]
},
{ {
"matcher": { "matcher": {
"id": "byName", "id": "byName",
@ -708,7 +841,7 @@
"properties": [ "properties": [
{ {
"id": "custom.width", "id": "custom.width",
"value": 460 "value": 520
} }
] ]
} }
@ -744,7 +877,7 @@
"editorMode": "code", "editorMode": "code",
"format": "table", "format": "table",
"rawQuery": true, "rawQuery": true,
"rawSql": "SELECT\n observed_at AS \"Hora\",\n severity AS \"Severidad\",\n COALESCE(machine_id, '—') AS \"Máquina\",\n incident_scope AS \"Ámbito\",\n incident_code AS \"Código\",\n COALESCE(production_order, '—') AS \"Orden\",\n COALESCE(odoo_workorder_id::text, '—') AS \"Workorder\",\n COALESCE(operator_name, '—') AS \"Operario\",\n detail AS \"Situación\",\n recommended_action AS \"Acción\"\nFROM mv_reports_ucepsa_prod.li_shopfloor_context_incidents_v1\nORDER BY\n CASE severity\n WHEN 'CRITICAL' THEN 1\n WHEN 'WARNING' THEN 2\n ELSE 3\n END,\n machine_id NULLS FIRST,\n incident_code", "rawSql": "SELECT\n observed_at AS \"Hora\",\n severity AS \"Severidad\",\n COALESCE(machine_id, '—') AS \"Máquina\",\n CASE incident_code\n WHEN 'RUNNING_WITHOUT_SHOPFLOOR_SESSION'\n THEN 'Máquina en marcha sin orden ni sesión Shop Floor'\n WHEN 'RUNNING_WITH_ORDER_NO_SHOPFLOOR_SESSION'\n THEN 'Máquina en marcha con orden, pero sin sesión Shop Floor'\n WHEN 'ORDER_WITHOUT_SHOPFLOOR_SESSION'\n THEN 'Orden publicada sin sesión Shop Floor'\n WHEN 'RUNNING_SESSION_WITHOUT_PUBLISHED_ORDER'\n THEN 'Sesión abierta y máquina en marcha, pero sin orden publicada'\n WHEN 'OPEN_SESSION_WITHOUT_PUBLISHED_ORDER'\n THEN 'Sesión abierta sin orden publicada'\n WHEN 'OPEN_SESSION_ORDER_MISMATCH'\n THEN 'La orden publicada no coincide con la sesión abierta'\n WHEN 'MULTIPLE_OPEN_SESSIONS'\n THEN 'Existen varias sesiones abiertas en la misma máquina'\n WHEN 'OPEN_SESSION_DATA_QUALITY_ERROR'\n THEN 'La sesión abierta presenta un error de calidad'\n WHEN 'SHOPFLOOR_SYNC_DELAYED'\n THEN 'La sincronización Shop Floor está retrasada'\n WHEN 'SHOPFLOOR_SYNC_STALE'\n THEN 'La sincronización Shop Floor está obsoleta'\n WHEN 'SHOPFLOOR_SYNC_ERROR'\n THEN 'El sincronizador Shop Floor ha registrado un error'\n WHEN 'SHOPFLOOR_SYNC_NOT_INITIALIZED'\n THEN 'El sincronizador Shop Floor no está inicializado'\n ELSE incident_code\n END AS \"Situación\",\n COALESCE(production_order, '—') AS \"Orden\",\n COALESCE(operator_name, '—') AS \"Operario\",\n recommended_action AS \"Acción\"\nFROM\n mv_reports_ucepsa_prod\n .li_shopfloor_context_incidents_v1\nORDER BY\n CASE severity\n WHEN 'CRITICAL' THEN 1\n WHEN 'WARNING' THEN 2\n ELSE 3\n END,\n machine_id NULLS FIRST,\n incident_code",
"refId": "A" "refId": "A"
} }
], ],
@ -778,6 +911,54 @@
} }
}, },
"overrides": [ "overrides": [
{
"matcher": {
"id": "byName",
"options": "Máquina"
},
"properties": [
{
"id": "custom.width",
"value": 95
}
]
},
{
"matcher": {
"id": "byName",
"options": "Orden"
},
"properties": [
{
"id": "custom.width",
"value": 145
}
]
},
{
"matcher": {
"id": "byName",
"options": "Workorder"
},
"properties": [
{
"id": "custom.width",
"value": 95
}
]
},
{
"matcher": {
"id": "byName",
"options": "Operario"
},
"properties": [
{
"id": "custom.width",
"value": 235
}
]
},
{ {
"matcher": { "matcher": {
"id": "byName", "id": "byName",
@ -787,6 +968,10 @@
{ {
"id": "unit", "id": "unit",
"value": "m" "value": "m"
},
{
"id": "custom.width",
"value": 125
} }
] ]
}, },
@ -799,18 +984,70 @@
{ {
"id": "unit", "id": "unit",
"value": "m" "value": "m"
},
{
"id": "custom.width",
"value": 125
} }
] ]
}, },
{ {
"matcher": { "matcher": {
"id": "byName", "id": "byName",
"options": "Acción" "options": "Estado"
}, },
"properties": [ "properties": [
{ {
"id": "custom.width", "id": "custom.width",
"value": 420 "value": 300
}
]
},
{
"matcher": {
"id": "byName",
"options": "Severidad"
},
"properties": [
{
"id": "mappings",
"value": [
{
"type": "value",
"options": {
"OK": {
"text": "OK",
"color": "green",
"index": 0
},
"INFO": {
"text": "INFO",
"color": "blue",
"index": 1
},
"WARNING": {
"text": "WARNING",
"color": "orange",
"index": 2
},
"CRITICAL": {
"text": "CRITICAL",
"color": "red",
"index": 3
}
}
}
]
},
{
"id": "custom.cellOptions",
"value": {
"type": "color-text"
}
},
{
"id": "custom.width",
"value": 110
} }
] ]
} }
@ -846,7 +1083,7 @@
"editorMode": "code", "editorMode": "code",
"format": "table", "format": "table",
"rawQuery": true, "rawQuery": true,
"rawSql": "SELECT\n machine_id AS \"Máquina\",\n production_order AS \"Orden\",\n odoo_workorder_id AS \"Workorder\",\n COALESCE(operator_name, operator_candidate_name, '—') AS \"Operario\",\n session_started_at AS \"Inicio\",\n session_age_min AS \"Minutos abierta\",\n machine_running AS \"Marcha\",\n telemetry_fresh AS \"Telemetría fresca\",\n open_stop_duration_min AS \"Paro abierto min\",\n watch_status AS \"Estado\",\n watch_severity AS \"Severidad\",\n recommended_action AS \"Acción\"\nFROM mv_reports_ucepsa_prod.li_shopfloor_open_session_watch_v1\nORDER BY machine_id, session_started_at", "rawSql": "SELECT\n machine_id AS \"Máquina\",\n production_order AS \"Orden\",\n odoo_workorder_id AS \"Workorder\",\n COALESCE(\n operator_name,\n operator_candidate_name,\n '—'\n ) AS \"Operario\",\n session_started_at AS \"Inicio\",\n session_age_min AS \"Minutos abierta\",\n CASE\n WHEN machine_running THEN 'Sí'\n ELSE 'No'\n END AS \"Marcha\",\n COALESCE(\n open_stop_duration_min,\n 0\n ) AS \"Paro abierto min\",\n CASE watch_status\n WHEN 'HEALTHY_OPEN_SESSION'\n THEN 'Produciendo con sesión Shop Floor'\n WHEN 'TEMPORARY_STOP_INSIDE_SESSION'\n THEN 'Parada dentro de sesión'\n WHEN 'RUNNING_SESSION_WITHOUT_PUBLISHED_ORDER'\n THEN 'Sesión activa sin orden publicada'\n WHEN 'OPEN_SESSION_WITHOUT_PUBLISHED_ORDER'\n THEN 'Sesión abierta sin orden publicada'\n WHEN 'OPEN_SESSION_ORDER_MISMATCH'\n THEN 'Orden y sesión no coinciden'\n WHEN 'MULTIPLE_OPEN_SESSIONS'\n THEN 'Varias sesiones abiertas'\n WHEN 'OPEN_SESSION_DATA_QUALITY_ERROR'\n THEN 'Error de calidad en sesión'\n ELSE watch_status\n END AS \"Estado\",\n watch_severity AS \"Severidad\"\nFROM\n mv_reports_ucepsa_prod\n .li_shopfloor_open_session_watch_v1\nORDER BY machine_id, session_started_at",
"refId": "A" "refId": "A"
} }
], ],
@ -883,31 +1120,79 @@
{ {
"matcher": { "matcher": {
"id": "byName", "id": "byName",
"options": "DQ" "options": "Máquina"
},
"properties": [
{
"id": "custom.width",
"value": 95
}
]
},
{
"matcher": {
"id": "byName",
"options": "Orden"
},
"properties": [
{
"id": "custom.width",
"value": 145
}
]
},
{
"matcher": {
"id": "byName",
"options": "Workorder"
},
"properties": [
{
"id": "custom.width",
"value": 95
}
]
},
{
"matcher": {
"id": "byName",
"options": "Operario"
},
"properties": [
{
"id": "custom.width",
"value": 235
}
]
},
{
"matcher": {
"id": "byName",
"options": "Calidad"
}, },
"properties": [ "properties": [
{ {
"id": "mappings", "id": "mappings",
"value": [ "value": [
{ {
"type": "value",
"options": { "options": {
"OK": { "OK": {
"text": "OK",
"color": "green", "color": "green",
"index": 0, "index": 0
"text": "OK"
}, },
"WARNING": { "WARNING": {
"text": "WARNING",
"color": "orange", "color": "orange",
"index": 1, "index": 1
"text": "WARNING"
}, },
"CRITICAL": { "CRITICAL": {
"text": "CRITICAL",
"color": "red", "color": "red",
"index": 2, "index": 2
"text": "CRITICAL"
} }
}, }
"type": "value"
} }
] ]
}, },
@ -916,6 +1201,22 @@
"value": { "value": {
"type": "color-text" "type": "color-text"
} }
},
{
"id": "custom.width",
"value": 100
}
]
},
{
"matcher": {
"id": "byName",
"options": "Incidencias"
},
"properties": [
{
"id": "custom.width",
"value": 280
} }
] ]
} }
@ -951,7 +1252,7 @@
"editorMode": "code", "editorMode": "code",
"format": "table", "format": "table",
"rawQuery": true, "rawQuery": true,
"rawSql": "SELECT\n machine_id AS \"Máquina\",\n production_order AS \"Orden\",\n odoo_workorder_id AS \"Workorder\",\n session_status AS \"Sesión\",\n workorder_state AS \"Estado Odoo\",\n COALESCE(operator_name, operator_candidate_name, '—') AS \"Operario\",\n operator_confirmation_status AS \"Operario\",\n quantity_confirmation_status AS \"Cantidad\",\n confidence AS \"Confianza\",\n dq_status AS \"DQ\",\n array_to_string(issue_codes, ', ') AS \"Incidencias DQ\",\n started_at AS \"Inicio\",\n ended_at AS \"Fin\"\nFROM mv_reports_ucepsa_prod.li_shopfloor_session_data_quality_v1\nORDER BY\n CASE dq_status\n WHEN 'CRITICAL' THEN 1\n WHEN 'WARNING' THEN 2\n ELSE 3\n END,\n COALESCE(ended_at, started_at) DESC NULLS LAST,\n odoo_workorder_id DESC\nLIMIT 50", "rawSql": "SELECT\n machine_id AS \"Máquina\",\n production_order AS \"Orden\",\n odoo_workorder_id AS \"Workorder\",\n CASE session_status\n WHEN 'OPEN' THEN 'Abierta'\n WHEN 'CLOSED' THEN 'Cerrada'\n WHEN 'PENDING' THEN 'Pendiente'\n ELSE session_status\n END AS \"Sesión\",\n workorder_state AS \"Estado Odoo\",\n COALESCE(\n operator_name,\n operator_candidate_name,\n '—'\n ) AS \"Operario\",\n operator_confirmation_status\n AS \"Confirmación operario\",\n quantity_confirmation_status\n AS \"Confirmación cantidad\",\n confidence AS \"Confianza\",\n dq_status AS \"Calidad\",\n COALESCE(\n NULLIF(\n array_to_string(\n issue_codes,\n ', '\n ),\n ''\n ),\n 'Sin incidencias'\n ) AS \"Incidencias\"\nFROM\n mv_reports_ucepsa_prod\n .li_shopfloor_session_data_quality_v1\nORDER BY\n CASE dq_status\n WHEN 'CRITICAL' THEN 1\n WHEN 'WARNING' THEN 2\n ELSE 3\n END,\n COALESCE(ended_at, started_at) DESC NULLS LAST,\n odoo_workorder_id DESC\nLIMIT 50",
"refId": "A" "refId": "A"
} }
], ],
@ -966,7 +1267,8 @@
"ucepsa", "ucepsa",
"shop-floor", "shop-floor",
"context-health", "context-health",
"shadow" "shadow",
"presentation-v0362"
], ],
"templating": { "templating": {
"list": [] "list": []

View File

@ -0,0 +1,335 @@
#!/usr/bin/env bash
set -euo pipefail
ACTION="${1:-deploy}"
GRAFANA_CONTAINER="${GRAFANA_CONTAINER:-mv_ucepsa_grafana}"
REPO_ROOT="${SHOPFLOOR_DASHBOARD_REPO_ROOT:-/srv/mesavault/40-clients/ucepsa/edge-oee-demo}"
DASHBOARD_FILE="${SHOPFLOOR_DASHBOARD_FILE:-$REPO_ROOT/grafana/dashboards/ucepsa-shopfloor-context-health.dashboard.json}"
FOLDER_UID="${SHOPFLOOR_DASHBOARD_FOLDER_UID:-ucepsa-mesavault}"
FOLDER_TITLE="${SHOPFLOOR_DASHBOARD_FOLDER_TITLE:-MESAVAULT — UCEPSA}"
DASHBOARD_UID="ucepsa-shopfloor-context-health"
DATASOURCE_UID="bfnbcasbm6hhca"
resolve_url() {
if [[ -n "${GRAFANA_URL:-}" ]]; then
printf '%s' "$GRAFANA_URL"
return
fi
local port_line host_port container_ip
port_line="$(
docker port "$GRAFANA_CONTAINER" 3000/tcp 2>/dev/null \
| head -n 1 || true
)"
if [[ -n "$port_line" ]]; then
host_port="${port_line##*:}"
printf 'http://127.0.0.1:%s' "$host_port"
return
fi
container_ip="$(
docker inspect "$GRAFANA_CONTAINER" \
--format '{{range .NetworkSettings.Networks}}{{.IPAddress}}{{end}}'
)"
if [[ -z "$container_ip" ]]; then
echo "ERROR: no se pudo resolver la URL de Grafana." >&2
exit 1
fi
printf 'http://%s:3000' "$container_ip"
}
if [[ -z "${GRAFANA_API_TOKEN:-}" ]]; then
cat >&2 <<'MSG'
ERROR: falta GRAFANA_API_TOKEN.
Cárguelo sin mostrarlo:
read -rsp "Token temporal de Grafana: " GRAFANA_API_TOKEN
echo
export GRAFANA_API_TOKEN
MSG
exit 1
fi
export GRAFANA_URL_RESOLVED
GRAFANA_URL_RESOLVED="$(resolve_url)"
python3 - \
"$ACTION" \
"$DASHBOARD_FILE" \
"$FOLDER_UID" \
"$FOLDER_TITLE" \
"$DASHBOARD_UID" \
"$DATASOURCE_UID" <<'PY'
import json
import os
import sys
import urllib.error
import urllib.parse
import urllib.request
(
action,
dashboard_file,
folder_uid,
folder_title,
dashboard_uid,
datasource_uid,
) = sys.argv[1:]
base_url = os.environ["GRAFANA_URL_RESOLVED"].rstrip("/")
token = os.environ["GRAFANA_API_TOKEN"]
def request(method, path, payload=None, allow_404=False):
url = base_url + path
body = None
headers = {
"Accept": "application/json",
"Authorization": f"Bearer {token}",
}
if payload is not None:
body = json.dumps(
payload,
ensure_ascii=False,
).encode("utf-8")
headers["Content-Type"] = "application/json"
req = urllib.request.Request(
url,
data=body,
headers=headers,
method=method,
)
try:
with urllib.request.urlopen(
req,
timeout=20,
) as response:
raw = response.read()
if not raw:
return None
return json.loads(raw.decode("utf-8"))
except urllib.error.HTTPError as exc:
detail = exc.read().decode(
"utf-8",
errors="replace",
)
if allow_404 and exc.code == 404:
return None
raise RuntimeError(
f"Grafana API {method} {path}: "
f"HTTP {exc.code}: {detail}"
) from exc
health = request("GET", "/api/health")
if not health or health.get("database") != "ok":
raise RuntimeError(
f"Grafana no está saludable: {health!r}"
)
folder = request(
"GET",
f"/api/folders/{urllib.parse.quote(folder_uid)}",
allow_404=True,
)
if action == "deploy" and folder is None:
request(
"POST",
"/api/folders",
{
"uid": folder_uid,
"title": folder_title,
},
)
if action == "deploy":
with open(
dashboard_file,
encoding="utf-8",
) as handle:
dashboard = json.load(handle)
result = request(
"POST",
"/api/dashboards/db",
{
"dashboard": dashboard,
"folderUid": folder_uid,
"overwrite": True,
"message": (
"MESAVAULT Shop Floor Context Health "
"presentation fix v0.3.6.2"
),
},
)
print(json.dumps(
{
"status": "deployed",
"grafana_url": base_url,
"dashboard_uid": dashboard_uid,
"folder_uid": folder_uid,
"response": result,
},
ensure_ascii=False,
indent=2,
))
elif action == "validate":
result = request(
"GET",
(
"/api/dashboards/uid/"
+ urllib.parse.quote(dashboard_uid)
),
)
dashboard = result["dashboard"]
panels = {
panel.get("id"): panel
for panel in dashboard.get("panels", [])
}
datasource_uids = sorted({
panel.get("datasource", {}).get("uid")
for panel in dashboard.get("panels", [])
if isinstance(
panel.get("datasource"),
dict,
)
and panel.get("datasource", {}).get("uid")
})
errors = []
if dashboard.get("uid") != dashboard_uid:
errors.append("UID inesperado")
if result.get("meta", {}).get("folderUid") != folder_uid:
errors.append("Carpeta inesperada")
if len(dashboard.get("panels", [])) != 10:
errors.append("El dashboard no tiene 10 paneles")
if datasource_uids != [datasource_uid]:
errors.append(
f"Datasource inesperado: {datasource_uids!r}"
)
health_sql = (
panels.get(1, {})
.get("targets", [{}])[0]
.get("rawSql", "")
)
sync_sql = (
panels.get(2, {})
.get("targets", [{}])[0]
.get("rawSql", "")
)
machine_sql = (
panels.get(7, {})
.get("targets", [{}])[0]
.get("rawSql", "")
)
incident_sql = (
panels.get(8, {})
.get("targets", [{}])[0]
.get("rawSql", "")
)
if "CASE overall_health_status" not in health_sql:
errors.append(
"Salud general no usa código numérico"
)
if "CASE sync_health_status" not in sync_sql:
errors.append(
"Sincronizador no usa código numérico"
)
for forbidden in ('AS "Sync"', 'AS "Acción"'):
if forbidden in machine_sql:
errors.append(
f"Tabla de máquinas conserva columna {forbidden}"
)
if 'AS "Situación"' not in incident_sql:
errors.append(
"Incidencias no usa situación operativa"
)
output = {
"status": "ok" if not errors else "error",
"grafana_url": base_url,
"dashboard_uid": dashboard.get("uid"),
"title": dashboard.get("title"),
"folder_uid": result.get("meta", {}).get(
"folderUid"
),
"panel_count": len(
dashboard.get("panels", [])
),
"datasource_uids": datasource_uids,
"version": dashboard.get("version"),
"url": result.get("meta", {}).get("url"),
"presentation_checks": {
"numeric_health_stat": (
"CASE overall_health_status"
in health_sql
),
"numeric_sync_stat": (
"CASE sync_health_status"
in sync_sql
),
"compact_machine_table": (
'AS "Sync"' not in machine_sql
and 'AS "Acción"' not in machine_sql
),
"friendly_incident_text": (
'AS "Situación"' in incident_sql
),
},
"errors": errors,
}
print(json.dumps(
output,
ensure_ascii=False,
indent=2,
))
if errors:
raise SystemExit(1)
elif action == "export":
result = request(
"GET",
(
"/api/dashboards/uid/"
+ urllib.parse.quote(dashboard_uid)
),
)
print(json.dumps(
result["dashboard"],
ensure_ascii=False,
indent=2,
))
else:
raise SystemExit(
"Uso: "
"deploy_shopfloor_context_health_dashboard_v0362.sh "
"{deploy|validate|export}"
)
PY