From dbc45bccd40dc91390bf7c568351d9cc55d25c2b Mon Sep 17 00:00:00 2001 From: Victor Fraile Garcia Date: Mon, 20 Jul 2026 11:50:02 +0200 Subject: [PATCH] feat(ucepsa): add Shop Floor context health dashboard --- ...opfloor_context_health_dashboard_v0.3.6.md | 102 ++ ...sa-shopfloor-context-health.dashboard.json | 984 ++++++++++++++++++ ...shopfloor_context_health_dashboard_v036.sh | 239 +++++ 3 files changed, 1325 insertions(+) create mode 100644 ucepsa/edge-oee-demo/docs/runbooks/102_shopfloor_context_health_dashboard_v0.3.6.md create mode 100644 ucepsa/edge-oee-demo/grafana/dashboards/ucepsa-shopfloor-context-health.dashboard.json create mode 100755 ucepsa/edge-oee-demo/ops/deploy_shopfloor_context_health_dashboard_v036.sh diff --git a/ucepsa/edge-oee-demo/docs/runbooks/102_shopfloor_context_health_dashboard_v0.3.6.md b/ucepsa/edge-oee-demo/docs/runbooks/102_shopfloor_context_health_dashboard_v0.3.6.md new file mode 100644 index 0000000..c5a4348 --- /dev/null +++ b/ucepsa/edge-oee-demo/docs/runbooks/102_shopfloor_context_health_dashboard_v0.3.6.md @@ -0,0 +1,102 @@ +# UCEPSA — Dashboard Salud y contexto Shop Floor v0.3.6 + +## Decisión de despliegue + +Grafana solo monta: + +```text +/srv/mesavault/edge-oee-ucepsa/grafana/data +→ /var/lib/grafana +``` + +Los JSON del repositorio no están provisionados automáticamente. Por eso el +dashboard se versiona en Git y se despliega mediante la API local de Grafana. + +No se modifica el dashboard operativo existente. + +## Identidad + +```text +Dashboard UID: +ucepsa-shopfloor-context-health + +Carpeta UID: +ucepsa-mesavault + +Carpeta: +MESAVAULT — UCEPSA + +Datasource PostgreSQL: +bfnbcasbm6hhca +``` + +## Paneles + +1. Salud general. +2. Salud del sincronizador. +3. Segundos desde la última sincronización. +4. Sesiones abiertas. +5. Incidencias WARNING. +6. Incidencias CRITICAL. +7. Estado vivo por máquina. +8. Incidencias activas. +9. Sesiones abiertas. +10. Calidad de sesiones. + +## Credenciales + +El script no guarda secretos en Git. + +Prioridad: + +1. `GRAFANA_API_TOKEN`. +2. `GRAFANA_USER` + `GRAFANA_PASSWORD`. +3. Variables `GF_SECURITY_ADMIN_USER` y `GF_SECURITY_ADMIN_PASSWORD` del + contenedor. + +No se muestran contraseñas en la salida. + +## Despliegue + +```bash +cd /srv/mesavault/edge-oee-ucepsa + +/srv/mesavault/40-clients/ucepsa/edge-oee-demo/ops/deploy_shopfloor_context_health_dashboard_v036.sh deploy +``` + +## Validación + +```bash +/srv/mesavault/40-clients/ucepsa/edge-oee-demo/ops/deploy_shopfloor_context_health_dashboard_v036.sh validate +``` + +Debe devolver: + +```text +status = ok +dashboard_uid = ucepsa-shopfloor-context-health +folder_uid = ucepsa-mesavault +panel_count = 10 +datasource_uids = [bfnbcasbm6hhca] +``` + +## Exportación posterior + +```bash +/srv/mesavault/40-clients/ucepsa/edge-oee-demo/ops/deploy_shopfloor_context_health_dashboard_v036.sh export \ + > /tmp/ucepsa-shopfloor-context-health.export.json +``` + +## Rollback + +La importación usa un UID propio y no sobrescribe otros dashboards. + +Para retirarlo, usar la API: + +```bash +curl -X DELETE \ + -H "Authorization: Bearer $GRAFANA_API_TOKEN" \ + http://127.0.0.1:3000/api/dashboards/uid/ucepsa-shopfloor-context-health +``` + +No se recomienda borrar la carpeta si contiene otros dashboards. 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 new file mode 100644 index 0000000..81a261f --- /dev/null +++ b/ucepsa/edge-oee-demo/grafana/dashboards/ucepsa-shopfloor-context-health.dashboard.json @@ -0,0 +1,984 @@ +{ + "annotations": { + "list": [ + { + "builtIn": 1, + "datasource": { + "type": "grafana", + "uid": "-- Grafana --" + }, + "enable": true, + "hide": true, + "iconColor": "rgba(0, 211, 255, 1)", + "name": "Annotations & Alerts", + "type": "dashboard" + } + ] + }, + "description": "Supervisión SHADOW de máquina física, orden Odoo, sesión Shop Floor, sincronizador y calidad temporal. No alimenta el Ledger oficial.", + "editable": true, + "fiscalYearStartMonth": 0, + "graphTooltip": 1, + "id": null, + "links": [], + "liveNow": false, + "panels": [ + { + "datasource": { + "type": "postgres", + "uid": "bfnbcasbm6hhca" + }, + "fieldConfig": { + "defaults": { + "color": { + "mode": "thresholds" + }, + "mappings": [ + { + "options": { + "HEALTHY": { + "color": "green", + "index": 0, + "text": "HEALTHY" + }, + "WARNING": { + "color": "orange", + "index": 1, + "text": "WARNING" + }, + "CRITICAL": { + "color": "red", + "index": 2, + "text": "CRITICAL" + } + }, + "type": "value" + } + ], + "thresholds": { + "mode": "absolute", + "steps": [ + { + "color": "green", + "value": null + } + ] + }, + "unit": "short" + }, + "overrides": [] + }, + "gridPos": { + "h": 4, + "w": 4, + "x": 0, + "y": 0 + }, + "id": 1, + "options": { + "colorMode": "background", + "graphMode": "none", + "justifyMode": "auto", + "orientation": "auto", + "percentChangeColorMode": "standard", + "reduceOptions": { + "calcs": [ + "lastNotNull" + ], + "fields": "", + "values": false + }, + "showPercentChange": false, + "textMode": "auto", + "wideLayout": true + }, + "pluginVersion": "11.0.0", + "targets": [ + { + "datasource": { + "type": "postgres", + "uid": "bfnbcasbm6hhca" + }, + "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", + "refId": "A" + } + ], + "title": "Salud general", + "type": "stat" + }, + { + "datasource": { + "type": "postgres", + "uid": "bfnbcasbm6hhca" + }, + "fieldConfig": { + "defaults": { + "color": { + "mode": "thresholds" + }, + "mappings": [ + { + "options": { + "HEALTHY": { + "color": "green", + "index": 0, + "text": "HEALTHY" + }, + "DELAYED": { + "color": "orange", + "index": 1, + "text": "DELAYED" + }, + "STALE": { + "color": "red", + "index": 2, + "text": "STALE" + }, + "ERROR": { + "color": "red", + "index": 3, + "text": "ERROR" + }, + "NOT_INITIALIZED": { + "color": "red", + "index": 4, + "text": "NOT INITIALIZED" + } + }, + "type": "value" + } + ], + "thresholds": { + "mode": "absolute", + "steps": [ + { + "color": "green", + "value": null + } + ] + }, + "unit": "short" + }, + "overrides": [] + }, + "gridPos": { + "h": 4, + "w": 4, + "x": 4, + "y": 0 + }, + "id": 2, + "options": { + "colorMode": "background", + "graphMode": "none", + "justifyMode": "auto", + "orientation": "auto", + "percentChangeColorMode": "standard", + "reduceOptions": { + "calcs": [ + "lastNotNull" + ], + "fields": "", + "values": false + }, + "showPercentChange": false, + "textMode": "auto", + "wideLayout": true + }, + "pluginVersion": "11.0.0", + "targets": [ + { + "datasource": { + "type": "postgres", + "uid": "bfnbcasbm6hhca" + }, + "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", + "refId": "A" + } + ], + "title": "Sincronizador", + "type": "stat" + }, + { + "datasource": { + "type": "postgres", + "uid": "bfnbcasbm6hhca" + }, + "fieldConfig": { + "defaults": { + "color": { + "mode": "thresholds" + }, + "mappings": [], + "thresholds": { + "mode": "absolute", + "steps": [ + { + "color": "green", + "value": null + } + ] + }, + "unit": "s" + }, + "overrides": [] + }, + "gridPos": { + "h": 4, + "w": 4, + "x": 8, + "y": 0 + }, + "id": 3, + "options": { + "colorMode": "value", + "graphMode": "none", + "justifyMode": "auto", + "orientation": "auto", + "percentChangeColorMode": "standard", + "reduceOptions": { + "calcs": [ + "lastNotNull" + ], + "fields": "", + "values": false + }, + "showPercentChange": false, + "textMode": "auto", + "wideLayout": true + }, + "pluginVersion": "11.0.0", + "targets": [ + { + "datasource": { + "type": "postgres", + "uid": "bfnbcasbm6hhca" + }, + "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", + "refId": "A" + } + ], + "title": "Segundos desde sync", + "type": "stat" + }, + { + "datasource": { + "type": "postgres", + "uid": "bfnbcasbm6hhca" + }, + "fieldConfig": { + "defaults": { + "color": { + "mode": "thresholds" + }, + "mappings": [], + "thresholds": { + "mode": "absolute", + "steps": [ + { + "color": "green", + "value": null + } + ] + }, + "unit": "short" + }, + "overrides": [] + }, + "gridPos": { + "h": 4, + "w": 4, + "x": 12, + "y": 0 + }, + "id": 4, + "options": { + "colorMode": "value", + "graphMode": "none", + "justifyMode": "auto", + "orientation": "auto", + "percentChangeColorMode": "standard", + "reduceOptions": { + "calcs": [ + "lastNotNull" + ], + "fields": "", + "values": false + }, + "showPercentChange": false, + "textMode": "auto", + "wideLayout": true + }, + "pluginVersion": "11.0.0", + "targets": [ + { + "datasource": { + "type": "postgres", + "uid": "bfnbcasbm6hhca" + }, + "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", + "refId": "A" + } + ], + "title": "Sesiones abiertas", + "type": "stat" + }, + { + "datasource": { + "type": "postgres", + "uid": "bfnbcasbm6hhca" + }, + "fieldConfig": { + "defaults": { + "color": { + "mode": "thresholds" + }, + "mappings": [], + "thresholds": { + "mode": "absolute", + "steps": [ + { + "color": "green", + "value": null + } + ] + }, + "unit": "short" + }, + "overrides": [] + }, + "gridPos": { + "h": 4, + "w": 4, + "x": 16, + "y": 0 + }, + "id": 5, + "options": { + "colorMode": "value", + "graphMode": "none", + "justifyMode": "auto", + "orientation": "auto", + "percentChangeColorMode": "standard", + "reduceOptions": { + "calcs": [ + "lastNotNull" + ], + "fields": "", + "values": false + }, + "showPercentChange": false, + "textMode": "auto", + "wideLayout": true + }, + "pluginVersion": "11.0.0", + "targets": [ + { + "datasource": { + "type": "postgres", + "uid": "bfnbcasbm6hhca" + }, + "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", + "refId": "A" + } + ], + "title": "Advertencias", + "type": "stat" + }, + { + "datasource": { + "type": "postgres", + "uid": "bfnbcasbm6hhca" + }, + "fieldConfig": { + "defaults": { + "color": { + "mode": "thresholds" + }, + "mappings": [], + "thresholds": { + "mode": "absolute", + "steps": [ + { + "color": "green", + "value": null + } + ] + }, + "unit": "short" + }, + "overrides": [] + }, + "gridPos": { + "h": 4, + "w": 4, + "x": 20, + "y": 0 + }, + "id": 6, + "options": { + "colorMode": "value", + "graphMode": "none", + "justifyMode": "auto", + "orientation": "auto", + "percentChangeColorMode": "standard", + "reduceOptions": { + "calcs": [ + "lastNotNull" + ], + "fields": "", + "values": false + }, + "showPercentChange": false, + "textMode": "auto", + "wideLayout": true + }, + "pluginVersion": "11.0.0", + "targets": [ + { + "datasource": { + "type": "postgres", + "uid": "bfnbcasbm6hhca" + }, + "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", + "refId": "A" + } + ], + "title": "Críticas", + "type": "stat" + }, + { + "datasource": { + "type": "postgres", + "uid": "bfnbcasbm6hhca" + }, + "fieldConfig": { + "defaults": { + "color": { + "mode": "thresholds" + }, + "custom": { + "align": "auto", + "cellOptions": { + "type": "auto" + }, + "filterable": true, + "inspect": false + }, + "mappings": [], + "thresholds": { + "mode": "absolute", + "steps": [ + { + "color": "green", + "value": null + } + ] + } + }, + "overrides": [ + { + "matcher": { + "id": "byName", + "options": "Estado" + }, + "properties": [ + { + "id": "custom.width", + "value": 260 + } + ] + }, + { + "matcher": { + "id": "byName", + "options": "Acción" + }, + "properties": [ + { + "id": "custom.width", + "value": 420 + } + ] + }, + { + "matcher": { + "id": "byName", + "options": "Operario" + }, + "properties": [ + { + "id": "custom.width", + "value": 220 + } + ] + }, + { + "matcher": { + "id": "byName", + "options": "Severidad" + }, + "properties": [ + { + "id": "mappings", + "value": [ + { + "options": { + "OK": { + "color": "green", + "index": 0, + "text": "OK" + }, + "INFO": { + "color": "blue", + "index": 1, + "text": "INFO" + }, + "WARNING": { + "color": "orange", + "index": 2, + "text": "WARNING" + }, + "CRITICAL": { + "color": "red", + "index": 3, + "text": "CRITICAL" + } + }, + "type": "value" + } + ] + }, + { + "id": "custom.cellOptions", + "value": { + "type": "color-text" + } + }, + { + "id": "custom.width", + "value": 110 + } + ] + } + ] + }, + "gridPos": { + "h": 9, + "w": 24, + "x": 0, + "y": 4 + }, + "id": 7, + "options": { + "cellHeight": "sm", + "footer": { + "countRows": false, + "fields": "", + "reducer": [ + "sum" + ], + "show": false + }, + "showHeader": true, + "sortBy": [ + { + "desc": false, + "displayName": "Máquina" + } + ] + }, + "pluginVersion": "11.0.0", + "targets": [ + { + "datasource": { + "type": "postgres", + "uid": "bfnbcasbm6hhca" + }, + "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", + "refId": "A" + } + ], + "title": "Estado vivo por máquina", + "type": "table" + }, + { + "datasource": { + "type": "postgres", + "uid": "bfnbcasbm6hhca" + }, + "fieldConfig": { + "defaults": { + "custom": { + "align": "auto", + "cellOptions": { + "type": "auto" + }, + "filterable": true, + "inspect": false + }, + "mappings": [], + "thresholds": { + "mode": "absolute", + "steps": [ + { + "color": "green", + "value": null + } + ] + } + }, + "overrides": [ + { + "matcher": { + "id": "byName", + "options": "Severidad" + }, + "properties": [ + { + "id": "mappings", + "value": [ + { + "options": { + "WARNING": { + "color": "orange", + "index": 0, + "text": "WARNING" + }, + "CRITICAL": { + "color": "red", + "index": 1, + "text": "CRITICAL" + } + }, + "type": "value" + } + ] + }, + { + "id": "custom.cellOptions", + "value": { + "type": "color-background" + } + }, + { + "id": "custom.width", + "value": 110 + } + ] + }, + { + "matcher": { + "id": "byName", + "options": "Situación" + }, + "properties": [ + { + "id": "custom.width", + "value": 420 + } + ] + }, + { + "matcher": { + "id": "byName", + "options": "Acción" + }, + "properties": [ + { + "id": "custom.width", + "value": 460 + } + ] + } + ] + }, + "gridPos": { + "h": 10, + "w": 24, + "x": 0, + "y": 13 + }, + "id": 8, + "options": { + "cellHeight": "sm", + "footer": { + "countRows": false, + "fields": "", + "reducer": [ + "sum" + ], + "show": false + }, + "showHeader": true, + "sortBy": [] + }, + "pluginVersion": "11.0.0", + "targets": [ + { + "datasource": { + "type": "postgres", + "uid": "bfnbcasbm6hhca" + }, + "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", + "refId": "A" + } + ], + "title": "Incidencias activas", + "type": "table" + }, + { + "datasource": { + "type": "postgres", + "uid": "bfnbcasbm6hhca" + }, + "fieldConfig": { + "defaults": { + "custom": { + "align": "auto", + "cellOptions": { + "type": "auto" + }, + "filterable": true, + "inspect": false + }, + "mappings": [], + "thresholds": { + "mode": "absolute", + "steps": [ + { + "color": "green", + "value": null + } + ] + } + }, + "overrides": [ + { + "matcher": { + "id": "byName", + "options": "Minutos abierta" + }, + "properties": [ + { + "id": "unit", + "value": "m" + } + ] + }, + { + "matcher": { + "id": "byName", + "options": "Paro abierto min" + }, + "properties": [ + { + "id": "unit", + "value": "m" + } + ] + }, + { + "matcher": { + "id": "byName", + "options": "Acción" + }, + "properties": [ + { + "id": "custom.width", + "value": 420 + } + ] + } + ] + }, + "gridPos": { + "h": 9, + "w": 24, + "x": 0, + "y": 23 + }, + "id": 9, + "options": { + "cellHeight": "sm", + "footer": { + "countRows": false, + "fields": "", + "reducer": [ + "sum" + ], + "show": false + }, + "showHeader": true, + "sortBy": [] + }, + "pluginVersion": "11.0.0", + "targets": [ + { + "datasource": { + "type": "postgres", + "uid": "bfnbcasbm6hhca" + }, + "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", + "refId": "A" + } + ], + "title": "Sesiones abiertas", + "type": "table" + }, + { + "datasource": { + "type": "postgres", + "uid": "bfnbcasbm6hhca" + }, + "fieldConfig": { + "defaults": { + "custom": { + "align": "auto", + "cellOptions": { + "type": "auto" + }, + "filterable": true, + "inspect": false + }, + "mappings": [], + "thresholds": { + "mode": "absolute", + "steps": [ + { + "color": "green", + "value": null + } + ] + } + }, + "overrides": [ + { + "matcher": { + "id": "byName", + "options": "DQ" + }, + "properties": [ + { + "id": "mappings", + "value": [ + { + "options": { + "OK": { + "color": "green", + "index": 0, + "text": "OK" + }, + "WARNING": { + "color": "orange", + "index": 1, + "text": "WARNING" + }, + "CRITICAL": { + "color": "red", + "index": 2, + "text": "CRITICAL" + } + }, + "type": "value" + } + ] + }, + { + "id": "custom.cellOptions", + "value": { + "type": "color-text" + } + } + ] + } + ] + }, + "gridPos": { + "h": 10, + "w": 24, + "x": 0, + "y": 32 + }, + "id": 10, + "options": { + "cellHeight": "sm", + "footer": { + "countRows": false, + "fields": "", + "reducer": [ + "sum" + ], + "show": false + }, + "showHeader": true, + "sortBy": [] + }, + "pluginVersion": "11.0.0", + "targets": [ + { + "datasource": { + "type": "postgres", + "uid": "bfnbcasbm6hhca" + }, + "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", + "refId": "A" + } + ], + "title": "Calidad de sesiones", + "type": "table" + } + ], + "refresh": "5s", + "schemaVersion": 42, + "tags": [ + "mesavault", + "ucepsa", + "shop-floor", + "context-health", + "shadow" + ], + "templating": { + "list": [] + }, + "time": { + "from": "now-12h", + "to": "now" + }, + "timepicker": {}, + "timezone": "browser", + "title": "MESAVAULT — UCEPSA · Salud y contexto Shop Floor", + "uid": "ucepsa-shopfloor-context-health", + "version": 1, + "weekStart": "" +} diff --git a/ucepsa/edge-oee-demo/ops/deploy_shopfloor_context_health_dashboard_v036.sh b/ucepsa/edge-oee-demo/ops/deploy_shopfloor_context_health_dashboard_v036.sh new file mode 100755 index 0000000..8933d2c --- /dev/null +++ b/ucepsa/edge-oee-demo/ops/deploy_shopfloor_context_health_dashboard_v036.sh @@ -0,0 +1,239 @@ +#!/usr/bin/env bash +set -euo pipefail + +ACTION="${1:-deploy}" + +ROOT="${SHOPFLOOR_DASHBOARD_ROOT:-/srv/mesavault/edge-oee-ucepsa}" +REPO_ROOT="${SHOPFLOOR_DASHBOARD_REPO_ROOT:-/srv/mesavault/40-clients/ucepsa/edge-oee-demo}" +GRAFANA_CONTAINER="${GRAFANA_CONTAINER:-mv_ucepsa_grafana}" +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" + +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" +} + +read_container_env() { + local key="$1" + docker inspect "$GRAFANA_CONTAINER" \ + --format '{{range .Config.Env}}{{println .}}{{end}}' \ + | sed -n "s/^${key}=//p" \ + | head -n 1 +} + +export GRAFANA_URL_RESOLVED +GRAFANA_URL_RESOLVED="$(resolve_url)" + +if [[ -z "${GRAFANA_API_TOKEN:-}" ]]; then + export GRAFANA_USER_RESOLVED="${GRAFANA_USER:-$(read_container_env GF_SECURITY_ADMIN_USER)}" + export GRAFANA_PASSWORD_RESOLVED="${GRAFANA_PASSWORD:-$(read_container_env GF_SECURITY_ADMIN_PASSWORD)}" + + if [[ -z "$GRAFANA_USER_RESOLVED" ]]; then + GRAFANA_USER_RESOLVED="admin" + fi + + if [[ -z "$GRAFANA_PASSWORD_RESOLVED" ]]; then + cat >&2 <<'MSG' +ERROR: no se encontró una credencial de escritura de Grafana. +Defina una de estas opciones sin guardarla en Git: + + export GRAFANA_API_TOKEN='...' + +o: + + export GRAFANA_USER='admin' + export GRAFANA_PASSWORD='...' +MSG + exit 1 + fi +fi + +python3 - "$ACTION" "$DASHBOARD_FILE" "$FOLDER_UID" "$FOLDER_TITLE" "$DASHBOARD_UID" <<'PY' +import base64 +import json +import os +import sys +import urllib.error +import urllib.parse +import urllib.request + +action, dashboard_file, folder_uid, folder_title, dashboard_uid = sys.argv[1:] +base_url = os.environ["GRAFANA_URL_RESOLVED"].rstrip("/") + +token = os.environ.get("GRAFANA_API_TOKEN") +user = os.environ.get("GRAFANA_USER_RESOLVED") +password = os.environ.get("GRAFANA_PASSWORD_RESOLVED") + + +def request(method, path, payload=None, allow_404=False): + url = base_url + path + body = None + headers = {"Accept": "application/json"} + + if payload is not None: + body = json.dumps(payload, ensure_ascii=False).encode("utf-8") + headers["Content-Type"] = "application/json" + + if token: + headers["Authorization"] = f"Bearer {token}" + elif user and password: + raw = f"{user}:{password}".encode("utf-8") + headers["Authorization"] = ( + "Basic " + base64.b64encode(raw).decode("ascii") + ) + + req = urllib.request.Request( + url, + data=body, + headers=headers, + method=method, + ) + + try: + with urllib.request.urlopen(req, timeout=15) as response: + data = response.read() + if not data: + return None + return json.loads(data.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}: 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}") + +if action in {"deploy", "validate"}: + folder = request( + "GET", + f"/api/folders/{urllib.parse.quote(folder_uid)}", + allow_404=True, + ) + if folder is None and action == "deploy": + folder = 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": "Deploy reproducible MESAVAULT Shop Floor Context Health v0.3.6", + }, + ) + print(json.dumps( + { + "status": "deployed", + "grafana_url": base_url, + "folder_uid": folder_uid, + "dashboard_uid": dashboard_uid, + "response": result, + }, + ensure_ascii=False, + indent=2, + )) + +elif action == "validate": + result = request( + "GET", + f"/api/dashboards/uid/{urllib.parse.quote(dashboard_uid)}", + ) + dashboard = result["dashboard"] + 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") + }) + + expected_datasource = "bfnbcasbm6hhca" + errors = [] + + if dashboard.get("uid") != dashboard_uid: + errors.append("UID de dashboard inesperado") + if result.get("meta", {}).get("folderUid") != folder_uid: + errors.append("El dashboard no está en la carpeta prevista") + if len(dashboard.get("panels", [])) != 10: + errors.append("El dashboard no tiene 10 paneles") + if datasource_uids != [expected_datasource]: + errors.append( + f"Datasource inesperado: {datasource_uids!r}" + ) + + 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"), + "errors": errors, + } + print(json.dumps(output, ensure_ascii=False, indent=2)) + + if errors: + raise SystemExit(1) + +elif action == "export": + result = request( + "GET", + f"/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_v036.sh " + "{deploy|validate|export}" + ) +PY