feat(ucepsa): add Shop Floor context health dashboard

This commit is contained in:
Victor Fraile Garcia 2026-07-20 11:50:02 +02:00
parent e5cb98e349
commit dbc45bccd4
3 changed files with 1325 additions and 0 deletions

View File

@ -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.

View File

@ -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": ""
}

View File

@ -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