diff --git a/ucepsa/edge-oee-demo/docs/runbooks/104_shopfloor_context_health_dashboard_presentation_v0.3.6.2.md b/ucepsa/edge-oee-demo/docs/runbooks/104_shopfloor_context_health_dashboard_presentation_v0.3.6.2.md new file mode 100644 index 0000000..37938e2 --- /dev/null +++ b/ucepsa/edge-oee-demo/docs/runbooks/104_shopfloor_context_health_dashboard_presentation_v0.3.6.2.md @@ -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 = [] +``` diff --git a/ucepsa/edge-oee-demo/grafana/dashboards/ucepsa-shopfloor-context-health.dashboard.json b/ucepsa/edge-oee-demo/grafana/dashboards/ucepsa-shopfloor-context-health.dashboard.json index 81a261f..9415ec1 100644 --- a/ucepsa/edge-oee-demo/grafana/dashboards/ucepsa-shopfloor-context-health.dashboard.json +++ b/ucepsa/edge-oee-demo/grafana/dashboards/ucepsa-shopfloor-context-health.dashboard.json @@ -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, "fiscalYearStartMonth": 0, "graphTooltip": 1, @@ -35,24 +35,29 @@ }, "mappings": [ { + "type": "value", "options": { - "HEALTHY": { + "0": { + "text": "HEALTHY", "color": "green", - "index": 0, - "text": "HEALTHY" + "index": 0 }, - "WARNING": { + "1": { + "text": "WARNING", "color": "orange", - "index": 1, - "text": "WARNING" + "index": 1 }, - "CRITICAL": { + "2": { + "text": "CRITICAL", "color": "red", - "index": 2, - "text": "CRITICAL" + "index": 2 + }, + "3": { + "text": "UNKNOWN", + "color": "red", + "index": 3 } - }, - "type": "value" + } } ], "thresholds": { @@ -61,6 +66,14 @@ { "color": "green", "value": null + }, + { + "color": "orange", + "value": 1 + }, + { + "color": "red", + "value": 2 } ] }, @@ -89,7 +102,7 @@ "values": false }, "showPercentChange": false, - "textMode": "auto", + "textMode": "value", "wideLayout": true }, "pluginVersion": "11.0.0", @@ -102,7 +115,7 @@ "editorMode": "code", "format": "table", "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" } ], @@ -121,34 +134,34 @@ }, "mappings": [ { + "type": "value", "options": { - "HEALTHY": { + "0": { + "text": "HEALTHY", "color": "green", - "index": 0, - "text": "HEALTHY" + "index": 0 }, - "DELAYED": { + "1": { + "text": "DELAYED", "color": "orange", - "index": 1, - "text": "DELAYED" + "index": 1 }, - "STALE": { + "2": { + "text": "STALE", "color": "red", - "index": 2, - "text": "STALE" + "index": 2 }, - "ERROR": { + "3": { + "text": "ERROR", "color": "red", - "index": 3, - "text": "ERROR" + "index": 3 }, - "NOT_INITIALIZED": { + "4": { + "text": "NOT INITIALIZED", "color": "red", - "index": 4, - "text": "NOT INITIALIZED" + "index": 4 } - }, - "type": "value" + } } ], "thresholds": { @@ -157,6 +170,14 @@ { "color": "green", "value": null + }, + { + "color": "orange", + "value": 1 + }, + { + "color": "red", + "value": 2 } ] }, @@ -185,7 +206,7 @@ "values": false }, "showPercentChange": false, - "textMode": "auto", + "textMode": "value", "wideLayout": true }, "pluginVersion": "11.0.0", @@ -198,7 +219,7 @@ "editorMode": "code", "format": "table", "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" } ], @@ -222,6 +243,14 @@ { "color": "green", "value": null + }, + { + "color": "orange", + "value": 45 + }, + { + "color": "red", + "value": 120 } ] }, @@ -250,7 +279,7 @@ "values": false }, "showPercentChange": false, - "textMode": "auto", + "textMode": "value", "wideLayout": true }, "pluginVersion": "11.0.0", @@ -263,7 +292,7 @@ "editorMode": "code", "format": "table", "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" } ], @@ -315,7 +344,7 @@ "values": false }, "showPercentChange": false, - "textMode": "auto", + "textMode": "value", "wideLayout": true }, "pluginVersion": "11.0.0", @@ -328,7 +357,7 @@ "editorMode": "code", "format": "table", "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" } ], @@ -352,6 +381,10 @@ { "color": "green", "value": null + }, + { + "color": "orange", + "value": 1 } ] }, @@ -380,7 +413,7 @@ "values": false }, "showPercentChange": false, - "textMode": "auto", + "textMode": "value", "wideLayout": true }, "pluginVersion": "11.0.0", @@ -393,7 +426,7 @@ "editorMode": "code", "format": "table", "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" } ], @@ -417,6 +450,10 @@ { "color": "green", "value": null + }, + { + "color": "red", + "value": 1 } ] }, @@ -445,7 +482,7 @@ "values": false }, "showPercentChange": false, - "textMode": "auto", + "textMode": "value", "wideLayout": true }, "pluginVersion": "11.0.0", @@ -458,7 +495,7 @@ "editorMode": "code", "format": "table", "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" } ], @@ -498,24 +535,60 @@ { "matcher": { "id": "byName", - "options": "Estado" + "options": "Máquina" }, "properties": [ { "id": "custom.width", - "value": 260 + "value": 95 } ] }, { "matcher": { "id": "byName", - "options": "Acción" + "options": "Marcha" }, "properties": [ { "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": [ { "id": "custom.width", - "value": 220 + "value": 235 + } + ] + }, + { + "matcher": { + "id": "byName", + "options": "Estado" + }, + "properties": [ + { + "id": "custom.width", + "value": 300 } ] }, @@ -541,29 +626,29 @@ "id": "mappings", "value": [ { + "type": "value", "options": { "OK": { + "text": "OK", "color": "green", - "index": 0, - "text": "OK" + "index": 0 }, "INFO": { + "text": "INFO", "color": "blue", - "index": 1, - "text": "INFO" + "index": 1 }, "WARNING": { + "text": "WARNING", "color": "orange", - "index": 2, - "text": "WARNING" + "index": 2 }, "CRITICAL": { + "text": "CRITICAL", "color": "red", - "index": 3, - "text": "CRITICAL" + "index": 3 } - }, - "type": "value" + } } ] }, @@ -616,7 +701,7 @@ "editorMode": "code", "format": "table", "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" } ], @@ -650,6 +735,18 @@ } }, "overrides": [ + { + "matcher": { + "id": "byName", + "options": "Hora" + }, + "properties": [ + { + "id": "custom.width", + "value": 175 + } + ] + }, { "matcher": { "id": "byName", @@ -660,19 +757,19 @@ "id": "mappings", "value": [ { + "type": "value", "options": { "WARNING": { + "text": "WARNING", "color": "orange", - "index": 0, - "text": "WARNING" + "index": 0 }, "CRITICAL": { + "text": "CRITICAL", "color": "red", - "index": 1, - "text": "CRITICAL" + "index": 1 } - }, - "type": "value" + } } ] }, @@ -688,6 +785,18 @@ } ] }, + { + "matcher": { + "id": "byName", + "options": "Máquina" + }, + "properties": [ + { + "id": "custom.width", + "value": 95 + } + ] + }, { "matcher": { "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": { "id": "byName", @@ -708,7 +841,7 @@ "properties": [ { "id": "custom.width", - "value": 460 + "value": 520 } ] } @@ -744,7 +877,7 @@ "editorMode": "code", "format": "table", "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" } ], @@ -778,6 +911,54 @@ } }, "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": { "id": "byName", @@ -787,6 +968,10 @@ { "id": "unit", "value": "m" + }, + { + "id": "custom.width", + "value": 125 } ] }, @@ -799,18 +984,70 @@ { "id": "unit", "value": "m" + }, + { + "id": "custom.width", + "value": 125 } ] }, { "matcher": { "id": "byName", - "options": "Acción" + "options": "Estado" }, "properties": [ { "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", "format": "table", "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" } ], @@ -883,31 +1120,79 @@ { "matcher": { "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": [ { "id": "mappings", "value": [ { + "type": "value", "options": { "OK": { + "text": "OK", "color": "green", - "index": 0, - "text": "OK" + "index": 0 }, "WARNING": { + "text": "WARNING", "color": "orange", - "index": 1, - "text": "WARNING" + "index": 1 }, "CRITICAL": { + "text": "CRITICAL", "color": "red", - "index": 2, - "text": "CRITICAL" + "index": 2 } - }, - "type": "value" + } } ] }, @@ -916,6 +1201,22 @@ "value": { "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", "format": "table", "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" } ], @@ -966,7 +1267,8 @@ "ucepsa", "shop-floor", "context-health", - "shadow" + "shadow", + "presentation-v0362" ], "templating": { "list": [] diff --git a/ucepsa/edge-oee-demo/ops/deploy_shopfloor_context_health_dashboard_v0362.sh b/ucepsa/edge-oee-demo/ops/deploy_shopfloor_context_health_dashboard_v0362.sh new file mode 100755 index 0000000..ab89c59 --- /dev/null +++ b/ucepsa/edge-oee-demo/ops/deploy_shopfloor_context_health_dashboard_v0362.sh @@ -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