Compare commits
59 Commits
main
...
feat/uceps
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
de0daf8ef0 | ||
|
|
c3eb98a91d | ||
|
|
33706d397a | ||
|
|
27a653eb42 | ||
|
|
3d0aef0410 | ||
|
|
b06f15534f | ||
|
|
72c57f4ce9 | ||
|
|
1fca1d6232 | ||
|
|
9dccf500e2 | ||
|
|
ce086b697c | ||
|
|
2efcb750a3 | ||
|
|
f9095e6a7b | ||
|
|
7e8a676d53 | ||
|
|
aa8d3dc267 | ||
|
|
10e5582b2c | ||
|
|
2afc19105b | ||
|
|
dbc45bccd4 | ||
|
|
e5cb98e349 | ||
|
|
d1f1b9a6c8 | ||
|
|
48fb79d452 | ||
|
|
86fc845a5e | ||
|
|
0ed49a3838 | ||
|
|
7f9a88d81a | ||
|
|
73544c0a85 | ||
|
|
43795f8038 | ||
|
|
e7aa13c433 | ||
|
|
73ce89be09 | ||
|
|
b2a09f3826 | ||
|
|
faf1804558 | ||
|
|
d83bacefc4 | ||
|
|
36d67d99b0 | ||
|
|
4586e29afe | ||
|
|
2d198e5ddb | ||
| 88c84f73f5 | |||
|
|
1381733157 | ||
|
|
42dc050fc7 | ||
|
|
7c5ad7f5a3 | ||
|
|
cc951b713d | ||
|
|
94fa0d7026 | ||
|
|
206adbc8bb | ||
|
|
bff3fef40d | ||
|
|
4e3e3aef1b | ||
|
|
0ed753587b | ||
|
|
90a0da730b | ||
|
|
350e9a29c8 | ||
|
|
939345753f | ||
|
|
00e71ecbf7 | ||
|
|
85d478b0df | ||
|
|
69f8416808 | ||
|
|
d525443de9 | ||
|
|
94f6e9dc3f | ||
|
|
8bc9c73f28 | ||
|
|
2c963be6cc | ||
|
|
94e7a3d27f | ||
|
|
0e4871d908 | ||
|
|
0061c6a616 | ||
|
|
a2b5d7dfb5 | ||
|
|
58a10d1c37 | ||
| 29b0e0456b |
48
.gitignore
vendored
Normal file
48
.gitignore
vendored
Normal file
@ -0,0 +1,48 @@
|
|||||||
|
# --- MESAVAULT / UCEPSA runtime & secrets ---
|
||||||
|
**/.env
|
||||||
|
**/.env.*
|
||||||
|
!**/.env.example
|
||||||
|
!**/.env.*.example
|
||||||
|
|
||||||
|
# Runtime / data
|
||||||
|
**/backups/
|
||||||
|
**/logs/
|
||||||
|
**/data/
|
||||||
|
**/postgres/data/
|
||||||
|
**/grafana/data/
|
||||||
|
**/mosquitto/data/
|
||||||
|
**/mosquitto/log/
|
||||||
|
**/__pycache__/
|
||||||
|
**/*.pyc
|
||||||
|
**/*.pyo
|
||||||
|
**/*.log
|
||||||
|
|
||||||
|
# Dumps / databases
|
||||||
|
**/*.dump
|
||||||
|
**/*.backup
|
||||||
|
**/*.sql.gz
|
||||||
|
**/*.tar
|
||||||
|
**/*.tar.gz
|
||||||
|
**/*.zip
|
||||||
|
|
||||||
|
# Certificates / keys / tokens
|
||||||
|
**/*.pem
|
||||||
|
**/*.key
|
||||||
|
**/*.crt
|
||||||
|
**/acme.json
|
||||||
|
**/id_rsa
|
||||||
|
**/id_rsa.pub
|
||||||
|
**/id_ed25519
|
||||||
|
**/id_ed25519.pub
|
||||||
|
**/passwords
|
||||||
|
|
||||||
|
# Odoo runtime
|
||||||
|
**/filestore/
|
||||||
|
**/enterprise/
|
||||||
|
**/odoo-data/
|
||||||
|
**/odoo-db-data/
|
||||||
|
|
||||||
|
# Editors
|
||||||
|
.vscode/
|
||||||
|
.idea/
|
||||||
|
*.swp
|
||||||
1
ucepsa/README.md
Normal file
1
ucepsa/README.md
Normal file
@ -0,0 +1 @@
|
|||||||
|
UCEPSA
|
||||||
67
ucepsa/edge-oee-demo/.gitignore
vendored
Normal file
67
ucepsa/edge-oee-demo/.gitignore
vendored
Normal file
@ -0,0 +1,67 @@
|
|||||||
|
# Secrets
|
||||||
|
.env
|
||||||
|
.env.*
|
||||||
|
**/.env
|
||||||
|
**/.env.*
|
||||||
|
!**/.env.example
|
||||||
|
|
||||||
|
# Python
|
||||||
|
**/__pycache__/
|
||||||
|
**/*.pyc
|
||||||
|
**/.venv/
|
||||||
|
**/venv/
|
||||||
|
|
||||||
|
# Logs
|
||||||
|
*.log
|
||||||
|
logs/
|
||||||
|
**/logs/
|
||||||
|
|
||||||
|
# Runtime data / volumes
|
||||||
|
data/
|
||||||
|
volumes/
|
||||||
|
postgres_data/
|
||||||
|
grafana/data/
|
||||||
|
mosquitto/data/
|
||||||
|
mosquitto/log/
|
||||||
|
minio/data/
|
||||||
|
|
||||||
|
# Backups and dumps with data
|
||||||
|
backups/
|
||||||
|
*.dump
|
||||||
|
*.backup
|
||||||
|
*.tar
|
||||||
|
*.tar.gz
|
||||||
|
*.tgz
|
||||||
|
*.zip
|
||||||
|
*.gz
|
||||||
|
*.sql.gz
|
||||||
|
|
||||||
|
# Keys / certificates
|
||||||
|
*.pem
|
||||||
|
*.key
|
||||||
|
*.crt
|
||||||
|
*.p12
|
||||||
|
id_rsa*
|
||||||
|
id_ed25519*
|
||||||
|
|
||||||
|
# Local env files
|
||||||
|
**/.env.local
|
||||||
|
**/*.env.local
|
||||||
|
|
||||||
|
# Backup / old files
|
||||||
|
*.bak
|
||||||
|
*.bak.*
|
||||||
|
*.bak_*
|
||||||
|
*_OLD
|
||||||
|
|
||||||
|
# Python cache
|
||||||
|
**/__pycache__/
|
||||||
|
**/*.pyc
|
||||||
|
|
||||||
|
# Grafana runtime data
|
||||||
|
grafana/data/
|
||||||
|
**/grafana/data/
|
||||||
|
|
||||||
|
# Audit packages
|
||||||
|
*_audit_*.tgz
|
||||||
|
*_audit_*.zip
|
||||||
112
ucepsa/edge-oee-demo/README.md
Normal file
112
ucepsa/edge-oee-demo/README.md
Normal file
@ -0,0 +1,112 @@
|
|||||||
|
# UCEPSA — MESAVAULT Edge-OEE
|
||||||
|
|
||||||
|
Repositorio del piloto productivo MESAVAULT Edge-OEE en UCEPSA.
|
||||||
|
|
||||||
|
## Estado actual
|
||||||
|
|
||||||
|
El sistema está en estado de piloto productivo activo.
|
||||||
|
|
||||||
|
Incluye:
|
||||||
|
|
||||||
|
- captura real de máquina mediante RevPi y nodos WISE;
|
||||||
|
- lectura energética mediante Eastron SDM630;
|
||||||
|
- publicación MQTT de telemetría;
|
||||||
|
- persistencia en PostgreSQL;
|
||||||
|
- integración con Odoo PROD mediante order_state;
|
||||||
|
- control automático de balizas;
|
||||||
|
- panel de operador para justificación de paros;
|
||||||
|
- dashboard Grafana con informe de paros, energía, estado máquina y capas de análisis.
|
||||||
|
|
||||||
|
## Máquinas incluidas
|
||||||
|
|
||||||
|
- CORT-00
|
||||||
|
- CORT-01
|
||||||
|
- CORT-02
|
||||||
|
|
||||||
|
## Servicios principales
|
||||||
|
|
||||||
|
En servidor mv-edge-oee:
|
||||||
|
|
||||||
|
- mv_ucepsa_mosquitto
|
||||||
|
- mv_ucepsa_postgres_hot
|
||||||
|
- mv_ucepsa_edge_oee_mqtt_pg_sink
|
||||||
|
- mv_ucepsa_odoo_order_state_publisher
|
||||||
|
- mv_ucepsa_odoo_order_state_pg_sink
|
||||||
|
- mv_ucepsa_operator_stop_console
|
||||||
|
- mv_ucepsa_grafana
|
||||||
|
|
||||||
|
En RevPi:
|
||||||
|
|
||||||
|
- mesavault-edge-oee-revpi
|
||||||
|
- mesavault-beacon-controller
|
||||||
|
|
||||||
|
## Integración Odoo
|
||||||
|
|
||||||
|
La integración con Odoo se realiza mediante un publicador externo:
|
||||||
|
|
||||||
|
tools/odoo_order_state_publisher.py
|
||||||
|
|
||||||
|
Este servicio consulta Odoo PROD y publica un mensaje MQTT retained por máquina:
|
||||||
|
|
||||||
|
vertical/ucepsa/ucepsa_onpremise/edge_oee/order_state/CORT-00
|
||||||
|
vertical/ucepsa/ucepsa_onpremise/edge_oee/order_state/CORT-01
|
||||||
|
vertical/ucepsa/ucepsa_onpremise/edge_oee/order_state/CORT-02
|
||||||
|
|
||||||
|
La regla validada es:
|
||||||
|
|
||||||
|
progress_count = 1 -> orden cargada correctamente
|
||||||
|
|
||||||
|
Validación real realizada:
|
||||||
|
|
||||||
|
CORT-00
|
||||||
|
WH/MO/00090
|
||||||
|
[6] BOLSA BD 22X30 TPTE
|
||||||
|
state = loaded
|
||||||
|
|
||||||
|
## Panel de operador
|
||||||
|
|
||||||
|
El panel de operador permite justificar paros detectados automáticamente.
|
||||||
|
|
||||||
|
Servicio:
|
||||||
|
|
||||||
|
mv_ucepsa_operator_stop_console
|
||||||
|
|
||||||
|
Acceso:
|
||||||
|
|
||||||
|
http://17.126.1.70:8088
|
||||||
|
|
||||||
|
Acceso por máquina:
|
||||||
|
|
||||||
|
http://17.126.1.70:8088/?machine_id=CORT-00
|
||||||
|
http://17.126.1.70:8088/?machine_id=CORT-01
|
||||||
|
http://17.126.1.70:8088/?machine_id=CORT-02
|
||||||
|
|
||||||
|
## Estructura del repositorio
|
||||||
|
|
||||||
|
services/ servicios Python/Docker
|
||||||
|
tools/ herramientas de sincronización, diagnóstico y publicación
|
||||||
|
sql/ migraciones, vistas y funciones PostgreSQL
|
||||||
|
grafana/ dashboards, scripts e import/export
|
||||||
|
deploy/ucepsa-local/ docker-compose y plantillas de entorno
|
||||||
|
revpi/ código y unidades systemd de RevPi
|
||||||
|
db/schema/ export de esquema PostgreSQL sin datos
|
||||||
|
docs/ documentación técnica y runbooks
|
||||||
|
env-examples/ ejemplos de variables sin secretos
|
||||||
|
|
||||||
|
## No versionar
|
||||||
|
|
||||||
|
No deben versionarse:
|
||||||
|
|
||||||
|
- .env reales;
|
||||||
|
- .env.local;
|
||||||
|
- contraseñas;
|
||||||
|
- tokens;
|
||||||
|
- claves privadas;
|
||||||
|
- dumps PostgreSQL con datos;
|
||||||
|
- volúmenes Docker;
|
||||||
|
- logs;
|
||||||
|
- backups comprimidos;
|
||||||
|
- datos de Grafana;
|
||||||
|
- datos de PostgreSQL.
|
||||||
|
|
||||||
|
Las credenciales deben mantenerse fuera del repositorio.
|
||||||
8742
ucepsa/edge-oee-demo/db/schema/postgres_schema_latest.sql
Normal file
8742
ucepsa/edge-oee-demo/db/schema/postgres_schema_latest.sql
Normal file
File diff suppressed because it is too large
Load Diff
@ -0,0 +1,55 @@
|
|||||||
|
version: "3.8"
|
||||||
|
|
||||||
|
services:
|
||||||
|
oee-demo-generator:
|
||||||
|
build:
|
||||||
|
context: ./services/oee-demo-generator
|
||||||
|
container_name: edge_oee_demo_generator
|
||||||
|
env_file:
|
||||||
|
- .env
|
||||||
|
networks:
|
||||||
|
- platform_net
|
||||||
|
restart: "no"
|
||||||
|
|
||||||
|
edge-oee-mqtt-pg-sink:
|
||||||
|
build:
|
||||||
|
context: ./services/edge-oee-mqtt-pg-sink
|
||||||
|
container_name: edge_oee_mqtt_pg_sink
|
||||||
|
env_file:
|
||||||
|
- .env.sink
|
||||||
|
environment:
|
||||||
|
PYTHONUNBUFFERED: "1"
|
||||||
|
networks:
|
||||||
|
- platform_net
|
||||||
|
restart: unless-stopped
|
||||||
|
|
||||||
|
operator-stop-console:
|
||||||
|
build:
|
||||||
|
context: ./services/operator-stop-console
|
||||||
|
container_name: edge_oee_operator_stop_console
|
||||||
|
env_file:
|
||||||
|
- .env.operator
|
||||||
|
environment:
|
||||||
|
PYTHONUNBUFFERED: "1"
|
||||||
|
ports:
|
||||||
|
- "100.116.75.30:8088:8080"
|
||||||
|
networks:
|
||||||
|
- platform_net
|
||||||
|
restart: unless-stopped
|
||||||
|
|
||||||
|
odoo-workorders-sync:
|
||||||
|
build:
|
||||||
|
context: ./services/odoo-workorders-sync
|
||||||
|
container_name: edge_oee_odoo_workorders_sync
|
||||||
|
env_file:
|
||||||
|
- .env.odoo-sync
|
||||||
|
environment:
|
||||||
|
PYTHONUNBUFFERED: "1"
|
||||||
|
networks:
|
||||||
|
- platform_net
|
||||||
|
restart: unless-stopped
|
||||||
|
|
||||||
|
networks:
|
||||||
|
platform_net:
|
||||||
|
external:
|
||||||
|
name: ${PLATFORM_NET}
|
||||||
53
ucepsa/edge-oee-demo/deploy/ucepsa-local/.env.example
Normal file
53
ucepsa/edge-oee-demo/deploy/ucepsa-local/.env.example
Normal file
@ -0,0 +1,53 @@
|
|||||||
|
# Identidad
|
||||||
|
TENANT=ucepsa
|
||||||
|
SITE=ucepsa_onpremise
|
||||||
|
VERTICAL=edge_oee
|
||||||
|
MACHINE_ID=CORT-01
|
||||||
|
ASSET=revpi_oee_node_01
|
||||||
|
|
||||||
|
# PostgreSQL MESAVAULT
|
||||||
|
POSTGRES_DB=mesavault_ucepsa_hot
|
||||||
|
POSTGRES_USER=mesavault
|
||||||
|
|
||||||
|
# Grafana
|
||||||
|
GRAFANA_ADMIN_USER=admin
|
||||||
|
|
||||||
|
# MQTT local
|
||||||
|
MQTT_PORT=1883
|
||||||
|
|
||||||
|
# MQTT credentials for local Edge-OEE sink
|
||||||
|
MQTT_USER_REVPI=revpi_ucepsa_edge_oee_01
|
||||||
|
POSTGRES_PASSWORD=__SET_IN_ENV_OR_VAULTWARDEN__
|
||||||
|
|
||||||
|
# PostgreSQL writer for Odoo sync
|
||||||
|
|
||||||
|
# Odoo UCEPSA
|
||||||
|
#ODOO_URL=https://17.126.1.61
|
||||||
|
ODOO_URL=http://17.126.1.61:8069
|
||||||
|
ODOO_DB=ucepsa_prod
|
||||||
|
ODOO_USER=__SET_IN_ENV_OR_VAULTWARDEN__
|
||||||
|
ODOO_PASSWORD=__SET_IN_ENV_OR_VAULTWARDEN__
|
||||||
|
ODOO_WORKCENTER_CODE=CORT-01
|
||||||
|
ODOO_INSTANCE=odoo_ucepsa_prod
|
||||||
|
|
||||||
|
ODOO_SYNC_INTERVAL_SECONDS=30
|
||||||
|
ODOO_WORKORDER_LIMIT=100
|
||||||
|
ODOO_WORKORDER_STATES=waiting,ready,progress,done
|
||||||
|
|
||||||
|
# PostgreSQL writer for Odoo sync
|
||||||
|
ODOO_SYNC_PGUSER=edge_oee_odoo_sync_writer
|
||||||
|
ODOO_SYNC_PGPASSWORD=__SET_IN_ENV_OR_VAULTWARDEN__
|
||||||
|
|
||||||
|
# Operator stop console
|
||||||
|
OPERATOR_PGUSER=edge_oee_operator_writer
|
||||||
|
OPERATOR_PGPASSWORD=__SET_IN_ENV_OR_VAULTWARDEN__
|
||||||
|
OPERATOR_APP_PORT=8080
|
||||||
|
OPERATOR_HOST_PORT=8088
|
||||||
|
OPERATOR_DEFAULT_MACHINE_ID=CORT-01
|
||||||
|
OPERATOR_DEFAULT_ASSET=revpi_oee_node_01
|
||||||
|
OPERATOR_ALLOWED_MACHINES=CORT-00,CORT-01,CORT-02,CORT-03,CORT-04,CORT-05,CORT-06,CORT-08,CORT-09,CORT-10,CORT-FAS1,CORT-FAS2
|
||||||
|
|
||||||
|
# Operator console auto refresh
|
||||||
|
OPERATOR_AUTO_REFRESH_SECONDS=5
|
||||||
|
MQTT_PASSWORD_REVPI=__SET_IN_ENV_OR_VAULTWARDEN__
|
||||||
|
GRAFANA_ADMIN_PASSWORD=__SET_IN_ENV_OR_VAULTWARDEN__
|
||||||
@ -0,0 +1,33 @@
|
|||||||
|
services:
|
||||||
|
mv_ucepsa_edge_oee_mqtt_pg_sink:
|
||||||
|
build:
|
||||||
|
context: ./services/edge-oee-mqtt-pg-sink
|
||||||
|
container_name: mv_ucepsa_edge_oee_mqtt_pg_sink
|
||||||
|
restart: unless-stopped
|
||||||
|
environment:
|
||||||
|
PYTHONUNBUFFERED: "1"
|
||||||
|
|
||||||
|
MQTT_HOST: mv_ucepsa_mosquitto
|
||||||
|
MQTT_PORT: 1883
|
||||||
|
MQTT_USER: ${MQTT_USER_REVPI}
|
||||||
|
MQTT_PASSWORD: ${MQTT_PASSWORD_REVPI}
|
||||||
|
MQTT_TOPIC: vertical/ucepsa/ucepsa_onpremise/edge_oee/revpi_oee_node_01/+
|
||||||
|
|
||||||
|
PGHOST: mv_ucepsa_postgres_hot
|
||||||
|
PGPORT: 5432
|
||||||
|
PGDATABASE: ${POSTGRES_DB}
|
||||||
|
PGUSER: ${POSTGRES_USER}
|
||||||
|
PGPASSWORD: ${POSTGRES_PASSWORD}
|
||||||
|
|
||||||
|
TENANT: ucepsa
|
||||||
|
SITE: ucepsa_onpremise
|
||||||
|
VERTICAL: edge_oee
|
||||||
|
ASSET: revpi_oee_node_01
|
||||||
|
MACHINE_ID: CORT-01
|
||||||
|
VALUE_HOUR_EUR: 36
|
||||||
|
TZ: Europe/Madrid
|
||||||
|
depends_on:
|
||||||
|
- mv_ucepsa_postgres_hot
|
||||||
|
- mv_ucepsa_mosquitto
|
||||||
|
networks:
|
||||||
|
- mv_ucepsa_net
|
||||||
@ -0,0 +1,35 @@
|
|||||||
|
services:
|
||||||
|
mv_ucepsa_odoo_workorders_sync:
|
||||||
|
build:
|
||||||
|
context: ./services/odoo-workorders-sync
|
||||||
|
container_name: mv_ucepsa_odoo_workorders_sync
|
||||||
|
restart: unless-stopped
|
||||||
|
environment:
|
||||||
|
PYTHONUNBUFFERED: "1"
|
||||||
|
|
||||||
|
ODOO_URL: ${ODOO_URL}
|
||||||
|
ODOO_DB: ${ODOO_DB}
|
||||||
|
ODOO_USER: ${ODOO_USER}
|
||||||
|
ODOO_PASSWORD: ${ODOO_PASSWORD}
|
||||||
|
ODOO_WORKCENTER_CODE: ${ODOO_WORKCENTER_CODE}
|
||||||
|
ODOO_INSTANCE: ${ODOO_INSTANCE}
|
||||||
|
ODOO_SYNC_INTERVAL_SECONDS: ${ODOO_SYNC_INTERVAL_SECONDS}
|
||||||
|
ODOO_WORKORDER_LIMIT: ${ODOO_WORKORDER_LIMIT}
|
||||||
|
ODOO_WORKORDER_STATES: ${ODOO_WORKORDER_STATES}
|
||||||
|
|
||||||
|
PGHOST: mv_ucepsa_postgres_hot
|
||||||
|
PGPORT: 5432
|
||||||
|
PGDATABASE: ${POSTGRES_DB}
|
||||||
|
PGUSER: ${ODOO_SYNC_PGUSER}
|
||||||
|
PGPASSWORD: ${ODOO_SYNC_PGPASSWORD}
|
||||||
|
|
||||||
|
TENANT: ucepsa
|
||||||
|
SITE: ucepsa_test
|
||||||
|
MACHINE_ID: CORT-01
|
||||||
|
ASSET: revpi_oee_node_01
|
||||||
|
TARGET_CYCLE_RATE_PPM: 10
|
||||||
|
TZ: Europe/Madrid
|
||||||
|
depends_on:
|
||||||
|
- mv_ucepsa_postgres_hot
|
||||||
|
networks:
|
||||||
|
- mv_ucepsa_net
|
||||||
@ -0,0 +1,28 @@
|
|||||||
|
services:
|
||||||
|
mv_ucepsa_operator_stop_console:
|
||||||
|
build:
|
||||||
|
context: ./services/operator-stop-console
|
||||||
|
container_name: mv_ucepsa_operator_stop_console
|
||||||
|
restart: unless-stopped
|
||||||
|
ports:
|
||||||
|
- "${OPERATOR_HOST_PORT:-8088}:${OPERATOR_APP_PORT:-8080}"
|
||||||
|
environment:
|
||||||
|
PYTHONUNBUFFERED: "1"
|
||||||
|
|
||||||
|
PGHOST: mv_ucepsa_postgres_hot
|
||||||
|
PGPORT: 5432
|
||||||
|
PGDATABASE: ${POSTGRES_DB}
|
||||||
|
PGUSER: ${OPERATOR_PGUSER}
|
||||||
|
PGPASSWORD: ${OPERATOR_PGPASSWORD}
|
||||||
|
|
||||||
|
APP_PORT: ${OPERATOR_APP_PORT:-8080}
|
||||||
|
APP_TITLE: "MESAVAULT Edge-OEE - Consola de paros UCEPSA"
|
||||||
|
DEFAULT_MACHINE_ID: ${OPERATOR_DEFAULT_MACHINE_ID:-CORT-01}
|
||||||
|
DEFAULT_ASSET: ${OPERATOR_DEFAULT_ASSET:-revpi_oee_node_01}
|
||||||
|
ALLOWED_MACHINES: ${OPERATOR_ALLOWED_MACHINES:-CORT-01}
|
||||||
|
AUTO_REFRESH_SECONDS: ${OPERATOR_AUTO_REFRESH_SECONDS:-5}
|
||||||
|
TZ: Europe/Madrid
|
||||||
|
depends_on:
|
||||||
|
- mv_ucepsa_postgres_hot
|
||||||
|
networks:
|
||||||
|
- mv_ucepsa_net
|
||||||
@ -0,0 +1,25 @@
|
|||||||
|
services:
|
||||||
|
order_report_api:
|
||||||
|
container_name: mv_ucepsa_order_report_api
|
||||||
|
build:
|
||||||
|
context: ./services/order-report-api
|
||||||
|
dockerfile: Dockerfile
|
||||||
|
image: mv_ucepsa_order_report_api:0.3
|
||||||
|
restart: unless-stopped
|
||||||
|
env_file:
|
||||||
|
- ./services/order-report-api/.env.local
|
||||||
|
ports:
|
||||||
|
- "8090:8090"
|
||||||
|
networks:
|
||||||
|
- ucepsa_edge_oee_net
|
||||||
|
healthcheck:
|
||||||
|
test: ["CMD", "curl", "-f", "http://127.0.0.1:8090/health"]
|
||||||
|
interval: 30s
|
||||||
|
timeout: 5s
|
||||||
|
retries: 3
|
||||||
|
start_period: 20s
|
||||||
|
|
||||||
|
networks:
|
||||||
|
ucepsa_edge_oee_net:
|
||||||
|
external: true
|
||||||
|
name: mv_ucepsa_net
|
||||||
@ -0,0 +1,28 @@
|
|||||||
|
services:
|
||||||
|
mv_ucepsa_odoo_order_state_pg_sink:
|
||||||
|
image: edge-oee-ucepsa-mv_ucepsa_edge_oee_mqtt_pg_sink
|
||||||
|
container_name: mv_ucepsa_odoo_order_state_pg_sink
|
||||||
|
restart: unless-stopped
|
||||||
|
env_file:
|
||||||
|
- .env
|
||||||
|
environment:
|
||||||
|
TENANT: ucepsa
|
||||||
|
SITE: ucepsa_onpremise
|
||||||
|
MQTT_HOST: mv_ucepsa_mosquitto
|
||||||
|
MQTT_PORT: 1883
|
||||||
|
MQTT_TOPIC: vertical/ucepsa/ucepsa_onpremise/edge_oee/order_state/+
|
||||||
|
PGHOST: mv_ucepsa_postgres_hot
|
||||||
|
PGPORT: 5432
|
||||||
|
PGDATABASE: ${POSTGRES_DB}
|
||||||
|
PGUSER: ${POSTGRES_USER}
|
||||||
|
PGPASSWORD: ${POSTGRES_PASSWORD}
|
||||||
|
volumes:
|
||||||
|
- ./tools:/app/tools:ro
|
||||||
|
working_dir: /app
|
||||||
|
command: python tools/order_state_pg_sink.py
|
||||||
|
networks:
|
||||||
|
- mv_ucepsa_net
|
||||||
|
|
||||||
|
networks:
|
||||||
|
mv_ucepsa_net:
|
||||||
|
external: true
|
||||||
@ -0,0 +1,37 @@
|
|||||||
|
services:
|
||||||
|
mv_ucepsa_odoo_order_state_publisher:
|
||||||
|
image: edge-oee-ucepsa-mv_ucepsa_edge_oee_mqtt_pg_sink
|
||||||
|
container_name: mv_ucepsa_odoo_order_state_publisher
|
||||||
|
restart: unless-stopped
|
||||||
|
env_file:
|
||||||
|
- .env
|
||||||
|
environment:
|
||||||
|
ODOO_DB_OVERRIDE: ucepsa_prod
|
||||||
|
SITE_OVERRIDE: ucepsa_onpremise
|
||||||
|
MQTT_HOST_OVERRIDE: mv_ucepsa_mosquitto
|
||||||
|
MQTT_USER: ${MQTT_USER_REVPI}
|
||||||
|
MQTT_PASSWORD: ${MQTT_PASSWORD_REVPI}
|
||||||
|
PYTHONUNBUFFERED: "1"
|
||||||
|
PYTHONDONTWRITEBYTECODE: "1"
|
||||||
|
volumes:
|
||||||
|
- ./:/app:ro
|
||||||
|
working_dir: /app
|
||||||
|
command:
|
||||||
|
- python
|
||||||
|
- tools/odoo_order_state_publisher.py
|
||||||
|
- --publish
|
||||||
|
- --loop
|
||||||
|
- --interval
|
||||||
|
- "5"
|
||||||
|
logging:
|
||||||
|
driver: "json-file"
|
||||||
|
options:
|
||||||
|
max-size: "10m"
|
||||||
|
max-file: "3"
|
||||||
|
networks:
|
||||||
|
- mv_ucepsa_net
|
||||||
|
|
||||||
|
networks:
|
||||||
|
mv_ucepsa_net:
|
||||||
|
external: true
|
||||||
|
name: mv_ucepsa_net
|
||||||
@ -0,0 +1,35 @@
|
|||||||
|
services:
|
||||||
|
mv_ucepsa_shopfloor_session_probe:
|
||||||
|
image: edge-oee-ucepsa-mv_ucepsa_edge_oee_mqtt_pg_sink
|
||||||
|
container_name: mv_ucepsa_shopfloor_session_probe
|
||||||
|
profiles:
|
||||||
|
- diagnostic
|
||||||
|
env_file:
|
||||||
|
- .env
|
||||||
|
environment:
|
||||||
|
ODOO_DB_OVERRIDE: ucepsa_prod
|
||||||
|
PYTHONUNBUFFERED: "1"
|
||||||
|
PYTHONDONTWRITEBYTECODE: "1"
|
||||||
|
volumes:
|
||||||
|
- ./:/app:ro
|
||||||
|
- /tmp:/probe-output
|
||||||
|
working_dir: /app
|
||||||
|
command:
|
||||||
|
- python
|
||||||
|
- tools/odoo_shopfloor_session_probe.py
|
||||||
|
- --machine
|
||||||
|
- CORT-00
|
||||||
|
- --watch
|
||||||
|
- --duration-minutes
|
||||||
|
- "30"
|
||||||
|
- --interval
|
||||||
|
- "2"
|
||||||
|
- --output
|
||||||
|
- /probe-output/odoo_shopfloor_session_probe.jsonl
|
||||||
|
networks:
|
||||||
|
- mv_ucepsa_net
|
||||||
|
|
||||||
|
networks:
|
||||||
|
mv_ucepsa_net:
|
||||||
|
external: true
|
||||||
|
name: mv_ucepsa_net
|
||||||
49
ucepsa/edge-oee-demo/deploy/ucepsa-local/docker-compose.yml
Normal file
49
ucepsa/edge-oee-demo/deploy/ucepsa-local/docker-compose.yml
Normal file
@ -0,0 +1,49 @@
|
|||||||
|
services:
|
||||||
|
mv_ucepsa_postgres_hot:
|
||||||
|
image: postgres:16-alpine
|
||||||
|
container_name: mv_ucepsa_postgres_hot
|
||||||
|
restart: unless-stopped
|
||||||
|
environment:
|
||||||
|
POSTGRES_DB: ${POSTGRES_DB}
|
||||||
|
POSTGRES_USER: ${POSTGRES_USER}
|
||||||
|
POSTGRES_PASSWORD: ${POSTGRES_PASSWORD}
|
||||||
|
TZ: Europe/Madrid
|
||||||
|
volumes:
|
||||||
|
- ./postgres/data:/var/lib/postgresql/data
|
||||||
|
networks:
|
||||||
|
- mv_ucepsa_net
|
||||||
|
|
||||||
|
mv_ucepsa_mosquitto:
|
||||||
|
image: eclipse-mosquitto:2
|
||||||
|
container_name: mv_ucepsa_mosquitto
|
||||||
|
restart: unless-stopped
|
||||||
|
ports:
|
||||||
|
- "1883:1883"
|
||||||
|
volumes:
|
||||||
|
- ./mosquitto/config:/mosquitto/config
|
||||||
|
- ./mosquitto/data:/mosquitto/data
|
||||||
|
- ./mosquitto/log:/mosquitto/log
|
||||||
|
networks:
|
||||||
|
- mv_ucepsa_net
|
||||||
|
|
||||||
|
mv_ucepsa_grafana:
|
||||||
|
image: grafana/grafana-oss:latest
|
||||||
|
container_name: mv_ucepsa_grafana
|
||||||
|
restart: unless-stopped
|
||||||
|
ports:
|
||||||
|
- "3000:3000"
|
||||||
|
environment:
|
||||||
|
GF_SECURITY_ADMIN_USER: ${GRAFANA_ADMIN_USER}
|
||||||
|
GF_SECURITY_ADMIN_PASSWORD: ${GRAFANA_ADMIN_PASSWORD}
|
||||||
|
GF_USERS_ALLOW_SIGN_UP: "false"
|
||||||
|
TZ: Europe/Madrid
|
||||||
|
volumes:
|
||||||
|
- ./grafana/data:/var/lib/grafana
|
||||||
|
depends_on:
|
||||||
|
- mv_ucepsa_postgres_hot
|
||||||
|
networks:
|
||||||
|
- mv_ucepsa_net
|
||||||
|
|
||||||
|
networks:
|
||||||
|
mv_ucepsa_net:
|
||||||
|
name: mv_ucepsa_net
|
||||||
37
ucepsa/edge-oee-demo/docs/ESTADO_ACTUAL_2026-05-27.md
Normal file
37
ucepsa/edge-oee-demo/docs/ESTADO_ACTUAL_2026-05-27.md
Normal file
@ -0,0 +1,37 @@
|
|||||||
|
# Estado actual — MESAVAULT Edge-OEE UCEPSA
|
||||||
|
|
||||||
|
## VM
|
||||||
|
|
||||||
|
- Host: mv-edge-oee
|
||||||
|
- IP: 17.126.1.70
|
||||||
|
- Stack: Docker Compose
|
||||||
|
- Fecha: 2026-05-27
|
||||||
|
|
||||||
|
## Servicios activos
|
||||||
|
|
||||||
|
- PostgreSQL: mv_ucepsa_postgres_hot
|
||||||
|
- Mosquitto: mv_ucepsa_mosquitto
|
||||||
|
- Grafana: mv_ucepsa_grafana
|
||||||
|
- MQTT sink: mv_ucepsa_edge_oee_mqtt_pg_sink
|
||||||
|
- Odoo sync: mv_ucepsa_odoo_workorders_sync
|
||||||
|
- Consola de paros: mv_ucepsa_operator_stop_console
|
||||||
|
|
||||||
|
## Funcionalidad validada
|
||||||
|
|
||||||
|
- Odoo UCEPSA sincroniza órdenes de Cortadora 01.
|
||||||
|
- Grafana muestra Odoo vs máquina.
|
||||||
|
- Consola de paros permite clasificar paros.
|
||||||
|
- Loss Intelligence v0 creada mediante vistas SQL.
|
||||||
|
- RevPi validada previamente por OpenVPN, actualmente apagada.
|
||||||
|
|
||||||
|
## URLs
|
||||||
|
|
||||||
|
- Grafana: http://17.126.1.70:3000
|
||||||
|
- Consola: http://17.126.1.70:8088/?machine_id=CORT-01
|
||||||
|
|
||||||
|
## Pendiente
|
||||||
|
|
||||||
|
- Conectar RevPi físicamente o por VPN.
|
||||||
|
- Crear HTTPS / PWA para tablets.
|
||||||
|
- Preparar backup automático.
|
||||||
|
- Escalar a más cortadoras.
|
||||||
41
ucepsa/edge-oee-demo/docs/README.platform40-original.md
Normal file
41
ucepsa/edge-oee-demo/docs/README.platform40-original.md
Normal file
@ -0,0 +1,41 @@
|
|||||||
|
# MESAVAULT Edge-OEE UCEPSA Demo
|
||||||
|
|
||||||
|
Demo técnica y comercial de Edge-OEE para UCEPSA.
|
||||||
|
|
||||||
|
Objetivo:
|
||||||
|
|
||||||
|
Odoo Shop Floor dice qué orden está en curso.
|
||||||
|
MESAVAULT Edge-OEE muestra qué hizo realmente la máquina durante esa orden.
|
||||||
|
|
||||||
|
La demo convierte eventos de máquina en:
|
||||||
|
|
||||||
|
- pérdidas cuantificadas;
|
||||||
|
- coste económico;
|
||||||
|
- causas de paro;
|
||||||
|
- discrepancias Odoo/máquina;
|
||||||
|
- acciones recomendadas;
|
||||||
|
- justificación económica de mantenimiento, producción y calidad.
|
||||||
|
|
||||||
|
## Arquitectura
|
||||||
|
|
||||||
|
Esta vertical sigue el patrón MESAVAULT ya utilizado en otras verticales:
|
||||||
|
|
||||||
|
- platform-40 procesa;
|
||||||
|
- mv_postgres_hot conserva;
|
||||||
|
- Grafana visualiza;
|
||||||
|
- los servicios de vertical quedan en /srv/verticals;
|
||||||
|
- Gitea guarda SQL, scripts, dashboards y documentación técnica saneada.
|
||||||
|
|
||||||
|
## Estado
|
||||||
|
|
||||||
|
Fase actual: demo simulada sin hardware adicional.
|
||||||
|
|
||||||
|
Primer hito:
|
||||||
|
|
||||||
|
En Grafana se debe ver:
|
||||||
|
|
||||||
|
- una orden ficticia de Odoo;
|
||||||
|
- una línea temporal realista de máquina;
|
||||||
|
- preparación, marcha, microparos y paros;
|
||||||
|
- coste por causa;
|
||||||
|
- acción recomendada.
|
||||||
@ -0,0 +1,29 @@
|
|||||||
|
# ADR — Contexto productivo multifuente durante migraciones ERP
|
||||||
|
|
||||||
|
## Estado
|
||||||
|
|
||||||
|
Aceptado para UCEPSA en modo SHADOW.
|
||||||
|
|
||||||
|
## Problema
|
||||||
|
|
||||||
|
Una máquina puede producir legítimamente sin orden Odoo porque la planta mantiene
|
||||||
|
temporalmente actividad en el ERP anterior.
|
||||||
|
|
||||||
|
Tratar todo `RUNNING_WITHOUT_VALID_ORDER` como error o pérdida produciría falsos
|
||||||
|
positivos y dañaría la implantación.
|
||||||
|
|
||||||
|
## Decisión
|
||||||
|
|
||||||
|
MESAVAULT separa:
|
||||||
|
|
||||||
|
- verdad física: WISE/RevPi;
|
||||||
|
- contexto productivo: Odoo, legacy, manual, mantenimiento o prueba;
|
||||||
|
- pérdida: solo tras intersectar actividad, contexto confirmado y política económica.
|
||||||
|
|
||||||
|
## Consecuencias
|
||||||
|
|
||||||
|
- Odoo sigue siendo la fuente preferida.
|
||||||
|
- Legacy se acepta como excepción explícita.
|
||||||
|
- Sin contexto confirmado se genera una tarea de revisión, no un coste.
|
||||||
|
- Las balizas no cambian de semántica.
|
||||||
|
- El porcentaje Odoo/legacy/sin contexto se convierte en indicador de migración.
|
||||||
@ -0,0 +1,76 @@
|
|||||||
|
# Consultas Grafana — Shop Floor Context Health v0.3.5
|
||||||
|
|
||||||
|
## Estado por máquina
|
||||||
|
|
||||||
|
```sql
|
||||||
|
SELECT
|
||||||
|
machine_id AS "Máquina",
|
||||||
|
live_context_status AS "Estado",
|
||||||
|
live_severity AS "Severidad",
|
||||||
|
published_production_order AS "Orden publicada",
|
||||||
|
odoo_workorder_id AS "Workorder sesión",
|
||||||
|
COALESCE(
|
||||||
|
operator_name,
|
||||||
|
operator_candidate_name
|
||||||
|
) AS "Operario",
|
||||||
|
sync_health_status AS "Sync",
|
||||||
|
recommended_action AS "Acción"
|
||||||
|
FROM
|
||||||
|
mv_reports_ucepsa_prod
|
||||||
|
.li_shopfloor_machine_live_context_v1
|
||||||
|
ORDER BY machine_id;
|
||||||
|
```
|
||||||
|
|
||||||
|
## Incidencias
|
||||||
|
|
||||||
|
```sql
|
||||||
|
SELECT
|
||||||
|
observed_at AS "Hora",
|
||||||
|
severity AS "Severidad",
|
||||||
|
machine_id AS "Máquina",
|
||||||
|
incident_code AS "Código",
|
||||||
|
production_order AS "Orden",
|
||||||
|
odoo_workorder_id AS "Workorder",
|
||||||
|
operator_name AS "Operario",
|
||||||
|
detail AS "Situación",
|
||||||
|
recommended_action AS "Acción"
|
||||||
|
FROM
|
||||||
|
mv_reports_ucepsa_prod
|
||||||
|
.li_shopfloor_context_incidents_v1
|
||||||
|
ORDER BY
|
||||||
|
CASE severity
|
||||||
|
WHEN 'CRITICAL' THEN 1
|
||||||
|
WHEN 'WARNING' THEN 2
|
||||||
|
ELSE 3
|
||||||
|
END,
|
||||||
|
machine_id;
|
||||||
|
```
|
||||||
|
|
||||||
|
## Sesiones abiertas
|
||||||
|
|
||||||
|
```sql
|
||||||
|
SELECT
|
||||||
|
machine_id AS "Máquina",
|
||||||
|
production_order AS "Orden",
|
||||||
|
odoo_workorder_id AS "Workorder",
|
||||||
|
operator_name AS "Operario",
|
||||||
|
session_started_at AS "Inicio",
|
||||||
|
session_age_min AS "Minutos abierta",
|
||||||
|
machine_running AS "Máquina en marcha",
|
||||||
|
open_stop_duration_min AS "Paro abierto min",
|
||||||
|
watch_status AS "Estado",
|
||||||
|
watch_severity AS "Severidad"
|
||||||
|
FROM
|
||||||
|
mv_reports_ucepsa_prod
|
||||||
|
.li_shopfloor_open_session_watch_v1
|
||||||
|
ORDER BY machine_id;
|
||||||
|
```
|
||||||
|
|
||||||
|
## Resumen
|
||||||
|
|
||||||
|
```sql
|
||||||
|
SELECT *
|
||||||
|
FROM
|
||||||
|
mv_reports_ucepsa_prod
|
||||||
|
.li_shopfloor_context_health_summary_v1;
|
||||||
|
```
|
||||||
@ -0,0 +1,95 @@
|
|||||||
|
--- SCHEMAS ---
|
||||||
|
schema_name
|
||||||
|
-------------
|
||||||
|
mv_control
|
||||||
|
mv_elpicon
|
||||||
|
mv_hot
|
||||||
|
(3 rows)
|
||||||
|
|
||||||
|
--- TABLES ---
|
||||||
|
table_schema | table_name
|
||||||
|
--------------+-----------------------------
|
||||||
|
mv_control | asset_device_map
|
||||||
|
mv_control | drain_downlink_audit
|
||||||
|
mv_control | drain_schedule
|
||||||
|
mv_hot | aquaponics_latest
|
||||||
|
mv_hot | aquaponics_readings
|
||||||
|
mv_hot | climate_air_readings
|
||||||
|
mv_hot | climate_node_latest
|
||||||
|
mv_hot | climate_node_readings
|
||||||
|
mv_hot | drain_latest
|
||||||
|
mv_hot | drain_readings
|
||||||
|
mv_hot | edge_oee_data_quality_demo
|
||||||
|
mv_hot | edge_oee_eastron_readings
|
||||||
|
mv_hot | edge_oee_energy_demo
|
||||||
|
mv_hot | edge_oee_events_demo
|
||||||
|
mv_hot | edge_oee_loss_summary_demo
|
||||||
|
mv_hot | edge_oee_machine_stops
|
||||||
|
mv_hot | edge_oee_machine_targets
|
||||||
|
mv_hot | edge_oee_machines
|
||||||
|
mv_hot | edge_oee_node_events
|
||||||
|
mv_hot | edge_oee_orders_demo
|
||||||
|
mv_hot | edge_oee_quality_cases_demo
|
||||||
|
mv_hot | edge_oee_stop_causes
|
||||||
|
mv_hot | hydro_events
|
||||||
|
mv_hot | hydro_risk_history
|
||||||
|
mv_hot | terrace_regime_config
|
||||||
|
mv_hot | weather_alerts
|
||||||
|
mv_hot | weather_forecast_hourly
|
||||||
|
(27 rows)
|
||||||
|
|
||||||
|
--- VIEWS ---
|
||||||
|
table_schema | table_name
|
||||||
|
--------------+--------------------------------------
|
||||||
|
mv_elpicon | climate_air_latest
|
||||||
|
mv_elpicon | climate_air_readings
|
||||||
|
mv_elpicon | drain_events
|
||||||
|
mv_elpicon | drain_latest
|
||||||
|
mv_elpicon | drain_readings
|
||||||
|
mv_hot | aquaponics_dashboard
|
||||||
|
mv_hot | aquaponics_flow_dashboard
|
||||||
|
mv_hot | aquaponics_readings_clean
|
||||||
|
mv_hot | climate_air_latest
|
||||||
|
mv_hot | climate_node_dashboard
|
||||||
|
mv_hot | climate_node_latest_clean
|
||||||
|
mv_hot | climate_node_readings_clean
|
||||||
|
mv_hot | drain_events
|
||||||
|
mv_hot | edge_oee_actions_demo
|
||||||
|
mv_hot | edge_oee_data_quality_dashboard_demo
|
||||||
|
mv_hot | edge_oee_eastron_dashboard
|
||||||
|
mv_hot | edge_oee_eastron_diagnosis_latest
|
||||||
|
mv_hot | edge_oee_eastron_latest
|
||||||
|
mv_hot | edge_oee_executive_summary_demo
|
||||||
|
mv_hot | edge_oee_kpi_cards_demo
|
||||||
|
mv_hot | edge_oee_latest_machine_snapshot
|
||||||
|
mv_hot | edge_oee_live_telemetry
|
||||||
|
mv_hot | edge_oee_machine_state_latest
|
||||||
|
mv_hot | edge_oee_node_events_recent
|
||||||
|
mv_hot | edge_oee_open_stops
|
||||||
|
mv_hot | edge_oee_order_timeline_demo
|
||||||
|
mv_hot | edge_oee_pareto_demo
|
||||||
|
mv_hot | edge_oee_quality_case_demo
|
||||||
|
mv_hot | edge_oee_stop_cause_options
|
||||||
|
mv_hot | edge_oee_stop_summary_demo
|
||||||
|
mv_hot | edge_oee_stops_recent
|
||||||
|
mv_hot | hydro_combined_risk_now_v1
|
||||||
|
mv_hot | hydro_dashboard_v2
|
||||||
|
mv_hot | hydro_decision_now_v1
|
||||||
|
mv_hot | hydro_diagnosis_latest_v2
|
||||||
|
mv_hot | hydro_features_now_v1
|
||||||
|
mv_hot | hydro_weather_risk
|
||||||
|
mv_hot | terrace_exposure_v1
|
||||||
|
mv_hot | weather_local_regime_v1
|
||||||
|
(39 rows)
|
||||||
|
|
||||||
|
--- ROLES RELEVANTES ---
|
||||||
|
rolname
|
||||||
|
---------------------------
|
||||||
|
edge_oee_demo_writer
|
||||||
|
edge_oee_eastron_writer
|
||||||
|
edge_oee_mqtt_sink_writer
|
||||||
|
edge_oee_operator_writer
|
||||||
|
grafana_elpicon_ro
|
||||||
|
grafana_ucepsa_ro
|
||||||
|
(6 rows)
|
||||||
|
|
||||||
@ -0,0 +1,421 @@
|
|||||||
|
# Runbook — Order Report, Margin Intelligence y Cost Readiness Cockpit
|
||||||
|
|
||||||
|
## 1. Propósito
|
||||||
|
|
||||||
|
Este documento describe la capa de informes de ejecución de orden, Margin Intelligence y Cost Readiness Cockpit desarrollada para MESAVAULT Edge-OEE en UCEPSA.
|
||||||
|
|
||||||
|
El objetivo de esta capa es unir datos de Odoo, máquina, paros, energía, materia prima e ingreso comercial para generar informes técnicos y económico-industriales por orden de fabricación.
|
||||||
|
|
||||||
|
## 2. Componentes incluidos
|
||||||
|
|
||||||
|
La fase incluye:
|
||||||
|
|
||||||
|
- informe HTML por orden;
|
||||||
|
- informe PDF por orden;
|
||||||
|
- índice de órdenes;
|
||||||
|
- Margin Intelligence;
|
||||||
|
- Cost Readiness Cockpit;
|
||||||
|
- vistas SQL de ejecución de orden;
|
||||||
|
- vistas SQL de materia prima;
|
||||||
|
- vistas SQL de margen;
|
||||||
|
- vistas SQL de readiness económico;
|
||||||
|
- microservicio FastAPI;
|
||||||
|
- despliegue mediante Docker Compose.
|
||||||
|
|
||||||
|
## 3. URLs operativas
|
||||||
|
|
||||||
|
Servicio Order Report API:
|
||||||
|
|
||||||
|
```text
|
||||||
|
http://17.126.1.70:8090
|
||||||
|
```
|
||||||
|
|
||||||
|
Healthcheck:
|
||||||
|
|
||||||
|
```text
|
||||||
|
http://17.126.1.70:8090/health
|
||||||
|
```
|
||||||
|
|
||||||
|
Índice de órdenes:
|
||||||
|
|
||||||
|
```text
|
||||||
|
http://17.126.1.70:8090/orders.html
|
||||||
|
```
|
||||||
|
|
||||||
|
Cost Readiness Cockpit:
|
||||||
|
|
||||||
|
```text
|
||||||
|
http://17.126.1.70:8090/cost-readiness.html
|
||||||
|
```
|
||||||
|
|
||||||
|
Informe HTML de orden:
|
||||||
|
|
||||||
|
```text
|
||||||
|
http://17.126.1.70:8090/report.html?order_ref=WH%2FMO%2F00025
|
||||||
|
```
|
||||||
|
|
||||||
|
Informe PDF de orden:
|
||||||
|
|
||||||
|
```text
|
||||||
|
http://17.126.1.70:8090/report.pdf?order_ref=WH%2FMO%2F00025
|
||||||
|
```
|
||||||
|
|
||||||
|
## 4. Servicio Docker
|
||||||
|
|
||||||
|
Contenedor:
|
||||||
|
|
||||||
|
```text
|
||||||
|
mv_ucepsa_order_report_api
|
||||||
|
```
|
||||||
|
|
||||||
|
Compose:
|
||||||
|
|
||||||
|
```text
|
||||||
|
docker-compose.order-report.yml
|
||||||
|
```
|
||||||
|
|
||||||
|
Arranque:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd /srv/mesavault/edge-oee-ucepsa
|
||||||
|
docker compose -f docker-compose.order-report.yml up -d --build
|
||||||
|
```
|
||||||
|
|
||||||
|
Parada:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd /srv/mesavault/edge-oee-ucepsa
|
||||||
|
docker compose -f docker-compose.order-report.yml down
|
||||||
|
```
|
||||||
|
|
||||||
|
Comprobación:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
curl http://127.0.0.1:8090/health
|
||||||
|
```
|
||||||
|
|
||||||
|
Logs:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
docker logs --tail=120 mv_ucepsa_order_report_api
|
||||||
|
```
|
||||||
|
|
||||||
|
## 5. Ubicación runtime
|
||||||
|
|
||||||
|
Ruta runtime:
|
||||||
|
|
||||||
|
```text
|
||||||
|
/srv/mesavault/edge-oee-ucepsa/services/order-report-api
|
||||||
|
```
|
||||||
|
|
||||||
|
Archivos principales:
|
||||||
|
|
||||||
|
```text
|
||||||
|
services/order-report-api/Dockerfile
|
||||||
|
services/order-report-api/requirements.txt
|
||||||
|
services/order-report-api/app/main.py
|
||||||
|
services/order-report-api/app/templates/order_report.html
|
||||||
|
services/order-report-api/app/templates/orders_index.html
|
||||||
|
services/order-report-api/app/templates/cost_readiness.html
|
||||||
|
services/order-report-api/.env.local
|
||||||
|
```
|
||||||
|
|
||||||
|
El archivo `.env.local` contiene credenciales y no debe versionarse.
|
||||||
|
|
||||||
|
## 6. Ubicación en Gitea
|
||||||
|
|
||||||
|
Repositorio local:
|
||||||
|
|
||||||
|
```text
|
||||||
|
/srv/mesavault/40-clients
|
||||||
|
```
|
||||||
|
|
||||||
|
Ruta del proyecto:
|
||||||
|
|
||||||
|
```text
|
||||||
|
ucepsa/edge-oee-demo/
|
||||||
|
```
|
||||||
|
|
||||||
|
Archivos versionados relacionados:
|
||||||
|
|
||||||
|
```text
|
||||||
|
ucepsa/edge-oee-demo/services/order-report-api/
|
||||||
|
ucepsa/edge-oee-demo/deploy/ucepsa-local/docker-compose.order-report.yml
|
||||||
|
ucepsa/edge-oee-demo/sql/reports/070_order_execution_report_views.sql
|
||||||
|
ucepsa/edge-oee-demo/sql/reports/080_margin_intelligence_cost_readiness.sql
|
||||||
|
ucepsa/edge-oee-demo/docs/runbooks/080_order_report_margin_cost_readiness.md
|
||||||
|
```
|
||||||
|
|
||||||
|
## 7. Vistas SQL principales
|
||||||
|
|
||||||
|
### 7.1. Informe técnico de orden
|
||||||
|
|
||||||
|
```text
|
||||||
|
mv_reports_ucepsa_demo.order_execution_report_v1
|
||||||
|
mv_reports_ucepsa_demo.order_execution_stops_v1
|
||||||
|
mv_reports_ucepsa_demo.order_execution_stop_causes_v1
|
||||||
|
mv_reports_ucepsa_demo.order_execution_timeline_v1
|
||||||
|
mv_reports_ucepsa_demo.order_execution_energy_timeseries_v1
|
||||||
|
mv_reports_ucepsa_demo.order_execution_data_quality_v1
|
||||||
|
mv_reports_ucepsa_demo.order_execution_report_full_v1
|
||||||
|
```
|
||||||
|
|
||||||
|
### 7.2. Margin Intelligence
|
||||||
|
|
||||||
|
```text
|
||||||
|
mv_edge_oee_ucepsa_demo.odoo_order_commercials
|
||||||
|
mv_edge_oee_ucepsa_demo.margin_cost_parameters
|
||||||
|
mv_edge_oee_ucepsa_demo.raw_material_cost_parameters
|
||||||
|
mv_reports_ucepsa_demo.order_raw_material_costs_v1
|
||||||
|
mv_reports_ucepsa_demo.order_raw_material_costs_summary_v1
|
||||||
|
mv_reports_ucepsa_demo.order_margin_report_v1
|
||||||
|
```
|
||||||
|
|
||||||
|
### 7.3. Cost Readiness
|
||||||
|
|
||||||
|
```text
|
||||||
|
mv_reports_ucepsa_demo.cost_readiness_orders_v1
|
||||||
|
mv_reports_ucepsa_demo.cost_readiness_raw_materials_v1
|
||||||
|
mv_reports_ucepsa_demo.cost_readiness_summary_v1
|
||||||
|
```
|
||||||
|
|
||||||
|
## 8. Orden piloto validada
|
||||||
|
|
||||||
|
Orden:
|
||||||
|
|
||||||
|
```text
|
||||||
|
WH/MO/00025
|
||||||
|
```
|
||||||
|
|
||||||
|
Pedido de venta:
|
||||||
|
|
||||||
|
```text
|
||||||
|
S00011
|
||||||
|
```
|
||||||
|
|
||||||
|
Cliente:
|
||||||
|
|
||||||
|
```text
|
||||||
|
FRAJUSEMA, S.L.
|
||||||
|
```
|
||||||
|
|
||||||
|
Producto:
|
||||||
|
|
||||||
|
```text
|
||||||
|
[220365] BOLSA BD DE 60x90 cms. G80 TRANSPARENTE
|
||||||
|
```
|
||||||
|
|
||||||
|
Resultado técnico validado:
|
||||||
|
|
||||||
|
```text
|
||||||
|
Duración: 35,83 min
|
||||||
|
Tiempo en marcha: 33,48 min
|
||||||
|
Tiempo parado: 2,35 min
|
||||||
|
Energía: 0,029 kWh
|
||||||
|
Ciclos reales: 63
|
||||||
|
Paros: 2
|
||||||
|
Coste paros: 1,41 €
|
||||||
|
Calidad técnica: FIABLE
|
||||||
|
```
|
||||||
|
|
||||||
|
Resultado económico actual:
|
||||||
|
|
||||||
|
```text
|
||||||
|
Ingreso neto: 1154,80 €
|
||||||
|
Materia prima: [120727] TUBO BD DE 60 cms. G80 TRANSPARENTE
|
||||||
|
Cantidad consumida: 138 kg
|
||||||
|
Coste materia prima: pendiente
|
||||||
|
Margen estimado: no calculable todavía
|
||||||
|
Motivo: falta coste €/kg de materia prima 120727
|
||||||
|
```
|
||||||
|
|
||||||
|
## 9. Criterio de margen
|
||||||
|
|
||||||
|
El sistema no calcula margen final cuando falta coste de materia prima.
|
||||||
|
|
||||||
|
Estados relevantes:
|
||||||
|
|
||||||
|
```text
|
||||||
|
REVENUE_LINKED
|
||||||
|
NO_REVENUE_LINKED
|
||||||
|
MISSING_RAW_MATERIAL_COST_PARAMETER
|
||||||
|
PARTIAL_MARGIN_MISSING_RAW_MATERIAL_COST
|
||||||
|
ESTIMATED_MARGIN_AVAILABLE
|
||||||
|
```
|
||||||
|
|
||||||
|
La decisión operativa actual es bloquear el margen estimado hasta que UCEPSA cargue costes reales de materia prima en Odoo o valide costes manuales por kg.
|
||||||
|
|
||||||
|
## 10. Cost Readiness Cockpit
|
||||||
|
|
||||||
|
El cockpit muestra:
|
||||||
|
|
||||||
|
- órdenes con ingreso;
|
||||||
|
- órdenes sin ingreso;
|
||||||
|
- órdenes con materia prima;
|
||||||
|
- órdenes sin materia prima;
|
||||||
|
- órdenes con coste material pendiente;
|
||||||
|
- materias primas que bloquean cálculo de margen;
|
||||||
|
- clientes y pedidos afectados;
|
||||||
|
- score de readiness económico.
|
||||||
|
|
||||||
|
Bloqueo principal actual:
|
||||||
|
|
||||||
|
```text
|
||||||
|
Falta coste €/kg para 120727 - [120727] TUBO BD DE 60 cms. G80 TRANSPARENTE
|
||||||
|
```
|
||||||
|
|
||||||
|
## 11. Comandos de validación
|
||||||
|
|
||||||
|
Resumen readiness:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
docker exec -i mv_ucepsa_postgres_hot \
|
||||||
|
sh -lc 'psql -U "$POSTGRES_USER" -d "$POSTGRES_DB"' <<'SQL'
|
||||||
|
SELECT *
|
||||||
|
FROM mv_reports_ucepsa_demo.cost_readiness_summary_v1;
|
||||||
|
SQL
|
||||||
|
```
|
||||||
|
|
||||||
|
Readiness por orden:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
docker exec -i mv_ucepsa_postgres_hot \
|
||||||
|
sh -lc 'psql -U "$POSTGRES_USER" -d "$POSTGRES_DB"' <<'SQL'
|
||||||
|
SELECT
|
||||||
|
odoo_order_ref,
|
||||||
|
sale_order_ref,
|
||||||
|
customer_name,
|
||||||
|
product_default_code,
|
||||||
|
revenue_net_eur,
|
||||||
|
cost_readiness_score,
|
||||||
|
cost_readiness_status,
|
||||||
|
cost_readiness_description
|
||||||
|
FROM mv_reports_ucepsa_demo.cost_readiness_orders_v1
|
||||||
|
ORDER BY cost_readiness_score ASC;
|
||||||
|
SQL
|
||||||
|
```
|
||||||
|
|
||||||
|
Materias primas pendientes:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
docker exec -i mv_ucepsa_postgres_hot \
|
||||||
|
sh -lc 'psql -U "$POSTGRES_USER" -d "$POSTGRES_DB"' <<'SQL'
|
||||||
|
SELECT
|
||||||
|
raw_product_default_code,
|
||||||
|
raw_product_name,
|
||||||
|
affected_orders,
|
||||||
|
affected_customers,
|
||||||
|
total_consumed_qty,
|
||||||
|
raw_material_readiness_status,
|
||||||
|
recommended_action
|
||||||
|
FROM mv_reports_ucepsa_demo.cost_readiness_raw_materials_v1
|
||||||
|
ORDER BY affected_order_count DESC;
|
||||||
|
SQL
|
||||||
|
```
|
||||||
|
|
||||||
|
Informe de margen:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
docker exec -i mv_ucepsa_postgres_hot \
|
||||||
|
sh -lc 'psql -U "$POSTGRES_USER" -d "$POSTGRES_DB"' <<'SQL'
|
||||||
|
SELECT
|
||||||
|
odoo_order_ref,
|
||||||
|
sale_order_ref,
|
||||||
|
customer_name,
|
||||||
|
revenue_net_eur,
|
||||||
|
raw_materials_summary,
|
||||||
|
material_cost_estimated_eur,
|
||||||
|
material_cost_source_status,
|
||||||
|
estimated_margin_eur,
|
||||||
|
estimated_margin_pct,
|
||||||
|
margin_status
|
||||||
|
FROM mv_reports_ucepsa_demo.order_margin_report_v1
|
||||||
|
WHERE odoo_order_ref = 'WH/MO/00025';
|
||||||
|
SQL
|
||||||
|
```
|
||||||
|
|
||||||
|
## 12. Cómo activar margen estimado en el futuro
|
||||||
|
|
||||||
|
Cuando UCEPSA tenga el coste real por kg del tubo `120727`, se podrá insertar en:
|
||||||
|
|
||||||
|
```text
|
||||||
|
mv_edge_oee_ucepsa_demo.raw_material_cost_parameters
|
||||||
|
```
|
||||||
|
|
||||||
|
Plantilla:
|
||||||
|
|
||||||
|
```sql
|
||||||
|
INSERT INTO mv_edge_oee_ucepsa_demo.raw_material_cost_parameters (
|
||||||
|
tenant,
|
||||||
|
site,
|
||||||
|
raw_product_default_code,
|
||||||
|
raw_product_name,
|
||||||
|
material_cost_eur_per_kg,
|
||||||
|
source,
|
||||||
|
notes
|
||||||
|
)
|
||||||
|
VALUES (
|
||||||
|
'ucepsa',
|
||||||
|
'ucepsa_test',
|
||||||
|
'120727',
|
||||||
|
'[120727] TUBO BD DE 60 cms. G80 TRANSPARENTE',
|
||||||
|
COSTE_REAL_EUR_KG,
|
||||||
|
'MANUAL_UCEPSA',
|
||||||
|
'Coste validado por UCEPSA para Margin Intelligence'
|
||||||
|
);
|
||||||
|
```
|
||||||
|
|
||||||
|
Tras insertar ese coste, `order_margin_report_v1` empezará a calcular:
|
||||||
|
|
||||||
|
```text
|
||||||
|
material_cost_estimated_eur
|
||||||
|
estimated_margin_eur
|
||||||
|
estimated_margin_pct
|
||||||
|
```
|
||||||
|
|
||||||
|
## 13. Riesgos y advertencias
|
||||||
|
|
||||||
|
No presentar `partial_margin_after_known_costs_eur` como margen real.
|
||||||
|
|
||||||
|
No introducir costes aproximados sin fuente validada.
|
||||||
|
|
||||||
|
No mezclar órdenes de prueba con datos productivos sin señalarlas.
|
||||||
|
|
||||||
|
No usar informes bloqueados por falta de coste como base para pricing.
|
||||||
|
|
||||||
|
No interpretar `qty_planned`, `qty_done` o ciclos como unidades comerciales hasta validar conversiones.
|
||||||
|
|
||||||
|
## 14. Estado actual
|
||||||
|
|
||||||
|
Estado funcional:
|
||||||
|
|
||||||
|
```text
|
||||||
|
Order Execution Report: operativo
|
||||||
|
Informe HTML/PDF: operativo
|
||||||
|
Margin Intelligence: operativo en modo parcial
|
||||||
|
Cost Readiness Cockpit: operativo
|
||||||
|
Margen estimado final: bloqueado hasta coste de materia prima
|
||||||
|
```
|
||||||
|
|
||||||
|
Snapshot recomendado:
|
||||||
|
|
||||||
|
```text
|
||||||
|
order-report-margin-cost-readiness-v01-ok
|
||||||
|
```
|
||||||
|
|
||||||
|
## 15. Accesos rápidos
|
||||||
|
|
||||||
|
```text
|
||||||
|
Orders:
|
||||||
|
http://17.126.1.70:8090/orders.html
|
||||||
|
|
||||||
|
Cost Readiness:
|
||||||
|
http://17.126.1.70:8090/cost-readiness.html
|
||||||
|
|
||||||
|
Informe HTML WH/MO/00025:
|
||||||
|
http://17.126.1.70:8090/report.html?order_ref=WH%2FMO%2F00025
|
||||||
|
|
||||||
|
Informe PDF WH/MO/00025:
|
||||||
|
http://17.126.1.70:8090/report.pdf?order_ref=WH%2FMO%2F00025
|
||||||
|
```
|
||||||
|
|
||||||
@ -0,0 +1,120 @@
|
|||||||
|
# UCEPSA — Despliegue LossIntelligence en modo sombra hasta octubre de 2026
|
||||||
|
|
||||||
|
## Objetivo
|
||||||
|
|
||||||
|
Construir y validar entre julio y septiembre la infraestructura reusable de MESAVAULT LossIntelligence sin convertir todavía los paros técnicos en pérdidas oficiales. En octubre se realizará el kickoff y se activará el Ledger con una fecha efectiva acordada.
|
||||||
|
|
||||||
|
## Regla de seguridad funcional
|
||||||
|
|
||||||
|
La captura raw no se modifica:
|
||||||
|
|
||||||
|
```text
|
||||||
|
WISE / RevPi / MQTT / PostgreSQL
|
||||||
|
-> evento físico
|
||||||
|
-> paro raw
|
||||||
|
```
|
||||||
|
|
||||||
|
LossIntelligence trabaja encima:
|
||||||
|
|
||||||
|
```text
|
||||||
|
paro raw
|
||||||
|
-> contexto Odoo histórico
|
||||||
|
-> política de elegibilidad
|
||||||
|
-> exclusión / agrupación / candidato
|
||||||
|
-> Loss Ledger
|
||||||
|
-> acción
|
||||||
|
-> prueba
|
||||||
|
-> resultado
|
||||||
|
```
|
||||||
|
|
||||||
|
Un paro raw no equivale automáticamente a pérdida.
|
||||||
|
|
||||||
|
## Estados
|
||||||
|
|
||||||
|
- `SHADOW`: infraestructura activa, resultados solo para validación interna.
|
||||||
|
- `ACTIVE`: Ledger oficial desde `actual_go_live_at`.
|
||||||
|
- `PAUSED`: captura conservada, promoción de nuevas pérdidas detenida.
|
||||||
|
- `CLOSED`: despliegue cerrado.
|
||||||
|
|
||||||
|
Octubre es una fecha prevista, no una activación automática. La activación requiere kickoff y actualización explícita de `actual_go_live_at`.
|
||||||
|
|
||||||
|
## Parámetros iniciales
|
||||||
|
|
||||||
|
- Coste de capacidad: `36 €/h`, provisional y pendiente de aprobación.
|
||||||
|
- Coste eléctrico variable: `0,128249 €/kWh`, derivado de la factura 6.1TD de junio de 2026.
|
||||||
|
- Bolsas por golpe: `1` o `2` según referencia. Debe configurarse por `product_id`.
|
||||||
|
- Cadencia objetivo: pendiente por referencia o familia. Hasta completarla no se activa pérdida por baja cadencia.
|
||||||
|
|
||||||
|
## Política inicial de paros
|
||||||
|
|
||||||
|
| Duración | Tratamiento en sombra |
|
||||||
|
|---|---|
|
||||||
|
| `<5 s` | Se conserva en raw y se excluye de la cola humana. |
|
||||||
|
| `5 s–5 min` | Se agrupa por máquina, orden, ventana de 15 min y causa. |
|
||||||
|
| `>=5 min` | Se mantiene como candidato individual. |
|
||||||
|
| Paro abierto | Solo monitorización; no entra en Ledger. |
|
||||||
|
| Sin orden Odoo válida y coherente | Excluido. |
|
||||||
|
| Antes del go-live | Sombra, nunca pérdida oficial. |
|
||||||
|
|
||||||
|
Los umbrales deben validarse con producción antes del kickoff.
|
||||||
|
|
||||||
|
## Rutina de revisión futura
|
||||||
|
|
||||||
|
La reunión diaria de 15–30 minutos entre dirección y jefe de producción será el punto de revisión. La cola oficial se limitará a cinco elementos:
|
||||||
|
|
||||||
|
1. pérdidas nuevas con mayor prioridad;
|
||||||
|
2. acciones cuyo plazo vence;
|
||||||
|
3. pruebas en curso que requieren decisión;
|
||||||
|
4. bloqueos de calidad de dato;
|
||||||
|
5. resultados que deben mantenerse, adaptarse, repetirse, descartarse o escalarse.
|
||||||
|
|
||||||
|
## Orden de implantación
|
||||||
|
|
||||||
|
1. Crear rama de trabajo en Gitea.
|
||||||
|
2. Tomar backup de PostgreSQL.
|
||||||
|
3. Aplicar migraciones 093–096.
|
||||||
|
4. Validar que la cola oficial está vacía en modo `SHADOW`.
|
||||||
|
5. Actualizar el sink de `order_state` para guardar campos tipados y un hash histórico completo.
|
||||||
|
6. Observar datos durante al menos dos semanas.
|
||||||
|
7. Completar bolsas por golpe de las referencias activas.
|
||||||
|
8. Validar política de microparos y causas con producción.
|
||||||
|
9. Construir dashboard de sombra y panel de calidad de dato.
|
||||||
|
10. Preparar checklist de kickoff.
|
||||||
|
11. En octubre, acordar `actual_go_live_at` y cambiar `mode` a `ACTIVE`.
|
||||||
|
|
||||||
|
## Kill switch
|
||||||
|
|
||||||
|
Para detener la promoción oficial sin tocar la captura:
|
||||||
|
|
||||||
|
```sql
|
||||||
|
UPDATE mv_loss_intelligence.deployment_config
|
||||||
|
SET mode = 'PAUSED', updated_at = now()
|
||||||
|
WHERE tenant = 'ucepsa'
|
||||||
|
AND site = 'ucepsa_onpremise'
|
||||||
|
AND scope_code = 'CUTTERS';
|
||||||
|
```
|
||||||
|
|
||||||
|
## Activación en kickoff
|
||||||
|
|
||||||
|
Ejemplo; sustituir fecha y hora por las acordadas:
|
||||||
|
|
||||||
|
```sql
|
||||||
|
UPDATE mv_loss_intelligence.deployment_config
|
||||||
|
SET
|
||||||
|
mode = 'ACTIVE',
|
||||||
|
actual_go_live_at = TIMESTAMPTZ '2026-10-05 06:00:00+02',
|
||||||
|
updated_at = now()
|
||||||
|
WHERE tenant = 'ucepsa'
|
||||||
|
AND site = 'ucepsa_onpremise'
|
||||||
|
AND scope_code = 'CUTTERS';
|
||||||
|
```
|
||||||
|
|
||||||
|
La activación no debe hacerse antes de confirmar:
|
||||||
|
|
||||||
|
- Shop Floor estable;
|
||||||
|
- órdenes correctamente cargadas por máquina;
|
||||||
|
- causas y umbrales aceptados;
|
||||||
|
- coste/hora revisado;
|
||||||
|
- productos activos con bolsas por golpe;
|
||||||
|
- responsables y reunión diaria confirmados;
|
||||||
|
- backup y rollback disponibles.
|
||||||
@ -0,0 +1,56 @@
|
|||||||
|
# UCEPSA LossIntelligence SHADOW v0.2 — elegibilidad power-aware
|
||||||
|
|
||||||
|
## Problema corregido
|
||||||
|
|
||||||
|
En UCEPSA, cuando una cortadora no tiene trabajo se corta su alimentación. El último evento puede ser `machine_stopped` y el paro raw permanece abierto o se cierra varios días después. Esa duración física no equivale a pérdida.
|
||||||
|
|
||||||
|
La v0.2 calcula tiempo elegible mediante la intersección:
|
||||||
|
|
||||||
|
```text
|
||||||
|
paro raw
|
||||||
|
∩ intervalo de orden Odoo válida
|
||||||
|
∩ isla de telemetría real
|
||||||
|
= tiempo candidato a pérdida
|
||||||
|
```
|
||||||
|
|
||||||
|
Cuando desaparece la alimentación, dejan de llegar muestras y la isla de telemetría termina. El tiempo posterior no entra en LossIntelligence.
|
||||||
|
|
||||||
|
## Umbrales iniciales
|
||||||
|
|
||||||
|
- Frecuencia nominal de captura observada: alrededor de 5 s.
|
||||||
|
- Un hueco mayor de 90 s abre una nueva isla de telemetría.
|
||||||
|
- Se toleran 30 s después de la última muestra válida.
|
||||||
|
- Menos de 5 s sigue siendo ruido.
|
||||||
|
- Entre 5 s y 5 min se agrupa.
|
||||||
|
- A partir de 5 min se muestra individualmente.
|
||||||
|
|
||||||
|
Los valores son de modo sombra y deben revisarse antes del kickoff de octubre.
|
||||||
|
|
||||||
|
## Vistas nuevas
|
||||||
|
|
||||||
|
- `li_telemetry_coverage_v1`
|
||||||
|
- `li_stop_order_overlaps_v2`
|
||||||
|
- `li_stop_eligible_slices_v2`
|
||||||
|
- `li_stop_candidates_v2`
|
||||||
|
- `li_stop_exclusion_diagnostics_v2`
|
||||||
|
- `li_stop_groups_v2`
|
||||||
|
- `li_shadow_review_queue_v2`
|
||||||
|
- `li_operator_stop_queue_v2`
|
||||||
|
- `li_shadow_summary_v2`
|
||||||
|
- `li_eligibility_comparison_v1`
|
||||||
|
- `machine_operational_state_v1`
|
||||||
|
- `li_data_quality_v2`
|
||||||
|
|
||||||
|
## Regla de interfaz
|
||||||
|
|
||||||
|
Una máquina sin telemetría y sin orden debe mostrarse como `SIN PRODUCCIÓN`, no como `NO OK`.
|
||||||
|
|
||||||
|
Estados que sí requieren atención:
|
||||||
|
|
||||||
|
- máquina en marcha sin orden válida;
|
||||||
|
- orden válida sin telemetría;
|
||||||
|
- telemetría inválida o comunicación fallida con orden activa.
|
||||||
|
|
||||||
|
## Activación
|
||||||
|
|
||||||
|
La v0.2 no cambia `deployment_config.mode`. Permanece `SHADOW` y la cola oficial v2 debe devolver cero filas.
|
||||||
@ -0,0 +1,93 @@
|
|||||||
|
# UCEPSA — Descubrimiento de sesiones Odoo Shop Floor v0.1
|
||||||
|
|
||||||
|
## Objetivo
|
||||||
|
|
||||||
|
Identificar, sin escribir en Odoo, qué registros y campos cambian cuando una operaria:
|
||||||
|
|
||||||
|
1. se identifica en Shop Floor;
|
||||||
|
2. inicia una orden en una cortadora;
|
||||||
|
3. pausa la orden;
|
||||||
|
4. la reanuda;
|
||||||
|
5. termina o abandona la orden;
|
||||||
|
6. es sustituida por otra operaria.
|
||||||
|
|
||||||
|
El resultado servirá para diseñar el publicador productivo de sesiones Shop Floor de MESAVAULT.
|
||||||
|
|
||||||
|
## Hipótesis técnica que se debe verificar
|
||||||
|
|
||||||
|
La fuente candidata principal es `mrp.workcenter.productivity`.
|
||||||
|
|
||||||
|
Una sesión activa debería aparecer como una fila con:
|
||||||
|
|
||||||
|
- `workorder_id`;
|
||||||
|
- `workcenter_id`;
|
||||||
|
- `date_start`;
|
||||||
|
- `date_end` vacío mientras sigue activa;
|
||||||
|
- `user_id` u otro campo de identidad;
|
||||||
|
- una pérdida/tipo compatible con tiempo productivo.
|
||||||
|
|
||||||
|
No se dará esta hipótesis por válida hasta observarla en UCEPSA PROD.
|
||||||
|
|
||||||
|
## Seguridad
|
||||||
|
|
||||||
|
La sonda:
|
||||||
|
|
||||||
|
- solo usa XML-RPC de lectura;
|
||||||
|
- no invoca `create`, `write`, `unlink` ni botones de Odoo;
|
||||||
|
- no publica MQTT;
|
||||||
|
- no modifica PostgreSQL;
|
||||||
|
- no altera balizas;
|
||||||
|
- no modifica Shop Floor.
|
||||||
|
|
||||||
|
## Secuencia recomendada
|
||||||
|
|
||||||
|
### 1. Descubrimiento estático
|
||||||
|
|
||||||
|
Ejecutar `--discover` y conservar la salida.
|
||||||
|
|
||||||
|
### 2. Instantánea inicial
|
||||||
|
|
||||||
|
Con CORT-00 sin una prueba en curso, ejecutar `--snapshot`.
|
||||||
|
|
||||||
|
### 3. Vigilancia
|
||||||
|
|
||||||
|
Iniciar `--watch` durante 30 minutos.
|
||||||
|
|
||||||
|
### 4. Acciones en Shop Floor
|
||||||
|
|
||||||
|
Anotar la hora local exacta de cada acción:
|
||||||
|
|
||||||
|
- T0: operaria se identifica;
|
||||||
|
- T1: abre CORT-00;
|
||||||
|
- T2: inicia la orden;
|
||||||
|
- T3: pausa;
|
||||||
|
- T4: reanuda;
|
||||||
|
- T5: finaliza;
|
||||||
|
- T6: otra operaria inicia o se incorpora, si el flujo lo permite.
|
||||||
|
|
||||||
|
Esperar al menos 20–30 segundos entre acciones para distinguirlas con claridad.
|
||||||
|
|
||||||
|
### 5. Cierre
|
||||||
|
|
||||||
|
Detener la sonda o esperar a que termine y copiar:
|
||||||
|
|
||||||
|
- salida de descubrimiento;
|
||||||
|
- instantánea inicial;
|
||||||
|
- JSONL de vigilancia;
|
||||||
|
- lista manual T0–T6;
|
||||||
|
- orden, producto y máquina utilizados.
|
||||||
|
|
||||||
|
## Criterios de aceptación
|
||||||
|
|
||||||
|
Se considera fuente oficial viable si se puede reconstruir de forma inequívoca:
|
||||||
|
|
||||||
|
- operaria;
|
||||||
|
- máquina;
|
||||||
|
- orden de trabajo;
|
||||||
|
- orden de fabricación;
|
||||||
|
- inicio;
|
||||||
|
- pausa/cierre;
|
||||||
|
- reanudación;
|
||||||
|
- relevo.
|
||||||
|
|
||||||
|
Debe confirmarse además que una sesión abierta tiene un estado detectable y que una pausa o fin deja una marca histórica, no solo un estado actual.
|
||||||
@ -0,0 +1,80 @@
|
|||||||
|
# UCEPSA — LossIntelligence Production Context SHADOW v0.3
|
||||||
|
|
||||||
|
## Decisión de arquitectura
|
||||||
|
|
||||||
|
Durante la migración conviven dos vías válidas:
|
||||||
|
|
||||||
|
1. Odoo Shop Floor, estándar preferido.
|
||||||
|
2. Producción legacy o manual autorizada.
|
||||||
|
|
||||||
|
`máquina en marcha + sin orden Odoo` no se convierte automáticamente en pérdida.
|
||||||
|
|
||||||
|
Primero se clasifica el contexto:
|
||||||
|
|
||||||
|
- ODOO_CONTEXT;
|
||||||
|
- LEGACY_AUTHORIZED;
|
||||||
|
- MANUAL_AUTHORIZED;
|
||||||
|
- MAINTENANCE;
|
||||||
|
- TEST_SETUP;
|
||||||
|
- UNCONFIRMED_CONTEXT;
|
||||||
|
- CONTEXT_CONFLICT.
|
||||||
|
|
||||||
|
## Protección de las balizas
|
||||||
|
|
||||||
|
Esta versión no modifica:
|
||||||
|
|
||||||
|
- `beacon_controller.py`;
|
||||||
|
- el publicador `order_state`;
|
||||||
|
- DO0 azul;
|
||||||
|
- DO1 rojo.
|
||||||
|
|
||||||
|
Azul fijo continúa significando exclusivamente orden Odoo válida.
|
||||||
|
|
||||||
|
Una producción legacy puede seguir provocando azul intermitente porque está fuera del
|
||||||
|
flujo Odoo, aunque MESAVAULT la reconozca como legacy autorizada.
|
||||||
|
|
||||||
|
## Flujo preventivo recomendado
|
||||||
|
|
||||||
|
Antes de producir por el ERP anterior, producción abre un contexto:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
python tools/li_context_admin.py open \
|
||||||
|
--machine CORT-02 \
|
||||||
|
--source LEGACY_ERP \
|
||||||
|
--external-order-ref LEGACY-1234 \
|
||||||
|
--product-name "Referencia legacy" \
|
||||||
|
--authorized-by "Juan Pablo" \
|
||||||
|
--notes "Producción autorizada fuera de Odoo durante migración"
|
||||||
|
```
|
||||||
|
|
||||||
|
Al finalizar:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
python tools/li_context_admin.py close \
|
||||||
|
--session-id 1 \
|
||||||
|
--closed-by "Juan Pablo"
|
||||||
|
```
|
||||||
|
|
||||||
|
## Flujo retrospectivo
|
||||||
|
|
||||||
|
Si no se abrió previamente, la vista `li_context_review_queue_v1` muestra un único
|
||||||
|
bloque físico. Tras confirmar el intervalo se crea una sesión retrospectiva.
|
||||||
|
|
||||||
|
## Seguridad SHADOW
|
||||||
|
|
||||||
|
Mientras `deployment_config.mode = SHADOW`, la herramienta fuerza:
|
||||||
|
|
||||||
|
```text
|
||||||
|
official_eligible = false
|
||||||
|
```
|
||||||
|
|
||||||
|
Por tanto, ningún contexto manual puede crear por sí solo una pérdida económica oficial.
|
||||||
|
|
||||||
|
## Qué no resuelve todavía
|
||||||
|
|
||||||
|
- La identidad activa de la operaria en el flujo personalizado de Shop Floor.
|
||||||
|
- El cierre real con declaración de producción.
|
||||||
|
- La extensión de los paros v2 para usar contexto legacy.
|
||||||
|
|
||||||
|
La siguiente subfase será `v0.3.1`: cruzar los paros con los contextos validados tras
|
||||||
|
comprobar que la apertura/cierre legacy funciona correctamente.
|
||||||
@ -0,0 +1,58 @@
|
|||||||
|
# UCEPSA — LossIntelligence Context-Aware Stops SHADOW v0.3.1
|
||||||
|
|
||||||
|
## Objetivo
|
||||||
|
|
||||||
|
Cruzar los paros físicos de WISE/RevPi con:
|
||||||
|
|
||||||
|
- cobertura válida de telemetría;
|
||||||
|
- contexto Odoo válido;
|
||||||
|
- producción legacy autorizada;
|
||||||
|
- producción manual autorizada.
|
||||||
|
|
||||||
|
## Seguridad
|
||||||
|
|
||||||
|
La migración 102 solo crea vistas paralelas.
|
||||||
|
|
||||||
|
No modifica:
|
||||||
|
|
||||||
|
- `li_stop_candidates_v2`;
|
||||||
|
- `li_stop_groups_v2`;
|
||||||
|
- `li_shadow_review_queue_v2`;
|
||||||
|
- `li_operator_stop_queue_v2`;
|
||||||
|
- `loss_ledger`;
|
||||||
|
- Odoo;
|
||||||
|
- RevPi;
|
||||||
|
- nodos WISE;
|
||||||
|
- balizas.
|
||||||
|
|
||||||
|
`official_ledger_eligible` queda forzado a `false`.
|
||||||
|
|
||||||
|
## Vistas nuevas
|
||||||
|
|
||||||
|
- `li_stop_context_slices_v1`
|
||||||
|
- `li_stop_candidates_context_v1`
|
||||||
|
- `li_stop_groups_context_v1`
|
||||||
|
- `li_shadow_review_queue_context_v1`
|
||||||
|
- `li_shadow_review_backlog_context_v1`
|
||||||
|
- `li_stop_context_diagnostics_v1`
|
||||||
|
|
||||||
|
## Comportamiento esperado durante la prueba
|
||||||
|
|
||||||
|
CORT-00:
|
||||||
|
|
||||||
|
- sesión legacy 1 abierta;
|
||||||
|
- un paro abierto se muestra como `OPEN_MONITORING`;
|
||||||
|
- al cerrar físicamente el paro, se convierte en candidato si supera el ruido.
|
||||||
|
|
||||||
|
CORT-02:
|
||||||
|
|
||||||
|
- `WH/MO/00081`;
|
||||||
|
- paros de 2–5 segundos deben quedar como ruido;
|
||||||
|
- paros superiores al umbral deben agruparse o mostrarse individualmente.
|
||||||
|
|
||||||
|
## Cola diaria
|
||||||
|
|
||||||
|
La cola diaria context-aware está limitada a las últimas 24 horas para evitar que
|
||||||
|
los falsos positivos históricos dominen la revisión.
|
||||||
|
|
||||||
|
El backlog histórico sigue disponible en una vista separada.
|
||||||
@ -0,0 +1,70 @@
|
|||||||
|
# UCEPSA — Hotfix historial de transiciones Odoo v0.3.2
|
||||||
|
|
||||||
|
## Causa confirmada
|
||||||
|
|
||||||
|
La tabla `mv_hot.odoo_order_state_events` tenía un índice único global:
|
||||||
|
|
||||||
|
```text
|
||||||
|
tenant + site + machine_id + payload_hash
|
||||||
|
```
|
||||||
|
|
||||||
|
Esto impedía representar una secuencia válida:
|
||||||
|
|
||||||
|
```text
|
||||||
|
A → B → A
|
||||||
|
```
|
||||||
|
|
||||||
|
El segundo `A` se descartaba aunque fuera una transición nueva en otro momento.
|
||||||
|
|
||||||
|
## Síntoma observado
|
||||||
|
|
||||||
|
CORT-02:
|
||||||
|
|
||||||
|
```text
|
||||||
|
WH/MO/00081-002
|
||||||
|
→ no_loaded_order
|
||||||
|
```
|
||||||
|
|
||||||
|
El estado `current` se actualizó correctamente, pero el evento histórico no se
|
||||||
|
insertó porque el hash de `no_loaded_order` ya existía de un hueco anterior.
|
||||||
|
|
||||||
|
## Corrección
|
||||||
|
|
||||||
|
- Se elimina la unicidad global por hash.
|
||||||
|
- El sink compara con el último evento de la máquina.
|
||||||
|
- Solo evita repeticiones consecutivas:
|
||||||
|
- A → A: no inserta;
|
||||||
|
- A → B: inserta B;
|
||||||
|
- A → B → A: inserta de nuevo A.
|
||||||
|
- CURRENT sigue refrescándose con cada mensaje.
|
||||||
|
- Se usa un advisory lock transaccional por máquina.
|
||||||
|
- Se repara la transición conocida de CORT-02 a las 15:51:03.861315589+02.
|
||||||
|
|
||||||
|
## Orden de despliegue obligatorio
|
||||||
|
|
||||||
|
1. Detener `mv_ucepsa_odoo_order_state_pg_sink`.
|
||||||
|
2. Aplicar migración 103.
|
||||||
|
3. Instalar el script corregido.
|
||||||
|
4. Arrancar el sink.
|
||||||
|
5. Ejecutar validación.
|
||||||
|
|
||||||
|
No se debe retirar el índice con el sink antiguo en funcionamiento, porque su
|
||||||
|
`ON CONFLICT` depende de ese índice.
|
||||||
|
|
||||||
|
## Alcance
|
||||||
|
|
||||||
|
No modifica:
|
||||||
|
|
||||||
|
- Odoo;
|
||||||
|
- publicador Odoo;
|
||||||
|
- MQTT;
|
||||||
|
- RevPi;
|
||||||
|
- WISE;
|
||||||
|
- balizas;
|
||||||
|
- tabla raw de paros;
|
||||||
|
- cola oficial;
|
||||||
|
- Loss Ledger.
|
||||||
|
|
||||||
|
La reparación conocida solo corrige el intervalo de WH/MO/00081-002.
|
||||||
|
Los huecos históricos anteriores que no puedan reconstruirse con evidencia deben
|
||||||
|
mantenerse con confianza reducida.
|
||||||
@ -0,0 +1,244 @@
|
|||||||
|
# UCEPSA — Odoo Shop Floor Sessions SHADOW v0.3.3
|
||||||
|
|
||||||
|
## Objetivo
|
||||||
|
|
||||||
|
Construir sesiones canónicas de producción a partir del flujo real de Odoo
|
||||||
|
Shop Floor sin pedir un segundo login a la operaria y sin alterar Odoo, balizas,
|
||||||
|
RevPi, WISE, la cola oficial ni el Loss Ledger.
|
||||||
|
|
||||||
|
## Evidencia base
|
||||||
|
|
||||||
|
### Workorder 87
|
||||||
|
|
||||||
|
```text
|
||||||
|
WH/MO/00081-001
|
||||||
|
CORT-02
|
||||||
|
11:48:31 → 13:48:58 hora local
|
||||||
|
operaria 43 / M. Angeles Martinez
|
||||||
|
85 kg contabilizados
|
||||||
|
```
|
||||||
|
|
||||||
|
La operaria está confirmada por dos filas de
|
||||||
|
`mrp.workcenter.productivity`.
|
||||||
|
|
||||||
|
### Workorder 810
|
||||||
|
|
||||||
|
```text
|
||||||
|
WH/MO/00081-002
|
||||||
|
CORT-02
|
||||||
|
14:01:56 → 15:50:59 hora local
|
||||||
|
candidata 20 / MARIA CIBÚ
|
||||||
|
76 kg candidatos
|
||||||
|
```
|
||||||
|
|
||||||
|
La persona y la cantidad quedan `PENDING_HUMAN`. El intervalo de sesión,
|
||||||
|
máquina, orden y workorder sí están confirmados.
|
||||||
|
|
||||||
|
## Alcance de v0.3.3
|
||||||
|
|
||||||
|
Crea:
|
||||||
|
|
||||||
|
- snapshot actual por workorder;
|
||||||
|
- eventos secuenciales por transición;
|
||||||
|
- sesión canónica por workorder;
|
||||||
|
- resolución de operaria con confianza;
|
||||||
|
- separación entre cantidad candidata y contabilizada;
|
||||||
|
- cola de revisión SHADOW;
|
||||||
|
- contexto paralelo `ODOO_SHOPFLOOR`;
|
||||||
|
- herramienta posterior de confirmación humana.
|
||||||
|
|
||||||
|
No cambia:
|
||||||
|
|
||||||
|
- `production_context_sessions`;
|
||||||
|
- vistas de candidatos de paro v0.3.1;
|
||||||
|
- cola oficial;
|
||||||
|
- Odoo;
|
||||||
|
- MQTT de balizas;
|
||||||
|
- RevPi;
|
||||||
|
- WISE;
|
||||||
|
- balizas.
|
||||||
|
|
||||||
|
La asociación de paros a las sesiones nuevas se hará en una versión posterior,
|
||||||
|
cuando la captura haya sido validada en operación continua.
|
||||||
|
|
||||||
|
## Reglas de operaria
|
||||||
|
|
||||||
|
1. Una única persona real en productividad:
|
||||||
|
- operaria confirmada;
|
||||||
|
- confianza alta.
|
||||||
|
|
||||||
|
2. Sin productividad y una única persona real asignada, excluyendo perfiles
|
||||||
|
técnicos:
|
||||||
|
- candidata;
|
||||||
|
- confianza media;
|
||||||
|
- confirmación humana pendiente.
|
||||||
|
|
||||||
|
3. Más de una persona real o ninguna evidencia:
|
||||||
|
- sin resolver;
|
||||||
|
- confianza baja.
|
||||||
|
|
||||||
|
`employee_id=35 / CORTADORAS` se excluye porque es el perfil técnico compartido
|
||||||
|
de la tablet.
|
||||||
|
|
||||||
|
## Reglas de cantidad
|
||||||
|
|
||||||
|
- Producción `done` y cantidad contabilizada:
|
||||||
|
`FINAL_POSTED`.
|
||||||
|
- Producción `to_close` con `qty_producing`:
|
||||||
|
`PROVISIONAL_TO_CLOSE`.
|
||||||
|
- La cantidad candidata no se transforma en cantidad confirmada sin evidencia
|
||||||
|
de stock final o confirmación humana.
|
||||||
|
|
||||||
|
## Tiempo
|
||||||
|
|
||||||
|
Los datetimes XML-RPC de Odoo se interpretan como UTC.
|
||||||
|
|
||||||
|
La sesión usa:
|
||||||
|
|
||||||
|
```text
|
||||||
|
mrp.workorder.date_start
|
||||||
|
mrp.workorder.date_finished
|
||||||
|
```
|
||||||
|
|
||||||
|
No usa `duration` como reloj de presencia.
|
||||||
|
|
||||||
|
## Cadena productiva
|
||||||
|
|
||||||
|
La clave se obtiene quitando el sufijo de backorder:
|
||||||
|
|
||||||
|
```text
|
||||||
|
WH/MO/00081-001 → ODOO:WH/MO/00081
|
||||||
|
WH/MO/00081-002 → ODOO:WH/MO/00081
|
||||||
|
```
|
||||||
|
|
||||||
|
`parent_production_id` y `child_production_ids` no se usan para los relevos,
|
||||||
|
porque en este caso representan una relación con la fabricación del tubo.
|
||||||
|
|
||||||
|
## Despliegue
|
||||||
|
|
||||||
|
### 1. Aplicar migración
|
||||||
|
|
||||||
|
```bash
|
||||||
|
FILE=/srv/mesavault/40-clients/ucepsa/edge-oee-demo/sql/versions/104_ucepsa_odoo_shopfloor_sessions_shadow_v033.sql
|
||||||
|
|
||||||
|
docker exec -i mv_ucepsa_postgres_hot sh -lc \
|
||||||
|
'psql -v ON_ERROR_STOP=1 -U "$POSTGRES_USER" -d "$POSTGRES_DB"' \
|
||||||
|
< "$FILE"
|
||||||
|
```
|
||||||
|
|
||||||
|
No es necesario detener servicios.
|
||||||
|
|
||||||
|
### 2. Instalar herramientas en runtime
|
||||||
|
|
||||||
|
```bash
|
||||||
|
REPO=/srv/mesavault/40-clients/ucepsa/edge-oee-demo
|
||||||
|
RUNTIME=/srv/mesavault/edge-oee-ucepsa
|
||||||
|
|
||||||
|
install -m 0755 \
|
||||||
|
"$REPO/tools/odoo_shopfloor_session_sync.py" \
|
||||||
|
"$RUNTIME/tools/odoo_shopfloor_session_sync.py"
|
||||||
|
|
||||||
|
install -m 0755 \
|
||||||
|
"$REPO/tools/odoo_shopfloor_session_admin.py" \
|
||||||
|
"$RUNTIME/tools/odoo_shopfloor_session_admin.py"
|
||||||
|
|
||||||
|
install -m 0755 \
|
||||||
|
"$REPO/ops/manage_odoo_shopfloor_sync_v033.sh" \
|
||||||
|
"$RUNTIME/ops/manage_odoo_shopfloor_sync_v033.sh"
|
||||||
|
```
|
||||||
|
|
||||||
|
### 3. Ensayo de los dos workorders conocidos
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd /srv/mesavault/edge-oee-ucepsa
|
||||||
|
|
||||||
|
ops/manage_odoo_shopfloor_sync_v033.sh dry-run-known
|
||||||
|
```
|
||||||
|
|
||||||
|
Debe mostrar:
|
||||||
|
|
||||||
|
```text
|
||||||
|
87 → operaria 43 / HIGH / 85 kg FINAL_POSTED
|
||||||
|
810 → candidata 20 / MEDIUM / 76 kg PROVISIONAL_TO_CLOSE
|
||||||
|
```
|
||||||
|
|
||||||
|
### 4. Backfill
|
||||||
|
|
||||||
|
```bash
|
||||||
|
ops/manage_odoo_shopfloor_sync_v033.sh backfill-known
|
||||||
|
```
|
||||||
|
|
||||||
|
### 5. Validación
|
||||||
|
|
||||||
|
```bash
|
||||||
|
VALIDATION=/srv/mesavault/40-clients/ucepsa/edge-oee-demo/ops/validate_odoo_shopfloor_sessions_shadow_v033.sql
|
||||||
|
|
||||||
|
docker exec -i mv_ucepsa_postgres_hot sh -lc \
|
||||||
|
'psql -v ON_ERROR_STOP=1 -U "$POSTGRES_USER" -d "$POSTGRES_DB"' \
|
||||||
|
< "$VALIDATION"
|
||||||
|
```
|
||||||
|
|
||||||
|
Resultados obligatorios:
|
||||||
|
|
||||||
|
```text
|
||||||
|
official_rows = 0
|
||||||
|
invalid_session_intervals = 0
|
||||||
|
known_backfill_ok = true
|
||||||
|
consecutive_duplicate_event_count = 0
|
||||||
|
generated_production_context_rows = 0
|
||||||
|
official_operator_queue_rows = 0
|
||||||
|
```
|
||||||
|
|
||||||
|
### 6. Activar sincronización continua
|
||||||
|
|
||||||
|
Solo después de validar el backfill:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd /srv/mesavault/edge-oee-ucepsa
|
||||||
|
ops/manage_odoo_shopfloor_sync_v033.sh start
|
||||||
|
ops/manage_odoo_shopfloor_sync_v033.sh status
|
||||||
|
docker logs --tail 100 mv_ucepsa_odoo_shopfloor_session_sync
|
||||||
|
```
|
||||||
|
|
||||||
|
## Confirmación posterior de Juan Pablo
|
||||||
|
|
||||||
|
No ejecutar hasta recibir confirmación.
|
||||||
|
|
||||||
|
### Confirmar operaria candidata
|
||||||
|
|
||||||
|
```bash
|
||||||
|
docker exec -i mv_ucepsa_odoo_order_state_pg_sink \
|
||||||
|
python tools/odoo_shopfloor_session_admin.py \
|
||||||
|
confirm-operator \
|
||||||
|
--workorder-id 810 \
|
||||||
|
--use-candidate \
|
||||||
|
--confirmed-by "Juan Pablo" \
|
||||||
|
--notes "Confirmado el segundo tramo de CORT-02."
|
||||||
|
```
|
||||||
|
|
||||||
|
### Confirmar cantidad
|
||||||
|
|
||||||
|
```bash
|
||||||
|
docker exec -i mv_ucepsa_odoo_order_state_pg_sink \
|
||||||
|
python tools/odoo_shopfloor_session_admin.py \
|
||||||
|
confirm-quantity \
|
||||||
|
--workorder-id 810 \
|
||||||
|
--quantity 76 \
|
||||||
|
--confirmed-by "Juan Pablo" \
|
||||||
|
--notes "Confirmados 76 kg declarados en el segundo tramo."
|
||||||
|
```
|
||||||
|
|
||||||
|
La sincronización posterior conserva las confirmaciones humanas.
|
||||||
|
|
||||||
|
## Parada y rollback operativo
|
||||||
|
|
||||||
|
Parar el sincronizador:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
/srv/mesavault/edge-oee-ucepsa/ops/manage_odoo_shopfloor_sync_v033.sh stop
|
||||||
|
```
|
||||||
|
|
||||||
|
La parada no afecta Odoo, captura de máquina, MQTT ni balizas.
|
||||||
|
|
||||||
|
Las tablas nuevas pueden mantenerse para diagnóstico. No se recomienda borrarlas
|
||||||
|
durante el piloto.
|
||||||
@ -0,0 +1,57 @@
|
|||||||
|
# UCEPSA — Guard READY/PENDING Shop Floor v0.3.3.1
|
||||||
|
|
||||||
|
## Hallazgo
|
||||||
|
|
||||||
|
Durante la primera sincronización continua apareció:
|
||||||
|
|
||||||
|
```text
|
||||||
|
workorder 434
|
||||||
|
state = ready
|
||||||
|
production_state = progress
|
||||||
|
date_start = 17/07/2026
|
||||||
|
date_finished = 04/09/2026
|
||||||
|
```
|
||||||
|
|
||||||
|
Odoo puede poblar esas fechas como planificación antes de que exista una sesión
|
||||||
|
real de operaria. La v0.3.3 las interpretó como intervalo y mostró 1.182 horas
|
||||||
|
de cobertura ficticia.
|
||||||
|
|
||||||
|
## Riesgo
|
||||||
|
|
||||||
|
El modo sigue siendo SHADOW y las sesiones aún no alimentan candidatos de paro,
|
||||||
|
por lo que no hubo impacto oficial. Aun así, un workorder `READY/PENDING` no debe
|
||||||
|
aportar:
|
||||||
|
|
||||||
|
- contexto productivo;
|
||||||
|
- horas de cobertura;
|
||||||
|
- atribución individual;
|
||||||
|
- cola de revisión de sesiones reales.
|
||||||
|
|
||||||
|
## Corrección
|
||||||
|
|
||||||
|
La v0.3.3.1:
|
||||||
|
|
||||||
|
1. conserva las fechas originales de Odoo en
|
||||||
|
`mv_hot.odoo_shopfloor_workorder_current`;
|
||||||
|
2. pone `started_at` y `ended_at` de la sesión a `NULL` mientras el estado no sea
|
||||||
|
`progress` o `done`;
|
||||||
|
3. exige `OPEN` o `CLOSED` para `context_time_usable`;
|
||||||
|
4. excluye `PENDING` de la cola de revisión;
|
||||||
|
5. excluye `PENDING` del cálculo de horas;
|
||||||
|
6. mantiene `official_eligible=false`.
|
||||||
|
|
||||||
|
## Orden de despliegue
|
||||||
|
|
||||||
|
1. Detener temporalmente el sincronizador.
|
||||||
|
2. Aplicar migración 105.
|
||||||
|
3. Instalar el sincronizador v0.3.3.1.
|
||||||
|
4. Refrescar explícitamente el workorder 434.
|
||||||
|
5. Validar.
|
||||||
|
6. Arrancar de nuevo el sincronizador.
|
||||||
|
|
||||||
|
## Observación de producción del lunes
|
||||||
|
|
||||||
|
Cuando 434 u otro workorder pase de `ready` a `progress`, el sincronizador
|
||||||
|
registrará la transición. Después debe comprobarse si Odoo actualiza
|
||||||
|
`date_start` al inicio real o conserva la fecha planificada. Esa observación
|
||||||
|
determinará la regla definitiva de inicio para v0.3.4.
|
||||||
@ -0,0 +1,126 @@
|
|||||||
|
# UCEPSA — Shop Floor Stop Attribution SHADOW v0.3.4
|
||||||
|
|
||||||
|
## Objetivo
|
||||||
|
|
||||||
|
Cruzar en paralelo:
|
||||||
|
|
||||||
|
```text
|
||||||
|
paro físico
|
||||||
|
∩ telemetría válida
|
||||||
|
∩ sesión Odoo Shop Floor OPEN/CLOSED
|
||||||
|
```
|
||||||
|
|
||||||
|
La versión no reemplaza las vistas context-aware v0.3.1 y no alimenta la cola
|
||||||
|
oficial.
|
||||||
|
|
||||||
|
## Evidencia que permite avanzar
|
||||||
|
|
||||||
|
La sesión real `workorder 340 / WH/MO/00352` fue observada:
|
||||||
|
|
||||||
|
```text
|
||||||
|
Odoo date_start: 07:36:19
|
||||||
|
Primera observación MESAVAULT: 07:36:27
|
||||||
|
Operario confirmado después: Esteban Vaquerizo García
|
||||||
|
```
|
||||||
|
|
||||||
|
El inicio original se conservó aunque la identidad apareció más tarde.
|
||||||
|
|
||||||
|
CORT-00, por el contrario, registró actividad física sin workorder Odoo en
|
||||||
|
`progress`; debe continuar bloqueada como ausencia de sesión Shop Floor.
|
||||||
|
|
||||||
|
## Nuevas vistas
|
||||||
|
|
||||||
|
- `li_stop_shopfloor_slices_v1`
|
||||||
|
- `li_stop_shopfloor_candidates_v1`
|
||||||
|
- `li_shadow_review_queue_shopfloor_v1`
|
||||||
|
- `li_shopfloor_session_stop_summary_v1`
|
||||||
|
- `li_stop_shopfloor_diagnostics_v1`
|
||||||
|
|
||||||
|
## Reglas
|
||||||
|
|
||||||
|
Un tramo solo puede ser candidato cuando:
|
||||||
|
|
||||||
|
1. el paro está cerrado;
|
||||||
|
2. existe exactamente una sesión Shop Floor;
|
||||||
|
3. existe telemetría válida;
|
||||||
|
4. supera el umbral de ruido.
|
||||||
|
|
||||||
|
La atribución temporal y la atribución individual son independientes:
|
||||||
|
|
||||||
|
```text
|
||||||
|
sesión válida + operario confirmado
|
||||||
|
→ CONFIRMED_OPERATOR
|
||||||
|
|
||||||
|
sesión válida + persona candidata
|
||||||
|
→ PENDING_OPERATOR_CANDIDATE
|
||||||
|
|
||||||
|
sesión válida sin persona
|
||||||
|
→ UNRESOLVED_OPERATOR
|
||||||
|
```
|
||||||
|
|
||||||
|
Los paros pueden analizarse por sesión aunque todavía no exista atribución
|
||||||
|
individual.
|
||||||
|
|
||||||
|
## Alcance económico
|
||||||
|
|
||||||
|
`shadow_capacity_loss_eur` es exposición provisional de capacidad usando el
|
||||||
|
parámetro de 36 €/h cuando proceda. No es una pérdida aprobada.
|
||||||
|
|
||||||
|
## Garantías
|
||||||
|
|
||||||
|
- `official_ledger_eligible=false`
|
||||||
|
- no modifica Odoo;
|
||||||
|
- no modifica sesiones;
|
||||||
|
- no modifica paros raw;
|
||||||
|
- no modifica balizas;
|
||||||
|
- no modifica RevPi ni WISE;
|
||||||
|
- no sustituye v0.3.1;
|
||||||
|
- no alimenta `li_operator_stop_queue_v2`.
|
||||||
|
|
||||||
|
## Despliegue
|
||||||
|
|
||||||
|
No es necesario detener servicios.
|
||||||
|
|
||||||
|
```bash
|
||||||
|
FILE=/srv/mesavault/40-clients/ucepsa/edge-oee-demo/sql/versions/106_ucepsa_shopfloor_stop_attribution_shadow_v034.sql
|
||||||
|
|
||||||
|
docker exec -i mv_ucepsa_postgres_hot sh -lc \
|
||||||
|
'psql -v ON_ERROR_STOP=1 -U "$POSTGRES_USER" -d "$POSTGRES_DB"' \
|
||||||
|
< "$FILE"
|
||||||
|
```
|
||||||
|
|
||||||
|
## Validación
|
||||||
|
|
||||||
|
```bash
|
||||||
|
VALIDATION=/srv/mesavault/40-clients/ucepsa/edge-oee-demo/ops/validate_shopfloor_stop_attribution_shadow_v034.sql
|
||||||
|
|
||||||
|
docker exec -i mv_ucepsa_postgres_hot sh -lc \
|
||||||
|
'psql -v ON_ERROR_STOP=1 -U "$POSTGRES_USER" -d "$POSTGRES_DB"' \
|
||||||
|
< "$VALIDATION"
|
||||||
|
```
|
||||||
|
|
||||||
|
Valores obligatorios:
|
||||||
|
|
||||||
|
```text
|
||||||
|
invalid_slice_intervals = 0
|
||||||
|
negative_slice_durations = 0
|
||||||
|
duration_overflow_count = 0
|
||||||
|
open_stops_marked_eligible = 0
|
||||||
|
cort00_attributed_rows = 0
|
||||||
|
official_candidate_rows = 0
|
||||||
|
official_operator_queue_rows = 0
|
||||||
|
```
|
||||||
|
|
||||||
|
La consulta de la sesión 340 debe devolver paros de CORT-02 atribuidos al
|
||||||
|
workorder y al operario Esteban, siempre que existan paros cerrados posteriores
|
||||||
|
a las 07:36:19.
|
||||||
|
|
||||||
|
## Siguiente observación
|
||||||
|
|
||||||
|
Cuando el workorder 340 se cierre:
|
||||||
|
|
||||||
|
- la sesión debe pasar `OPEN → CLOSED`;
|
||||||
|
- debe conservar el inicio 07:36:19;
|
||||||
|
- debe obtener un `ended_at` real;
|
||||||
|
- deben resolverse cantidad y posible continuación;
|
||||||
|
- los paros posteriores al cierre deben quedar fuera de la sesión.
|
||||||
@ -0,0 +1,94 @@
|
|||||||
|
# UCEPSA — Semántica del resumen de paros Shop Floor v0.3.4.1
|
||||||
|
|
||||||
|
## Hallazgo
|
||||||
|
|
||||||
|
La validación de v0.3.4 mostró para el workorder 340:
|
||||||
|
|
||||||
|
```text
|
||||||
|
physical_stop_count = 31
|
||||||
|
eligible_stop_count = 28
|
||||||
|
eligible_stop_minutes = 55,25
|
||||||
|
```
|
||||||
|
|
||||||
|
El diagnóstico descomponía esos 55 minutos en:
|
||||||
|
|
||||||
|
```text
|
||||||
|
CANDIDATE_GROUP 32,88 min
|
||||||
|
CANDIDATE_INDIVIDUAL 17,51 min
|
||||||
|
EXCLUDED_NOISE 0,11 min
|
||||||
|
OPEN_MONITORING 4,81 min
|
||||||
|
```
|
||||||
|
|
||||||
|
La vista de resumen sumaba todos los minutos observados en la columna
|
||||||
|
`eligible_stop_minutes`, aunque el importe económico sí excluía correctamente
|
||||||
|
el ruido y el paro abierto.
|
||||||
|
|
||||||
|
## Corrección
|
||||||
|
|
||||||
|
`eligible_stop_minutes` pasa a incluir únicamente filas con:
|
||||||
|
|
||||||
|
```text
|
||||||
|
technically_eligible = true
|
||||||
|
```
|
||||||
|
|
||||||
|
Se añaden columnas separadas:
|
||||||
|
|
||||||
|
- `observed_stop_minutes`
|
||||||
|
- `telemetry_covered_stop_minutes`
|
||||||
|
- `candidate_group_minutes`
|
||||||
|
- `candidate_individual_minutes`
|
||||||
|
- `excluded_noise_minutes`
|
||||||
|
- `open_monitoring_minutes`
|
||||||
|
- `blocked_minutes`
|
||||||
|
|
||||||
|
No se modifica la vista de candidatos, la cola SHADOW, la cola oficial ni los
|
||||||
|
datos raw.
|
||||||
|
|
||||||
|
## Resultado esperado para la sesión 340
|
||||||
|
|
||||||
|
A la hora de la validación inicial, aproximadamente:
|
||||||
|
|
||||||
|
```text
|
||||||
|
observed_stop_minutes 55,3
|
||||||
|
eligible_stop_minutes 50,4
|
||||||
|
candidate_group_minutes 32,9
|
||||||
|
candidate_individual 17,5
|
||||||
|
excluded_noise 0,1
|
||||||
|
open_monitoring 4,8
|
||||||
|
shadow_capacity_loss_eur 30,2
|
||||||
|
```
|
||||||
|
|
||||||
|
El paro abierto puede seguir creciendo hasta que la máquina vuelva a arrancar,
|
||||||
|
por lo que los valores exactos pueden variar.
|
||||||
|
|
||||||
|
## Despliegue
|
||||||
|
|
||||||
|
No es necesario detener servicios.
|
||||||
|
|
||||||
|
```bash
|
||||||
|
FILE=/srv/mesavault/40-clients/ucepsa/edge-oee-demo/sql/versions/107_fix_shopfloor_stop_summary_semantics_v0341.sql
|
||||||
|
|
||||||
|
docker exec -i mv_ucepsa_postgres_hot sh -lc \
|
||||||
|
'psql -v ON_ERROR_STOP=1 -U "$POSTGRES_USER" -d "$POSTGRES_DB"' \
|
||||||
|
< "$FILE"
|
||||||
|
```
|
||||||
|
|
||||||
|
## Validación
|
||||||
|
|
||||||
|
```bash
|
||||||
|
VALIDATION=/srv/mesavault/40-clients/ucepsa/edge-oee-demo/ops/validate_shopfloor_stop_summary_semantics_v0341.sql
|
||||||
|
|
||||||
|
docker exec -i mv_ucepsa_postgres_hot sh -lc \
|
||||||
|
'psql -v ON_ERROR_STOP=1 -U "$POSTGRES_USER" -d "$POSTGRES_DB"' \
|
||||||
|
< "$VALIDATION"
|
||||||
|
```
|
||||||
|
|
||||||
|
Valores obligatorios:
|
||||||
|
|
||||||
|
```text
|
||||||
|
eligible_decomposition_errors = 0
|
||||||
|
summary_candidate_mismatches = 0
|
||||||
|
open_or_noise_inflation_errors = 0
|
||||||
|
official_summary_rows = 0
|
||||||
|
official_operator_queue_rows = 0
|
||||||
|
```
|
||||||
@ -0,0 +1,211 @@
|
|||||||
|
# UCEPSA — Shop Floor Context Health v0.3.5
|
||||||
|
|
||||||
|
## Objetivo
|
||||||
|
|
||||||
|
Supervisar en vivo cinco capas:
|
||||||
|
|
||||||
|
```text
|
||||||
|
máquina física
|
||||||
|
orden Odoo publicada
|
||||||
|
sesión Odoo Shop Floor
|
||||||
|
sincronizador
|
||||||
|
calidad temporal
|
||||||
|
```
|
||||||
|
|
||||||
|
Esta versión no cambia la atribución de paros v0.3.4, no modifica Odoo y no
|
||||||
|
alimenta el Ledger oficial.
|
||||||
|
|
||||||
|
## Nuevos objetos
|
||||||
|
|
||||||
|
### Política
|
||||||
|
|
||||||
|
```text
|
||||||
|
mv_loss_intelligence.odoo_shopfloor_health_policies
|
||||||
|
```
|
||||||
|
|
||||||
|
Umbrales iniciales:
|
||||||
|
|
||||||
|
```text
|
||||||
|
intervalo esperado 15 s
|
||||||
|
DELAYED a partir de 45 s
|
||||||
|
STALE a partir de 120 s
|
||||||
|
sesión OPEN obsoleta 12 h
|
||||||
|
tolerancia fecha futura 5 min
|
||||||
|
```
|
||||||
|
|
||||||
|
### Vistas
|
||||||
|
|
||||||
|
```text
|
||||||
|
li_shopfloor_sync_health_v1
|
||||||
|
li_shopfloor_session_data_quality_v1
|
||||||
|
li_context_identity_registry_v1
|
||||||
|
li_shopfloor_machine_live_context_v1
|
||||||
|
li_shopfloor_open_session_watch_v1
|
||||||
|
li_shopfloor_context_incidents_v1
|
||||||
|
li_shopfloor_context_health_summary_v1
|
||||||
|
```
|
||||||
|
|
||||||
|
## Estados vivos principales
|
||||||
|
|
||||||
|
### Producción coherente
|
||||||
|
|
||||||
|
```text
|
||||||
|
máquina en marcha
|
||||||
|
+ orden publicada
|
||||||
|
+ una sesión OPEN del mismo workorder
|
||||||
|
→ HEALTHY_OPEN_SESSION
|
||||||
|
```
|
||||||
|
|
||||||
|
### Parada dentro de una orden
|
||||||
|
|
||||||
|
```text
|
||||||
|
máquina parada
|
||||||
|
+ orden publicada
|
||||||
|
+ sesión OPEN
|
||||||
|
→ TEMPORARY_STOP_INSIDE_SESSION
|
||||||
|
```
|
||||||
|
|
||||||
|
La parada física no cierra la sesión.
|
||||||
|
|
||||||
|
### Producción sin Shop Floor
|
||||||
|
|
||||||
|
```text
|
||||||
|
máquina en marcha
|
||||||
|
+ sin orden
|
||||||
|
+ sin sesión
|
||||||
|
→ RUNNING_WITHOUT_SHOPFLOOR_SESSION
|
||||||
|
```
|
||||||
|
|
||||||
|
Acción: confirmar sistema anterior, prueba, material residual o inicio omitido.
|
||||||
|
|
||||||
|
### Orden sin sesión
|
||||||
|
|
||||||
|
```text
|
||||||
|
orden publicada
|
||||||
|
+ ninguna sesión OPEN
|
||||||
|
→ RUNNING_WITH_ORDER_NO_SHOPFLOOR_SESSION
|
||||||
|
u ORDER_WITHOUT_SHOPFLOOR_SESSION
|
||||||
|
```
|
||||||
|
|
||||||
|
### Conflicto
|
||||||
|
|
||||||
|
```text
|
||||||
|
más de una sesión OPEN
|
||||||
|
o workorder publicado distinto del workorder de la sesión
|
||||||
|
→ CRITICAL
|
||||||
|
```
|
||||||
|
|
||||||
|
## Identidades con namespace
|
||||||
|
|
||||||
|
Nunca se cruza un identificador numérico desnudo.
|
||||||
|
|
||||||
|
Ejemplos:
|
||||||
|
|
||||||
|
```text
|
||||||
|
ODOO_SHOPFLOOR:WORKORDER:340
|
||||||
|
PRODUCTION_CONTEXT:LEGACY_ERP:1
|
||||||
|
```
|
||||||
|
|
||||||
|
## Incidencias
|
||||||
|
|
||||||
|
La vista de incidencias expone:
|
||||||
|
|
||||||
|
```text
|
||||||
|
severidad
|
||||||
|
código
|
||||||
|
máquina
|
||||||
|
context_key
|
||||||
|
orden
|
||||||
|
workorder
|
||||||
|
operario
|
||||||
|
detalle
|
||||||
|
acción recomendada
|
||||||
|
```
|
||||||
|
|
||||||
|
No crea tickets ni escribe en Odoo.
|
||||||
|
|
||||||
|
## Despliegue
|
||||||
|
|
||||||
|
No es necesario detener servicios.
|
||||||
|
|
||||||
|
```bash
|
||||||
|
FILE=/srv/mesavault/40-clients/ucepsa/edge-oee-demo/sql/versions/108_ucepsa_shopfloor_context_health_v035.sql
|
||||||
|
|
||||||
|
docker exec -i mv_ucepsa_postgres_hot sh -lc \
|
||||||
|
'psql -v ON_ERROR_STOP=1 -U "$POSTGRES_USER" -d "$POSTGRES_DB"' \
|
||||||
|
< "$FILE"
|
||||||
|
```
|
||||||
|
|
||||||
|
## Validación
|
||||||
|
|
||||||
|
```bash
|
||||||
|
VALIDATION=/srv/mesavault/40-clients/ucepsa/edge-oee-demo/ops/validate_shopfloor_context_health_v035.sql
|
||||||
|
|
||||||
|
docker exec -i mv_ucepsa_postgres_hot sh -lc \
|
||||||
|
'psql -v ON_ERROR_STOP=1 -U "$POSTGRES_USER" -d "$POSTGRES_DB"' \
|
||||||
|
< "$VALIDATION"
|
||||||
|
```
|
||||||
|
|
||||||
|
Valores estructurales obligatorios:
|
||||||
|
|
||||||
|
```text
|
||||||
|
live_machine_rows = 3
|
||||||
|
distinct_machine_rows = 3
|
||||||
|
pending_has_time_count = 0
|
||||||
|
open_missing_start_count = 0
|
||||||
|
open_has_end_count = 0
|
||||||
|
closed_missing_start_count = 0
|
||||||
|
closed_missing_end_count = 0
|
||||||
|
invalid_interval_count = 0
|
||||||
|
context_key_collision_count = 0
|
||||||
|
temporary_stop_wrong_severity = 0
|
||||||
|
healthy_session_wrong_severity = 0
|
||||||
|
idle_wrong_severity = 0
|
||||||
|
official_live_rows = 0
|
||||||
|
official_incident_rows = 0
|
||||||
|
official_operator_queue_rows = 0
|
||||||
|
```
|
||||||
|
|
||||||
|
`multiple_open_session_rows` debe ser cero en operación normal. Si no lo es, la
|
||||||
|
vista lo elevará como incidencia crítica.
|
||||||
|
|
||||||
|
## Lectura esperada
|
||||||
|
|
||||||
|
Mientras el workorder 340 siga abierto:
|
||||||
|
|
||||||
|
```text
|
||||||
|
CORT-02
|
||||||
|
context_key = ODOO_SHOPFLOOR:WORKORDER:340
|
||||||
|
operario = Esteban
|
||||||
|
estado = HEALTHY_OPEN_SESSION
|
||||||
|
```
|
||||||
|
|
||||||
|
Durante una parada física:
|
||||||
|
|
||||||
|
```text
|
||||||
|
estado = TEMPORARY_STOP_INSIDE_SESSION
|
||||||
|
severidad = INFO
|
||||||
|
```
|
||||||
|
|
||||||
|
CORT-00 solo aparecerá como advertencia cuando realmente esté en marcha sin
|
||||||
|
orden ni sesión.
|
||||||
|
|
||||||
|
## Grafana
|
||||||
|
|
||||||
|
La vista recomendada para una tabla operativa es:
|
||||||
|
|
||||||
|
```text
|
||||||
|
li_shopfloor_machine_live_context_v1
|
||||||
|
```
|
||||||
|
|
||||||
|
Para una lista de excepciones:
|
||||||
|
|
||||||
|
```text
|
||||||
|
li_shopfloor_context_incidents_v1
|
||||||
|
```
|
||||||
|
|
||||||
|
Para un indicador único:
|
||||||
|
|
||||||
|
```text
|
||||||
|
li_shopfloor_context_health_summary_v1
|
||||||
|
```
|
||||||
@ -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.
|
||||||
@ -0,0 +1,80 @@
|
|||||||
|
# UCEPSA — Limpieza controlada de dashboards Grafana v0.3.6.1
|
||||||
|
|
||||||
|
## Objetivo
|
||||||
|
|
||||||
|
Conservar exclusivamente:
|
||||||
|
|
||||||
|
```text
|
||||||
|
ucepsa-shopfloor-context-health
|
||||||
|
ucepsa-edge-oee-machine
|
||||||
|
```
|
||||||
|
|
||||||
|
El script no decide por título. Usa UID exacto para distinguir los dos
|
||||||
|
dashboards que actualmente comparten el título `MESAVAULT Edge-OEE — Máquina`.
|
||||||
|
|
||||||
|
## Seguridad
|
||||||
|
|
||||||
|
- `plan` es el modo predeterminado y no modifica Grafana.
|
||||||
|
- `apply` comprueba que los dos UIDs canónicos existen.
|
||||||
|
- Antes de borrar, exporta todos los dashboards y sus metadatos.
|
||||||
|
- La copia incluye manifiesto y SHA-256.
|
||||||
|
- La operación requiere una confirmación explícita.
|
||||||
|
- No borra carpetas automáticamente.
|
||||||
|
- No toca datasources, usuarios, alertas ni configuración de Grafana.
|
||||||
|
|
||||||
|
## Autenticación
|
||||||
|
|
||||||
|
```bash
|
||||||
|
read -rsp "Token temporal de Grafana: " GRAFANA_API_TOKEN
|
||||||
|
echo
|
||||||
|
export GRAFANA_API_TOKEN
|
||||||
|
```
|
||||||
|
|
||||||
|
## Plan
|
||||||
|
|
||||||
|
```bash
|
||||||
|
SCRIPT=/srv/mesavault/40-clients/ucepsa/edge-oee-demo/ops/cleanup_grafana_dashboards_v0361.sh
|
||||||
|
|
||||||
|
"$SCRIPT" plan
|
||||||
|
```
|
||||||
|
|
||||||
|
Revisar que solo aparezcan como `KEEP`:
|
||||||
|
|
||||||
|
```text
|
||||||
|
ucepsa-shopfloor-context-health
|
||||||
|
ucepsa-edge-oee-machine
|
||||||
|
```
|
||||||
|
|
||||||
|
## Aplicación
|
||||||
|
|
||||||
|
```bash
|
||||||
|
export GRAFANA_CLEANUP_CONFIRM='DELETE_NON_CANONICAL_DASHBOARDS'
|
||||||
|
|
||||||
|
"$SCRIPT" apply
|
||||||
|
|
||||||
|
unset GRAFANA_CLEANUP_CONFIRM
|
||||||
|
```
|
||||||
|
|
||||||
|
Las copias quedan bajo:
|
||||||
|
|
||||||
|
```text
|
||||||
|
/srv/mesavault/backups/ucepsa-grafana-cleanup/<timestamp>/
|
||||||
|
```
|
||||||
|
|
||||||
|
## Verificación
|
||||||
|
|
||||||
|
```bash
|
||||||
|
"$SCRIPT" verify
|
||||||
|
unset GRAFANA_API_TOKEN
|
||||||
|
```
|
||||||
|
|
||||||
|
## Carpetas vacías
|
||||||
|
|
||||||
|
El script devuelve `empty_folders_not_deleted`. Borrarlas después desde la
|
||||||
|
interfaz, únicamente cuando estén vacías:
|
||||||
|
|
||||||
|
```text
|
||||||
|
Dashboards → seleccionar carpeta vacía → Delete
|
||||||
|
```
|
||||||
|
|
||||||
|
No borrar una carpeta que contenga uno de los dos dashboards canónicos.
|
||||||
@ -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 = []
|
||||||
|
```
|
||||||
@ -0,0 +1,187 @@
|
|||||||
|
# UCEPSA — Historial persistente de incidencias Shop Floor v0.3.7
|
||||||
|
|
||||||
|
## Objetivo
|
||||||
|
|
||||||
|
Convertir incidencias vivas en episodios persistentes:
|
||||||
|
|
||||||
|
```text
|
||||||
|
incidencia aparece
|
||||||
|
→ abre un episodio
|
||||||
|
|
||||||
|
incidencia continúa
|
||||||
|
→ actualiza last_seen_at
|
||||||
|
|
||||||
|
incidencia desaparece durante 45 s
|
||||||
|
→ cierra el episodio
|
||||||
|
|
||||||
|
incidencia reaparece más tarde
|
||||||
|
→ abre un episodio nuevo
|
||||||
|
```
|
||||||
|
|
||||||
|
No genera una fila cada 15 segundos.
|
||||||
|
|
||||||
|
## Alcance
|
||||||
|
|
||||||
|
La versión:
|
||||||
|
|
||||||
|
- lee la vista viva `li_shopfloor_context_incidents_v1`;
|
||||||
|
- guarda episodios SHADOW;
|
||||||
|
- permite revisión humana o técnica;
|
||||||
|
- amplía el dashboard existente;
|
||||||
|
- no crea sesiones `LEGACY_ERP`;
|
||||||
|
- no modifica Odoo;
|
||||||
|
- no atribuye pérdidas;
|
||||||
|
- no alimenta el Ledger oficial.
|
||||||
|
|
||||||
|
`first_seen_at` es la primera observación realizada por MESAVAULT después de
|
||||||
|
activar el colector. No debe interpretarse como el inicio físico exacto de una
|
||||||
|
situación que ya estuviera activa.
|
||||||
|
|
||||||
|
## Objetos
|
||||||
|
|
||||||
|
```text
|
||||||
|
shopfloor_context_incident_policies
|
||||||
|
shopfloor_context_incident_episodes
|
||||||
|
shopfloor_context_incident_reviews
|
||||||
|
shopfloor_context_incident_collector_state
|
||||||
|
|
||||||
|
li_shopfloor_context_incident_history_v1
|
||||||
|
li_shopfloor_context_incident_backlog_v1
|
||||||
|
li_shopfloor_context_incident_daily_summary_v1
|
||||||
|
li_shopfloor_context_incident_collector_health_v1
|
||||||
|
```
|
||||||
|
|
||||||
|
## Clasificaciones de revisión
|
||||||
|
|
||||||
|
```text
|
||||||
|
AUTHORIZED_LEGACY_PRODUCTION
|
||||||
|
TEST_OR_SETUP
|
||||||
|
RESIDUAL_MATERIAL
|
||||||
|
ODOO_START_OMITTED
|
||||||
|
DATA_ISSUE
|
||||||
|
NOT_RELEVANT
|
||||||
|
OTHER
|
||||||
|
```
|
||||||
|
|
||||||
|
Una revisión solo documenta lo sucedido. No abre un contexto ni convierte el
|
||||||
|
episodio en pérdida.
|
||||||
|
|
||||||
|
## Despliegue
|
||||||
|
|
||||||
|
### Migración
|
||||||
|
|
||||||
|
```bash
|
||||||
|
FILE=/srv/mesavault/40-clients/ucepsa/edge-oee-demo/sql/versions/109_ucepsa_shopfloor_context_incident_history_v037.sql
|
||||||
|
|
||||||
|
docker exec -i mv_ucepsa_postgres_hot sh -lc \
|
||||||
|
'psql -v ON_ERROR_STOP=1 -U "$POSTGRES_USER" -d "$POSTGRES_DB"' \
|
||||||
|
< "$FILE"
|
||||||
|
```
|
||||||
|
|
||||||
|
### Colector
|
||||||
|
|
||||||
|
Copiar las herramientas al runtime:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
install -m 0644 \
|
||||||
|
/srv/mesavault/40-clients/ucepsa/edge-oee-demo/tools/shopfloor_context_incident_collector.py \
|
||||||
|
/srv/mesavault/edge-oee-ucepsa/tools/
|
||||||
|
|
||||||
|
install -m 0644 \
|
||||||
|
/srv/mesavault/40-clients/ucepsa/edge-oee-demo/tools/shopfloor_context_incident_admin.py \
|
||||||
|
/srv/mesavault/edge-oee-ucepsa/tools/
|
||||||
|
```
|
||||||
|
|
||||||
|
Arranque:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
MANAGER=/srv/mesavault/40-clients/ucepsa/edge-oee-demo/ops/manage_shopfloor_context_incident_collector_v037.sh
|
||||||
|
|
||||||
|
"$MANAGER" dry-run
|
||||||
|
"$MANAGER" once
|
||||||
|
"$MANAGER" start
|
||||||
|
"$MANAGER" status
|
||||||
|
```
|
||||||
|
|
||||||
|
### Validación
|
||||||
|
|
||||||
|
Esperar al menos 20 segundos:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
sleep 20
|
||||||
|
|
||||||
|
VALIDATION=/srv/mesavault/40-clients/ucepsa/edge-oee-demo/ops/validate_shopfloor_context_incident_history_v037.sql
|
||||||
|
|
||||||
|
docker exec -i mv_ucepsa_postgres_hot sh -lc \
|
||||||
|
'psql -v ON_ERROR_STOP=1 -U "$POSTGRES_USER" -d "$POSTGRES_DB"' \
|
||||||
|
< "$VALIDATION"
|
||||||
|
```
|
||||||
|
|
||||||
|
Valores obligatorios:
|
||||||
|
|
||||||
|
```text
|
||||||
|
last_seen_before_start = 0
|
||||||
|
end_before_start = 0
|
||||||
|
open_with_end = 0
|
||||||
|
closed_without_end = 0
|
||||||
|
official_episode_rows = 0
|
||||||
|
duplicate_open_fingerprints = 0
|
||||||
|
live_incidents_without_open_episode = 0
|
||||||
|
expired_open_episodes_not_live = 0
|
||||||
|
review_trigger_mismatches = 0
|
||||||
|
official_operator_queue_rows = 0
|
||||||
|
```
|
||||||
|
|
||||||
|
## Revisión de un episodio
|
||||||
|
|
||||||
|
Listar backlog:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
"$MANAGER" admin list
|
||||||
|
```
|
||||||
|
|
||||||
|
Ver detalle:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
"$MANAGER" admin show --episode-id 1
|
||||||
|
```
|
||||||
|
|
||||||
|
Ejemplo de clasificación, solo después de confirmación humana:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
"$MANAGER" admin review \
|
||||||
|
--episode-id 1 \
|
||||||
|
--classification AUTHORIZED_LEGACY_PRODUCTION \
|
||||||
|
--reviewed-by "Juan Pablo" \
|
||||||
|
--notes "Producción confirmada con el sistema anterior."
|
||||||
|
```
|
||||||
|
|
||||||
|
No ejecutar esa clasificación automáticamente para CORT-00.
|
||||||
|
|
||||||
|
## Dashboard
|
||||||
|
|
||||||
|
El despliegue mantiene el mismo UID y añade tres paneles:
|
||||||
|
|
||||||
|
```text
|
||||||
|
Incidencias pendientes de revisión
|
||||||
|
Historial de las últimas 24 horas
|
||||||
|
Resumen diario de los últimos 7 días
|
||||||
|
```
|
||||||
|
|
||||||
|
```bash
|
||||||
|
read -rsp "Token temporal de Grafana: " GRAFANA_API_TOKEN
|
||||||
|
echo
|
||||||
|
export GRAFANA_API_TOKEN
|
||||||
|
|
||||||
|
DASH=/srv/mesavault/40-clients/ucepsa/edge-oee-demo/ops/deploy_shopfloor_context_incident_history_dashboard_v037.sh
|
||||||
|
|
||||||
|
"$DASH" deploy
|
||||||
|
"$DASH" validate
|
||||||
|
|
||||||
|
unset GRAFANA_API_TOKEN
|
||||||
|
```
|
||||||
|
|
||||||
|
## Workorder 340
|
||||||
|
|
||||||
|
Esta versión puede desplegarse con el workorder 340 todavía abierto. Su cierre
|
||||||
|
sigue siendo una validación independiente del ciclo de sesión Shop Floor.
|
||||||
@ -0,0 +1,186 @@
|
|||||||
|
# UCEPSA — Arbitraje canónico de contexto SHADOW v0.3.8
|
||||||
|
|
||||||
|
## Objetivo
|
||||||
|
|
||||||
|
Convertir varias fuentes posibles de contexto en una decisión única y
|
||||||
|
reproducible por tramo temporal.
|
||||||
|
|
||||||
|
```text
|
||||||
|
una fuente autoritativa
|
||||||
|
→ contexto canónico
|
||||||
|
|
||||||
|
cero fuentes autoritativas + actividad
|
||||||
|
→ UNCONFIRMED_CONTEXT
|
||||||
|
|
||||||
|
más de una fuente autoritativa
|
||||||
|
→ CONTEXT_CONFLICT
|
||||||
|
```
|
||||||
|
|
||||||
|
La versión continúa íntegramente en SHADOW.
|
||||||
|
|
||||||
|
## Fuentes autoritativas
|
||||||
|
|
||||||
|
```text
|
||||||
|
ODOO_SHOPFLOOR
|
||||||
|
LEGACY_ERP autorizado
|
||||||
|
MANUAL_AUTHORIZED
|
||||||
|
MAINTENANCE
|
||||||
|
TEST_SETUP
|
||||||
|
```
|
||||||
|
|
||||||
|
## Evidencias auxiliares
|
||||||
|
|
||||||
|
```text
|
||||||
|
ODOO_ORDER_CONTEXT
|
||||||
|
INFERRED_SHADOW
|
||||||
|
```
|
||||||
|
|
||||||
|
Una orden publicada o una inferencia nunca se promueven por sí solas a contexto
|
||||||
|
canónico.
|
||||||
|
|
||||||
|
## Beneficios operativos
|
||||||
|
|
||||||
|
### Una sola respuesta por tramo
|
||||||
|
|
||||||
|
Antes, una consulta podía usar orden Odoo, otra la sesión Shop Floor y otra el
|
||||||
|
contexto legacy. Ahora todas pueden consultar la misma clave canónica.
|
||||||
|
|
||||||
|
Ejemplo:
|
||||||
|
|
||||||
|
```text
|
||||||
|
CORT-02
|
||||||
|
ODOO_SHOPFLOOR:WORKORDER:340
|
||||||
|
```
|
||||||
|
|
||||||
|
### Evita doble contabilización
|
||||||
|
|
||||||
|
Si alguien abre un contexto legacy mientras continúa una sesión Shop Floor:
|
||||||
|
|
||||||
|
```text
|
||||||
|
Shop Floor + legacy
|
||||||
|
→ CONTEXT_CONFLICT
|
||||||
|
→ ningún paro es elegible
|
||||||
|
```
|
||||||
|
|
||||||
|
El sistema no aplica una prioridad silenciosa.
|
||||||
|
|
||||||
|
### Protege frente a falsas pérdidas
|
||||||
|
|
||||||
|
CORT-00 en marcha sin contexto explícito permanece:
|
||||||
|
|
||||||
|
```text
|
||||||
|
UNCONFIRMED_CONTEXT
|
||||||
|
```
|
||||||
|
|
||||||
|
No se inventa una orden, un operario ni una pérdida económica.
|
||||||
|
|
||||||
|
### Permite convivir durante la migración
|
||||||
|
|
||||||
|
Una orden fabricada con el sistema anterior puede usar un contexto
|
||||||
|
`LEGACY_ERP` autorizado sin obligar a fingir que pasó por Shop Floor.
|
||||||
|
|
||||||
|
### Simplifica el producto
|
||||||
|
|
||||||
|
Las capas posteriores dejan de implementar reglas diferentes para Odoo,
|
||||||
|
legacy y manual. Consultan:
|
||||||
|
|
||||||
|
```text
|
||||||
|
canonical_context_key
|
||||||
|
canonical_context_source
|
||||||
|
canonical_context_status
|
||||||
|
```
|
||||||
|
|
||||||
|
### Facilita la activación futura
|
||||||
|
|
||||||
|
La misma capa permite medir cobertura y conflictos antes de decidir si puede
|
||||||
|
alimentar el Loss Ledger oficial.
|
||||||
|
|
||||||
|
## Nuevos objetos
|
||||||
|
|
||||||
|
```text
|
||||||
|
context_arbitration_source_policies
|
||||||
|
|
||||||
|
li_canonical_context_candidates_v1
|
||||||
|
li_canonical_context_segments_v1
|
||||||
|
li_context_arbitration_conflicts_v1
|
||||||
|
li_machine_canonical_context_live_v1
|
||||||
|
li_canonical_context_coverage_v1
|
||||||
|
li_stop_canonical_context_slices_v1
|
||||||
|
li_stop_canonical_context_shadow_v1
|
||||||
|
li_canonical_context_diagnostics_v1
|
||||||
|
```
|
||||||
|
|
||||||
|
## Despliegue
|
||||||
|
|
||||||
|
```bash
|
||||||
|
FILE=/srv/mesavault/40-clients/ucepsa/edge-oee-demo/sql/versions/110_ucepsa_canonical_context_arbitration_shadow_v038.sql
|
||||||
|
|
||||||
|
docker exec -i mv_ucepsa_postgres_hot sh -lc \
|
||||||
|
'psql -v ON_ERROR_STOP=1 -U "$POSTGRES_USER" -d "$POSTGRES_DB"' \
|
||||||
|
< "$FILE"
|
||||||
|
```
|
||||||
|
|
||||||
|
No es necesario detener ningún servicio.
|
||||||
|
|
||||||
|
## Validación
|
||||||
|
|
||||||
|
```bash
|
||||||
|
VALIDATION=/srv/mesavault/40-clients/ucepsa/edge-oee-demo/ops/validate_canonical_context_arbitration_v038.sql
|
||||||
|
|
||||||
|
docker exec -i mv_ucepsa_postgres_hot sh -lc \
|
||||||
|
'psql -v ON_ERROR_STOP=1 -U "$POSTGRES_USER" -d "$POSTGRES_DB"' \
|
||||||
|
< "$VALIDATION"
|
||||||
|
```
|
||||||
|
|
||||||
|
Valores obligatorios:
|
||||||
|
|
||||||
|
```text
|
||||||
|
candidate_key_collisions = 0
|
||||||
|
invalid_segment_intervals = 0
|
||||||
|
negative_segment_durations = 0
|
||||||
|
one_candidate_without_canonical_key = 0
|
||||||
|
ambiguous_with_canonical_key = 0
|
||||||
|
supporting_promoted_to_canonical = 0
|
||||||
|
conflict_without_review = 0
|
||||||
|
live_machine_rows = 3
|
||||||
|
distinct_live_machines = 3
|
||||||
|
official_live_rows = 0
|
||||||
|
stop_duration_overflow_count = 0
|
||||||
|
open_stops_marked_eligible = 0
|
||||||
|
nonproductive_stops_marked_eligible = 0
|
||||||
|
ambiguous_stops_marked_eligible = 0
|
||||||
|
official_canonical_stop_rows = 0
|
||||||
|
official_canonical_segment_rows = 0
|
||||||
|
official_operator_queue_rows = 0
|
||||||
|
```
|
||||||
|
|
||||||
|
## Lectura esperada
|
||||||
|
|
||||||
|
Mientras la situación actual continúe:
|
||||||
|
|
||||||
|
```text
|
||||||
|
CORT-00
|
||||||
|
→ UNCONFIRMED_CONTEXT
|
||||||
|
|
||||||
|
CORT-01
|
||||||
|
→ NO_ACTIVE_CONTEXT
|
||||||
|
|
||||||
|
CORT-02
|
||||||
|
→ ODOO_SHOPFLOOR
|
||||||
|
→ ODOO_SHOPFLOOR:WORKORDER:340
|
||||||
|
```
|
||||||
|
|
||||||
|
Si las máquinas cambian durante la validación, los estados pueden variar, pero
|
||||||
|
las invariantes deben mantenerse.
|
||||||
|
|
||||||
|
## Dashboard
|
||||||
|
|
||||||
|
Se amplía el dashboard existente con:
|
||||||
|
|
||||||
|
```text
|
||||||
|
Contexto canónico vivo por máquina
|
||||||
|
Conflictos de arbitraje · últimas 24 horas
|
||||||
|
Cobertura de contexto canónico · últimos 30 días
|
||||||
|
```
|
||||||
|
|
||||||
|
El refresco queda fijado en 15 segundos.
|
||||||
@ -0,0 +1,161 @@
|
|||||||
|
# UCEPSA — Readiness de rollout del contexto canónico v0.3.9
|
||||||
|
|
||||||
|
## Objetivo
|
||||||
|
|
||||||
|
Responder con números a esta pregunta:
|
||||||
|
|
||||||
|
> ¿Qué le falta a cada cortadora antes de poder usar el contexto canónico en un
|
||||||
|
> piloto controlado?
|
||||||
|
|
||||||
|
La versión no activa el Ledger oficial. Incluso una máquina en
|
||||||
|
`READY_FOR_CONTROLLED_SHADOW` conserva:
|
||||||
|
|
||||||
|
```text
|
||||||
|
ready_for_official_ledger = false
|
||||||
|
official_ledger_eligible = false
|
||||||
|
```
|
||||||
|
|
||||||
|
## Criterios iniciales
|
||||||
|
|
||||||
|
Ventana:
|
||||||
|
|
||||||
|
```text
|
||||||
|
7 días
|
||||||
|
```
|
||||||
|
|
||||||
|
Umbrales configurables:
|
||||||
|
|
||||||
|
```text
|
||||||
|
mínimo observado 8 h
|
||||||
|
contexto confirmado mínimo 90 %
|
||||||
|
contexto sin confirmar máximo 10 %
|
||||||
|
conflicto máximo 0 min
|
||||||
|
revisiones pendientes máximas 0
|
||||||
|
antigüedad máxima revisión 24 h
|
||||||
|
DQ crítica máxima 0
|
||||||
|
sync requerido HEALTHY
|
||||||
|
colector requerido HEALTHY
|
||||||
|
```
|
||||||
|
|
||||||
|
Estos valores son iniciales y pueden revisarse antes de octubre.
|
||||||
|
|
||||||
|
## Estados
|
||||||
|
|
||||||
|
```text
|
||||||
|
TECHNICAL_BLOCKED
|
||||||
|
INSUFFICIENT_OBSERVATION
|
||||||
|
REVIEW_BACKLOG
|
||||||
|
COVERAGE_NOT_READY
|
||||||
|
READY_FOR_CONTROLLED_SHADOW
|
||||||
|
```
|
||||||
|
|
||||||
|
La evaluación se detiene en el primer bloqueo operativo relevante.
|
||||||
|
|
||||||
|
## Beneficios
|
||||||
|
|
||||||
|
### Evita activar por intuición
|
||||||
|
|
||||||
|
No basta con que un dashboard se vea bien. La vista exige salud técnica,
|
||||||
|
observación suficiente, ausencia de conflictos, backlog resuelto y cobertura
|
||||||
|
mínima.
|
||||||
|
|
||||||
|
### Convierte el rollout en una lista de acciones
|
||||||
|
|
||||||
|
Ejemplos:
|
||||||
|
|
||||||
|
```text
|
||||||
|
CORT-00
|
||||||
|
REVIEW_BACKLOG
|
||||||
|
→ preguntar a Producción por el episodio pendiente
|
||||||
|
|
||||||
|
CORT-01
|
||||||
|
INSUFFICIENT_OBSERVATION
|
||||||
|
→ acumular horas reales
|
||||||
|
|
||||||
|
CORT-02
|
||||||
|
COVERAGE_NOT_READY
|
||||||
|
→ aumentar uso correcto de Shop Floor
|
||||||
|
```
|
||||||
|
|
||||||
|
### Separa readiness SHADOW de activación oficial
|
||||||
|
|
||||||
|
El sistema puede indicar que una máquina está preparada para una prueba
|
||||||
|
controlada sin permitir todavía que genere pérdidas oficiales.
|
||||||
|
|
||||||
|
### Da una evolución medible
|
||||||
|
|
||||||
|
Cada semana puede observarse:
|
||||||
|
|
||||||
|
```text
|
||||||
|
sube % confirmado
|
||||||
|
baja % sin confirmar
|
||||||
|
desaparece backlog
|
||||||
|
conflictos siguen en cero
|
||||||
|
```
|
||||||
|
|
||||||
|
## Objetos
|
||||||
|
|
||||||
|
```text
|
||||||
|
context_rollout_readiness_policies
|
||||||
|
|
||||||
|
li_canonical_context_readiness_metrics_v1
|
||||||
|
li_canonical_context_readiness_gate_v1
|
||||||
|
li_context_rollout_blockers_v1
|
||||||
|
li_context_rollout_readiness_summary_v1
|
||||||
|
```
|
||||||
|
|
||||||
|
## Despliegue
|
||||||
|
|
||||||
|
```bash
|
||||||
|
FILE=/srv/mesavault/40-clients/ucepsa/edge-oee-demo/sql/versions/111_ucepsa_context_rollout_readiness_shadow_v039.sql
|
||||||
|
|
||||||
|
docker exec -i mv_ucepsa_postgres_hot sh -lc \
|
||||||
|
'psql -v ON_ERROR_STOP=1 -U "$POSTGRES_USER" -d "$POSTGRES_DB"' \
|
||||||
|
< "$FILE"
|
||||||
|
```
|
||||||
|
|
||||||
|
## Validación
|
||||||
|
|
||||||
|
```bash
|
||||||
|
VALIDATION=/srv/mesavault/40-clients/ucepsa/edge-oee-demo/ops/validate_context_rollout_readiness_v039.sql
|
||||||
|
|
||||||
|
docker exec -i mv_ucepsa_postgres_hot sh -lc \
|
||||||
|
'psql -v ON_ERROR_STOP=1 -U "$POSTGRES_USER" -d "$POSTGRES_DB"' \
|
||||||
|
< "$VALIDATION"
|
||||||
|
```
|
||||||
|
|
||||||
|
Valores obligatorios:
|
||||||
|
|
||||||
|
```text
|
||||||
|
machine_rows = 3
|
||||||
|
distinct_machines = 3
|
||||||
|
official_metric_rows = 0
|
||||||
|
|
||||||
|
negative_observed_hours = 0
|
||||||
|
negative_confirmed_hours = 0
|
||||||
|
negative_unconfirmed_hours = 0
|
||||||
|
negative_conflict_minutes = 0
|
||||||
|
|
||||||
|
invalid_confirmed_pct = 0
|
||||||
|
invalid_unconfirmed_pct = 0
|
||||||
|
invalid_operator_pct = 0
|
||||||
|
|
||||||
|
activity_decomposition_errors = 0
|
||||||
|
ready_gate_mismatches = 0
|
||||||
|
|
||||||
|
official_gate_rows = 0
|
||||||
|
official_blocker_rows = 0
|
||||||
|
official_operator_queue_rows = 0
|
||||||
|
```
|
||||||
|
|
||||||
|
## Dashboard
|
||||||
|
|
||||||
|
Se añaden al dashboard existente:
|
||||||
|
|
||||||
|
```text
|
||||||
|
Readiness de contexto por máquina
|
||||||
|
Bloqueos de rollout
|
||||||
|
Resumen de readiness
|
||||||
|
```
|
||||||
|
|
||||||
|
El UID no cambia y el refresco permanece en 15 segundos.
|
||||||
@ -0,0 +1,186 @@
|
|||||||
|
# UCEPSA — Cola de casos raíz SHADOW v0.3.10
|
||||||
|
|
||||||
|
## Objetivo
|
||||||
|
|
||||||
|
Reducir la intervención humana a una decisión por problema real.
|
||||||
|
|
||||||
|
Ejemplo actual:
|
||||||
|
|
||||||
|
```text
|
||||||
|
episodio 7 incidencia de máquina
|
||||||
|
episodio 8 evidencia workorder 649
|
||||||
|
episodio 9 evidencia workorder 698
|
||||||
|
```
|
||||||
|
|
||||||
|
La cola humana debe presentar:
|
||||||
|
|
||||||
|
```text
|
||||||
|
1 caso raíz
|
||||||
|
CORT-00
|
||||||
|
Varias órdenes Shop Floor abiertas
|
||||||
|
Evidencias: 7, 8 y 9
|
||||||
|
```
|
||||||
|
|
||||||
|
No se elimina ninguna evidencia.
|
||||||
|
|
||||||
|
## Arquitectura
|
||||||
|
|
||||||
|
```text
|
||||||
|
episodios técnicos
|
||||||
|
→ constructor de casos raíz
|
||||||
|
→ una pregunta humana
|
||||||
|
→ revisión auditada
|
||||||
|
→ cascada de revisión a episodios asociados
|
||||||
|
```
|
||||||
|
|
||||||
|
## Agrupación inicial
|
||||||
|
|
||||||
|
`MULTIPLE_OPEN_SESSIONS` se agrupa cuando:
|
||||||
|
|
||||||
|
```text
|
||||||
|
misma máquina
|
||||||
|
misma familia
|
||||||
|
intervalos solapados o separados <= 120 s
|
||||||
|
```
|
||||||
|
|
||||||
|
Los demás episodios generan inicialmente un caso individual.
|
||||||
|
|
||||||
|
## Revisión
|
||||||
|
|
||||||
|
Clasificaciones de caso:
|
||||||
|
|
||||||
|
```text
|
||||||
|
ODOO_DOUBLE_START_ONE_VALID
|
||||||
|
ODOO_DOUBLE_START_BOTH_INVALID
|
||||||
|
ODOO_CONSECUTIVE_ORDERS_NOT_CLOSED
|
||||||
|
AUTHORIZED_LEGACY_PRODUCTION
|
||||||
|
TEST_OR_SETUP
|
||||||
|
RESIDUAL_MATERIAL
|
||||||
|
ODOO_START_OMITTED
|
||||||
|
DATA_ISSUE
|
||||||
|
NOT_RELEVANT
|
||||||
|
OTHER
|
||||||
|
```
|
||||||
|
|
||||||
|
Una revisión de caso:
|
||||||
|
|
||||||
|
- actualiza el caso raíz;
|
||||||
|
- crea una revisión auditada para cada episodio pendiente;
|
||||||
|
- elimina la duplicación del backlog;
|
||||||
|
- no modifica Odoo;
|
||||||
|
- no crea pérdidas oficiales.
|
||||||
|
|
||||||
|
## Objetos
|
||||||
|
|
||||||
|
```text
|
||||||
|
context_review_case_policies
|
||||||
|
context_review_cases
|
||||||
|
context_review_case_evidence
|
||||||
|
context_review_case_reviews
|
||||||
|
context_review_case_builder_state
|
||||||
|
|
||||||
|
li_context_review_case_history_v1
|
||||||
|
li_context_review_case_backlog_v1
|
||||||
|
li_context_review_case_evidence_v1
|
||||||
|
li_context_review_case_builder_health_v1
|
||||||
|
|
||||||
|
li_canonical_context_readiness_metrics_v2
|
||||||
|
li_canonical_context_readiness_gate_v2
|
||||||
|
li_context_rollout_blockers_v2
|
||||||
|
li_context_rollout_readiness_summary_v2
|
||||||
|
```
|
||||||
|
|
||||||
|
## Despliegue
|
||||||
|
|
||||||
|
### Migración
|
||||||
|
|
||||||
|
```bash
|
||||||
|
FILE=/srv/mesavault/40-clients/ucepsa/edge-oee-demo/sql/versions/112_ucepsa_context_root_case_queue_shadow_v0310.sql
|
||||||
|
|
||||||
|
docker exec -i mv_ucepsa_postgres_hot sh -lc \
|
||||||
|
'psql -v ON_ERROR_STOP=1 -U "$POSTGRES_USER" -d "$POSTGRES_DB"' \
|
||||||
|
< "$FILE"
|
||||||
|
```
|
||||||
|
|
||||||
|
### Runtime
|
||||||
|
|
||||||
|
```bash
|
||||||
|
RUNTIME=/srv/mesavault/edge-oee-ucepsa
|
||||||
|
TARGET=/srv/mesavault/40-clients/ucepsa/edge-oee-demo
|
||||||
|
|
||||||
|
install -m 0644 \
|
||||||
|
"$TARGET/tools/context_review_case_builder.py" \
|
||||||
|
"$RUNTIME/tools/"
|
||||||
|
|
||||||
|
install -m 0644 \
|
||||||
|
"$TARGET/tools/context_review_case_admin.py" \
|
||||||
|
"$RUNTIME/tools/"
|
||||||
|
```
|
||||||
|
|
||||||
|
### Constructor
|
||||||
|
|
||||||
|
```bash
|
||||||
|
MANAGER=/srv/mesavault/40-clients/ucepsa/edge-oee-demo/ops/manage_context_review_case_builder_v0310.sh
|
||||||
|
|
||||||
|
"$MANAGER" dry-run
|
||||||
|
"$MANAGER" once
|
||||||
|
"$MANAGER" start
|
||||||
|
sleep 35
|
||||||
|
"$MANAGER" status
|
||||||
|
```
|
||||||
|
|
||||||
|
## Validación
|
||||||
|
|
||||||
|
```bash
|
||||||
|
VALIDATION=/srv/mesavault/40-clients/ucepsa/edge-oee-demo/ops/validate_context_root_case_queue_v0310.sql
|
||||||
|
|
||||||
|
docker exec -i mv_ucepsa_postgres_hot sh -lc \
|
||||||
|
'psql -v ON_ERROR_STOP=1 -U "$POSTGRES_USER" -d "$POSTGRES_DB"' \
|
||||||
|
< "$VALIDATION"
|
||||||
|
```
|
||||||
|
|
||||||
|
Valores obligatorios:
|
||||||
|
|
||||||
|
```text
|
||||||
|
evidence_link_duplicates = 0
|
||||||
|
review_episodes_without_case = 0
|
||||||
|
root_case_count_7_8_9 = 1
|
||||||
|
evidence_count_7_8_9 = 3
|
||||||
|
ready_v2_gate_mismatches = 0
|
||||||
|
official_case_rows = 0
|
||||||
|
official_gate_rows = 0
|
||||||
|
official_operator_queue_rows = 0
|
||||||
|
```
|
||||||
|
|
||||||
|
## Consola
|
||||||
|
|
||||||
|
```bash
|
||||||
|
"$MANAGER" admin list
|
||||||
|
"$MANAGER" admin show --case-id <id>
|
||||||
|
```
|
||||||
|
|
||||||
|
Cuando Juan Pablo confirme la orden real del conflicto:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
"$MANAGER" admin review \
|
||||||
|
--case-id <id> \
|
||||||
|
--classification ODOO_DOUBLE_START_ONE_VALID \
|
||||||
|
--selected-order-ref "WH/MO/00706" \
|
||||||
|
--selected-workorder-id 698 \
|
||||||
|
--reviewed-by "Juan Pablo" \
|
||||||
|
--notes "Confirma que WH/MO/00706 era la orden real y WH/MO/00660 quedó abierta por error."
|
||||||
|
```
|
||||||
|
|
||||||
|
No ejecutar hasta recibir confirmación.
|
||||||
|
|
||||||
|
## Dashboard
|
||||||
|
|
||||||
|
Se mantienen los dos dashboards existentes. El dashboard de salud añade:
|
||||||
|
|
||||||
|
```text
|
||||||
|
Casos raíz pendientes
|
||||||
|
Evidencias asociadas
|
||||||
|
Historial de casos raíz
|
||||||
|
```
|
||||||
|
|
||||||
|
El panel readiness utiliza v2 y cuenta decisiones humanas, no evidencias.
|
||||||
@ -0,0 +1,160 @@
|
|||||||
|
# UCEPSA — Decisiones humanas consolidadas SHADOW v0.3.11
|
||||||
|
|
||||||
|
## Objetivo
|
||||||
|
|
||||||
|
Reducir la carga de revisión:
|
||||||
|
|
||||||
|
```text
|
||||||
|
10 episodios técnicos
|
||||||
|
→ 7 casos raíz
|
||||||
|
→ aproximadamente 3 decisiones humanas
|
||||||
|
```
|
||||||
|
|
||||||
|
No se elimina ningún intervalo, caso o evidencia.
|
||||||
|
|
||||||
|
## Agrupación inicial
|
||||||
|
|
||||||
|
### Actividad sin contexto
|
||||||
|
|
||||||
|
```text
|
||||||
|
misma máquina
|
||||||
|
+ mismo día local Europe/Madrid
|
||||||
|
+ separación <= 15 min
|
||||||
|
→ una decisión humana
|
||||||
|
```
|
||||||
|
|
||||||
|
### Conflictos de arranque Odoo
|
||||||
|
|
||||||
|
```text
|
||||||
|
MULTIPLE_OPEN_SESSIONS
|
||||||
|
+ ORDER_WITHOUT_SESSION
|
||||||
|
+ misma máquina
|
||||||
|
+ separación <= 5 min
|
||||||
|
→ una decisión humana
|
||||||
|
```
|
||||||
|
|
||||||
|
No se inventan turnos horarios porque todavía no existe un calendario de turnos
|
||||||
|
confirmado. El día local actúa como frontera conservadora.
|
||||||
|
|
||||||
|
## Casos esperados
|
||||||
|
|
||||||
|
```text
|
||||||
|
CORT-00 casos 1/2/3/4
|
||||||
|
→ una decisión sobre sistema antiguo
|
||||||
|
|
||||||
|
CORT-00 casos 5/6
|
||||||
|
→ una decisión WH/MO/00660 vs WH/MO/00706
|
||||||
|
|
||||||
|
CORT-02 casos 7/8
|
||||||
|
→ una decisión sobre actividad posterior sin sesión
|
||||||
|
```
|
||||||
|
|
||||||
|
## Revisión
|
||||||
|
|
||||||
|
La revisión de una decisión:
|
||||||
|
|
||||||
|
- registra una única respuesta humana;
|
||||||
|
- revisa únicamente los casos raíz pendientes;
|
||||||
|
- revisa únicamente los episodios pendientes;
|
||||||
|
- conserva las revisiones ya existentes;
|
||||||
|
- no modifica Odoo;
|
||||||
|
- no crea contextos retrospectivos automáticamente;
|
||||||
|
- no habilita el Ledger oficial.
|
||||||
|
|
||||||
|
## Despliegue
|
||||||
|
|
||||||
|
```bash
|
||||||
|
FILE=/srv/mesavault/40-clients/ucepsa/edge-oee-demo/sql/versions/113_ucepsa_context_decision_groups_shadow_v0311.sql
|
||||||
|
|
||||||
|
docker exec -i mv_ucepsa_postgres_hot sh -lc \
|
||||||
|
'psql -v ON_ERROR_STOP=1 -U "$POSTGRES_USER" -d "$POSTGRES_DB"' \
|
||||||
|
< "$FILE"
|
||||||
|
```
|
||||||
|
|
||||||
|
No hay servicio nuevo: la agrupación es determinista mediante vistas SQL.
|
||||||
|
|
||||||
|
## Runtime
|
||||||
|
|
||||||
|
```bash
|
||||||
|
RUNTIME=/srv/mesavault/edge-oee-ucepsa
|
||||||
|
TARGET=/srv/mesavault/40-clients/ucepsa/edge-oee-demo
|
||||||
|
|
||||||
|
install -m 0644 \
|
||||||
|
"$TARGET/tools/context_decision_group_admin.py" \
|
||||||
|
"$RUNTIME/tools/"
|
||||||
|
```
|
||||||
|
|
||||||
|
## Validación
|
||||||
|
|
||||||
|
```bash
|
||||||
|
VALIDATION=/srv/mesavault/40-clients/ucepsa/edge-oee-demo/ops/validate_context_decision_groups_v0311.sql
|
||||||
|
|
||||||
|
docker exec -i mv_ucepsa_postgres_hot sh -lc \
|
||||||
|
'psql -v ON_ERROR_STOP=1 -U "$POSTGRES_USER" -d "$POSTGRES_DB"' \
|
||||||
|
< "$VALIDATION"
|
||||||
|
```
|
||||||
|
|
||||||
|
Valores obligatorios:
|
||||||
|
|
||||||
|
```text
|
||||||
|
root_case_assignment_duplicates = 0
|
||||||
|
recent_root_cases_without_decision_group = 0
|
||||||
|
|
||||||
|
decision_group_count_1_2_3_4 = 1
|
||||||
|
linked_case_count_1_2_3_4 = 4
|
||||||
|
|
||||||
|
decision_group_count_5_6 = 1
|
||||||
|
linked_case_count_5_6 = 2
|
||||||
|
|
||||||
|
decision_group_count_7_8 = 1
|
||||||
|
linked_case_count_7_8 = 2
|
||||||
|
|
||||||
|
decision_group_count_exceeds_root_case_count = 0
|
||||||
|
ready_v3_gate_mismatches = 0
|
||||||
|
|
||||||
|
official_decision_group_rows = 0
|
||||||
|
official_gate_rows = 0
|
||||||
|
official_operator_queue_rows = 0
|
||||||
|
```
|
||||||
|
|
||||||
|
## Consola
|
||||||
|
|
||||||
|
```bash
|
||||||
|
MANAGER=/srv/mesavault/40-clients/ucepsa/edge-oee-demo/ops/manage_context_decision_groups_v0311.sh
|
||||||
|
|
||||||
|
"$MANAGER" list --limit 50
|
||||||
|
"$MANAGER" show \
|
||||||
|
--decision-group-key 'DECISION:...'
|
||||||
|
```
|
||||||
|
|
||||||
|
No revisar el grupo de doble arranque hasta recibir la respuesta de Juan Pablo.
|
||||||
|
|
||||||
|
Cuando responda:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
"$MANAGER" review \
|
||||||
|
--decision-group-key 'DECISION:ODOO_ORDER_START_CONFLICT:CORT-00:20260720:5' \
|
||||||
|
--classification ODOO_DOUBLE_START_ONE_VALID \
|
||||||
|
--selected-order-ref "WH/MO/00706" \
|
||||||
|
--selected-workorder-id 698 \
|
||||||
|
--reviewed-by "Juan Pablo" \
|
||||||
|
--notes "Confirma la orden real y la orden abierta por error."
|
||||||
|
```
|
||||||
|
|
||||||
|
## Dashboard
|
||||||
|
|
||||||
|
Se mantiene el mismo UID y se añaden:
|
||||||
|
|
||||||
|
```text
|
||||||
|
Decisiones humanas pendientes
|
||||||
|
Casos raíz incluidos en cada decisión
|
||||||
|
Historial de decisiones humanas
|
||||||
|
```
|
||||||
|
|
||||||
|
Readiness v3 compara:
|
||||||
|
|
||||||
|
```text
|
||||||
|
evidencias pendientes
|
||||||
|
casos raíz pendientes
|
||||||
|
decisiones humanas pendientes
|
||||||
|
```
|
||||||
@ -0,0 +1,142 @@
|
|||||||
|
# UCEPSA — Agenda diaria de gobernanza SHADOW v0.3.12
|
||||||
|
|
||||||
|
## Objetivo
|
||||||
|
|
||||||
|
Convertir la arquitectura de gobernanza en una revisión diaria de 15 minutos:
|
||||||
|
|
||||||
|
```text
|
||||||
|
datos automáticos
|
||||||
|
→ decisiones humanas consolidadas
|
||||||
|
→ agenda priorizada
|
||||||
|
→ snapshot auditable
|
||||||
|
→ revisión y seguimiento
|
||||||
|
```
|
||||||
|
|
||||||
|
Una fila de agenda equivale a una pregunta humana.
|
||||||
|
|
||||||
|
## Priorización
|
||||||
|
|
||||||
|
La severidad técnica se conserva.
|
||||||
|
|
||||||
|
La agenda añade un estado de atención independiente:
|
||||||
|
|
||||||
|
```text
|
||||||
|
0-8 h RECENT
|
||||||
|
8-24 h ATTENTION
|
||||||
|
>24 h OVERDUE
|
||||||
|
```
|
||||||
|
|
||||||
|
Estos tramos organizan la reunión. No convierten una advertencia en crítica.
|
||||||
|
|
||||||
|
## Objetos
|
||||||
|
|
||||||
|
```text
|
||||||
|
context_governance_daily_policies
|
||||||
|
context_governance_agenda_snapshots
|
||||||
|
context_governance_agenda_snapshot_items
|
||||||
|
|
||||||
|
li_context_governance_decision_evidence_pack_v1
|
||||||
|
li_context_governance_daily_agenda_v1
|
||||||
|
li_context_governance_daily_summary_v1
|
||||||
|
li_context_governance_agenda_snapshot_history_v1
|
||||||
|
```
|
||||||
|
|
||||||
|
## Despliegue
|
||||||
|
|
||||||
|
```bash
|
||||||
|
FILE=/srv/mesavault/40-clients/ucepsa/edge-oee-demo/sql/versions/114_ucepsa_context_daily_governance_agenda_shadow_v0312.sql
|
||||||
|
|
||||||
|
docker exec -i mv_ucepsa_postgres_hot sh -lc \
|
||||||
|
'psql -v ON_ERROR_STOP=1 -U "$POSTGRES_USER" -d "$POSTGRES_DB"' \
|
||||||
|
< "$FILE"
|
||||||
|
```
|
||||||
|
|
||||||
|
## Runtime
|
||||||
|
|
||||||
|
```bash
|
||||||
|
RUNTIME=/srv/mesavault/edge-oee-ucepsa
|
||||||
|
TARGET=/srv/mesavault/40-clients/ucepsa/edge-oee-demo
|
||||||
|
|
||||||
|
install -m 0644 \
|
||||||
|
"$TARGET/tools/context_daily_governance.py" \
|
||||||
|
"$RUNTIME/tools/"
|
||||||
|
```
|
||||||
|
|
||||||
|
## Consola
|
||||||
|
|
||||||
|
```bash
|
||||||
|
MANAGER=/srv/mesavault/40-clients/ucepsa/edge-oee-demo/ops/manage_context_daily_governance_v0312.sh
|
||||||
|
|
||||||
|
"$MANAGER" agenda --limit 50
|
||||||
|
|
||||||
|
"$MANAGER" show \
|
||||||
|
--decision-group-key 'DECISION:...'
|
||||||
|
```
|
||||||
|
|
||||||
|
## Guardar la agenda de una reunión
|
||||||
|
|
||||||
|
```bash
|
||||||
|
"$MANAGER" snapshot \
|
||||||
|
--generated-by "Víctor Fraile" \
|
||||||
|
--notes "Revisión diaria con Dirección y Producción."
|
||||||
|
```
|
||||||
|
|
||||||
|
La salida devuelve `snapshot_id`.
|
||||||
|
|
||||||
|
## Exportar Markdown
|
||||||
|
|
||||||
|
Vista actual:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
OUT=/srv/mesavault/exports/governance-review-$(date +%F).md
|
||||||
|
mkdir -p "$(dirname "$OUT")"
|
||||||
|
|
||||||
|
"$MANAGER" export-markdown \
|
||||||
|
--generated-by "MESAVAULT" \
|
||||||
|
> "$OUT"
|
||||||
|
```
|
||||||
|
|
||||||
|
Snapshot inmutable:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
"$MANAGER" export-markdown \
|
||||||
|
--snapshot-id NUMERO \
|
||||||
|
> "$OUT"
|
||||||
|
```
|
||||||
|
|
||||||
|
## Validación
|
||||||
|
|
||||||
|
```bash
|
||||||
|
VALIDATION=/srv/mesavault/40-clients/ucepsa/edge-oee-demo/ops/validate_context_daily_governance_v0312.sql
|
||||||
|
|
||||||
|
docker exec -i mv_ucepsa_postgres_hot sh -lc \
|
||||||
|
'psql -v ON_ERROR_STOP=1 -U "$POSTGRES_USER" -d "$POSTGRES_DB"' \
|
||||||
|
< "$VALIDATION"
|
||||||
|
```
|
||||||
|
|
||||||
|
Valores obligatorios:
|
||||||
|
|
||||||
|
```text
|
||||||
|
agenda_rows = decision_backlog_rows
|
||||||
|
duplicated_agenda_decisions = 0
|
||||||
|
agenda_rank_mismatches = 0
|
||||||
|
invalid_attention_status_rows = 0
|
||||||
|
snapshot_item_count_mismatches = 0
|
||||||
|
|
||||||
|
official_snapshot_rows = 0
|
||||||
|
official_snapshot_item_rows = 0
|
||||||
|
official_summary_rows = 0
|
||||||
|
official_operator_queue_rows = 0
|
||||||
|
```
|
||||||
|
|
||||||
|
## Dashboard
|
||||||
|
|
||||||
|
Se mantiene el mismo UID y se añaden:
|
||||||
|
|
||||||
|
```text
|
||||||
|
Agenda diaria de gobernanza
|
||||||
|
Paquete de evidencia por decisión
|
||||||
|
Indicadores de gobernanza diaria
|
||||||
|
```
|
||||||
|
|
||||||
|
Paneles totales esperados: 28.
|
||||||
@ -0,0 +1,195 @@
|
|||||||
|
# UCEPSA — Regresión y hardening SHADOW v0.3.13.1
|
||||||
|
|
||||||
|
## Objetivo
|
||||||
|
|
||||||
|
Proteger automáticamente los comportamientos ya validados:
|
||||||
|
|
||||||
|
```text
|
||||||
|
cierre Shop Floor
|
||||||
|
arbitraje canónico
|
||||||
|
contexto legacy
|
||||||
|
conflictos sin prioridad silenciosa
|
||||||
|
casos raíz
|
||||||
|
decisiones consolidadas
|
||||||
|
agenda diaria
|
||||||
|
snapshots
|
||||||
|
readiness
|
||||||
|
guardas oficiales
|
||||||
|
```
|
||||||
|
|
||||||
|
La suite no modifica Odoo ni datos de producción.
|
||||||
|
|
||||||
|
## Contratos principales
|
||||||
|
|
||||||
|
```text
|
||||||
|
WO 340:
|
||||||
|
sesión cerrada
|
||||||
|
100 kg finales
|
||||||
|
Esteban confirmado
|
||||||
|
sin extensión posterior al cierre
|
||||||
|
|
||||||
|
Episodio 1:
|
||||||
|
LEGACY_ERP autorizado
|
||||||
|
intervalo exacto
|
||||||
|
cobertura canónica completa
|
||||||
|
|
||||||
|
WO 649/698:
|
||||||
|
conflicto histórico conservado
|
||||||
|
sin clave canónica
|
||||||
|
|
||||||
|
Gobernanza:
|
||||||
|
7/8/9 → un caso raíz
|
||||||
|
1-4, 5-6 y 7-8 → tres decisiones
|
||||||
|
agenda = backlog
|
||||||
|
snapshot 1 coherente
|
||||||
|
|
||||||
|
SHADOW:
|
||||||
|
cero filas oficiales
|
||||||
|
```
|
||||||
|
|
||||||
|
## Objetos
|
||||||
|
|
||||||
|
```text
|
||||||
|
context_regression_policies
|
||||||
|
context_regression_runs
|
||||||
|
context_regression_results
|
||||||
|
|
||||||
|
li_context_regression_latest_run_v1
|
||||||
|
li_context_regression_latest_results_v1
|
||||||
|
li_context_regression_failures_v1
|
||||||
|
li_context_regression_run_history_v1
|
||||||
|
li_context_regression_health_v1
|
||||||
|
```
|
||||||
|
|
||||||
|
## Despliegue
|
||||||
|
|
||||||
|
```bash
|
||||||
|
FILE=/srv/mesavault/40-clients/ucepsa/edge-oee-demo/sql/versions/115_ucepsa_context_regression_hardening_shadow_v0313.sql
|
||||||
|
|
||||||
|
docker exec -i mv_ucepsa_postgres_hot sh -lc \
|
||||||
|
'psql -v ON_ERROR_STOP=1 -U "$POSTGRES_USER" -d "$POSTGRES_DB"' \
|
||||||
|
< "$FILE"
|
||||||
|
```
|
||||||
|
|
||||||
|
## Runtime
|
||||||
|
|
||||||
|
```bash
|
||||||
|
RUNTIME=/srv/mesavault/edge-oee-ucepsa
|
||||||
|
TARGET=/srv/mesavault/40-clients/ucepsa/edge-oee-demo
|
||||||
|
|
||||||
|
install -m 0644 \
|
||||||
|
"$TARGET/tools/context_regression_suite.py" \
|
||||||
|
"$RUNTIME/tools/"
|
||||||
|
```
|
||||||
|
|
||||||
|
## Ejecución previa sin persistencia
|
||||||
|
|
||||||
|
```bash
|
||||||
|
MANAGER=/srv/mesavault/40-clients/ucepsa/edge-oee-demo/ops/manage_context_regression_v0313.sh
|
||||||
|
|
||||||
|
"$MANAGER" dry-run \
|
||||||
|
--triggered-by "Víctor Fraile"
|
||||||
|
```
|
||||||
|
|
||||||
|
## Primera ejecución persistente
|
||||||
|
|
||||||
|
```bash
|
||||||
|
"$MANAGER" run \
|
||||||
|
--triggered-by "Víctor Fraile" \
|
||||||
|
--trigger-kind DEPLOYMENT \
|
||||||
|
--notes "Validación posterior al despliegue v0.3.13."
|
||||||
|
```
|
||||||
|
|
||||||
|
`PASS` es el resultado ideal. `WARN` es admisible si se limita a salud o
|
||||||
|
rendimiento. `FAIL` o `ERROR` requiere investigación antes de continuar.
|
||||||
|
|
||||||
|
## Consultas
|
||||||
|
|
||||||
|
```bash
|
||||||
|
"$MANAGER" show
|
||||||
|
"$MANAGER" list --limit 20
|
||||||
|
"$MANAGER" show --run-id NUMERO
|
||||||
|
```
|
||||||
|
|
||||||
|
## Exportar Markdown
|
||||||
|
|
||||||
|
```bash
|
||||||
|
OUT=/srv/mesavault/exports/context-regression-$(date +%F).md
|
||||||
|
|
||||||
|
"$MANAGER" export-markdown \
|
||||||
|
> "$OUT"
|
||||||
|
|
||||||
|
sed -n '1,260p' "$OUT"
|
||||||
|
```
|
||||||
|
|
||||||
|
## Validación
|
||||||
|
|
||||||
|
```bash
|
||||||
|
VALIDATION=/srv/mesavault/40-clients/ucepsa/edge-oee-demo/ops/validate_context_regression_v0313.sql
|
||||||
|
|
||||||
|
docker exec -i mv_ucepsa_postgres_hot sh -lc \
|
||||||
|
'psql -v ON_ERROR_STOP=1 -U "$POSTGRES_USER" -d "$POSTGRES_DB"' \
|
||||||
|
< "$VALIDATION"
|
||||||
|
```
|
||||||
|
|
||||||
|
Valores obligatorios:
|
||||||
|
|
||||||
|
```text
|
||||||
|
run_count_mismatches = 0
|
||||||
|
duplicate_test_codes = 0
|
||||||
|
suite_complete = true
|
||||||
|
latest_fail_or_error_count = 0
|
||||||
|
critical_contract_not_passed = 0
|
||||||
|
|
||||||
|
official_run_rows = 0
|
||||||
|
official_result_rows = 0
|
||||||
|
official_health_rows = 0
|
||||||
|
official_operator_queue_rows = 0
|
||||||
|
```
|
||||||
|
|
||||||
|
## Uso operativo recomendado
|
||||||
|
|
||||||
|
Ejecutar la suite:
|
||||||
|
|
||||||
|
```text
|
||||||
|
después de cada migración
|
||||||
|
después de modificar sincronizadores o arbitraje
|
||||||
|
antes de desplegar el dashboard
|
||||||
|
antes de activar cualquier nueva fase de readiness
|
||||||
|
```
|
||||||
|
|
||||||
|
La ejecución periódica puede programarse después de acordar la hora adecuada.
|
||||||
|
No se instala ningún cron automáticamente en esta versión.
|
||||||
|
|
||||||
|
## Dashboard
|
||||||
|
|
||||||
|
Se mantiene el mismo UID y se añaden:
|
||||||
|
|
||||||
|
```text
|
||||||
|
Última ejecución de regresión
|
||||||
|
Warnings y fallos
|
||||||
|
Histórico de regresión
|
||||||
|
```
|
||||||
|
|
||||||
|
Paneles totales esperados: 31.
|
||||||
|
|
||||||
|
|
||||||
|
## Hotfix 0.3.13.1
|
||||||
|
|
||||||
|
La vista `li_context_arbitration_conflicts_v1` es derivada del estado canónico
|
||||||
|
actual. Al corregir Odoo, el conflicto desaparece correctamente de esa vista.
|
||||||
|
|
||||||
|
Los contratos `CONFLICT-001` y `CONFLICT-002` pasan a verificar:
|
||||||
|
|
||||||
|
```text
|
||||||
|
episodios persistentes 7/8/9
|
||||||
|
caso raíz 6
|
||||||
|
decisión consolidada del doble arranque
|
||||||
|
cero elegibilidad oficial
|
||||||
|
```
|
||||||
|
|
||||||
|
No dependen ya de una ventana derivada vacía y dejan de ejecutar la consulta de
|
||||||
|
aproximadamente 18 segundos.
|
||||||
|
|
||||||
|
La migración 116 solo actualiza la versión de política y el mínimo esperado de
|
||||||
|
tests a 25. La migración 115 permanece inmutable.
|
||||||
@ -0,0 +1,209 @@
|
|||||||
|
# UCEPSA — Governance Review UI SHADOW v0.3.14
|
||||||
|
|
||||||
|
## Objetivo
|
||||||
|
|
||||||
|
Dar a Juan Pablo una interfaz de trabajo, separada de Grafana, para resolver
|
||||||
|
excepciones sin interpretar SQL, hashes ni códigos técnicos.
|
||||||
|
|
||||||
|
```text
|
||||||
|
Grafana
|
||||||
|
→ supervisión y auditoría
|
||||||
|
|
||||||
|
Governance Review UI
|
||||||
|
→ revisar y resolver
|
||||||
|
```
|
||||||
|
|
||||||
|
## URL prevista
|
||||||
|
|
||||||
|
```text
|
||||||
|
http://17.126.1.70:8091
|
||||||
|
```
|
||||||
|
|
||||||
|
La aplicación incluye autenticación, sesión firmada, CSRF, roles y auditoría.
|
||||||
|
|
||||||
|
## Roles
|
||||||
|
|
||||||
|
```text
|
||||||
|
PRODUCTION
|
||||||
|
→ decisiones de Producción y conjuntas
|
||||||
|
|
||||||
|
TECHNICAL
|
||||||
|
→ decisiones técnicas y conjuntas
|
||||||
|
|
||||||
|
ADMIN
|
||||||
|
→ todas las decisiones
|
||||||
|
```
|
||||||
|
|
||||||
|
## Funciones
|
||||||
|
|
||||||
|
```text
|
||||||
|
cola por decisión humana
|
||||||
|
estado de incidencia separado de estado de revisión
|
||||||
|
línea temporal por intervalos
|
||||||
|
evidencia técnica desplegable
|
||||||
|
formularios específicos por familia
|
||||||
|
selección de uno o varios casos
|
||||||
|
revisión en cascada de casos y episodios
|
||||||
|
auditoría por usuario
|
||||||
|
historial de acciones
|
||||||
|
```
|
||||||
|
|
||||||
|
## Contextos retrospectivos
|
||||||
|
|
||||||
|
La creación es opcional y exige confirmación explícita.
|
||||||
|
|
||||||
|
Solo se permite para:
|
||||||
|
|
||||||
|
```text
|
||||||
|
AUTHORIZED_LEGACY_PRODUCTION → LEGACY_ERP
|
||||||
|
TEST_OR_SETUP → TEST_SETUP
|
||||||
|
MAINTENANCE → MAINTENANCE
|
||||||
|
```
|
||||||
|
|
||||||
|
Condiciones:
|
||||||
|
|
||||||
|
```text
|
||||||
|
caso terminado
|
||||||
|
ended_at conocido
|
||||||
|
sin solapamiento con Odoo u otro contexto autoritativo
|
||||||
|
un contexto exacto por caso
|
||||||
|
sin cubrir huecos
|
||||||
|
official_eligible = false
|
||||||
|
```
|
||||||
|
|
||||||
|
## Lo que NO hace
|
||||||
|
|
||||||
|
```text
|
||||||
|
no cambia órdenes Odoo
|
||||||
|
no cierra workorders
|
||||||
|
no cambia cantidades
|
||||||
|
no edita telemetría raw
|
||||||
|
no activa el Ledger oficial
|
||||||
|
```
|
||||||
|
|
||||||
|
## Despliegue SQL
|
||||||
|
|
||||||
|
```bash
|
||||||
|
FILE=/srv/mesavault/40-clients/ucepsa/edge-oee-demo/sql/versions/117_ucepsa_governance_review_ui_shadow_v0314.sql
|
||||||
|
|
||||||
|
docker exec -i mv_ucepsa_postgres_hot sh -lc \
|
||||||
|
'psql -v ON_ERROR_STOP=1 -U "$POSTGRES_USER" -d "$POSTGRES_DB"' \
|
||||||
|
< "$FILE"
|
||||||
|
```
|
||||||
|
|
||||||
|
## Copiar aplicación al runtime
|
||||||
|
|
||||||
|
```bash
|
||||||
|
TARGET=/srv/mesavault/40-clients/ucepsa/edge-oee-demo
|
||||||
|
RUNTIME=/srv/mesavault/edge-oee-ucepsa
|
||||||
|
|
||||||
|
rm -rf "$RUNTIME/governance-ui"
|
||||||
|
mkdir -p "$RUNTIME/governance-ui"
|
||||||
|
|
||||||
|
cp -a \
|
||||||
|
"$TARGET/governance-ui/." \
|
||||||
|
"$RUNTIME/governance-ui/"
|
||||||
|
```
|
||||||
|
|
||||||
|
## Construir imagen y crear hashes
|
||||||
|
|
||||||
|
```bash
|
||||||
|
MANAGER=/srv/mesavault/40-clients/ucepsa/edge-oee-demo/ops/manage_governance_review_ui_v0314.sh
|
||||||
|
|
||||||
|
"$MANAGER" build
|
||||||
|
```
|
||||||
|
|
||||||
|
Generar un hash para cada usuario:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
docker run --rm -it \
|
||||||
|
--entrypoint python \
|
||||||
|
mesavault/ucepsa-governance-ui:0.3.14 \
|
||||||
|
/app/tools/generate_password_hash.py
|
||||||
|
```
|
||||||
|
|
||||||
|
Guardar únicamente los hashes en `.env`.
|
||||||
|
|
||||||
|
## Configuración
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd /srv/mesavault/edge-oee-ucepsa/governance-ui
|
||||||
|
|
||||||
|
cp .env.example .env
|
||||||
|
chmod 600 .env
|
||||||
|
nano .env
|
||||||
|
```
|
||||||
|
|
||||||
|
Generar el secreto:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
python3 - <<'PY'
|
||||||
|
import secrets
|
||||||
|
print(secrets.token_urlsafe(48))
|
||||||
|
PY
|
||||||
|
```
|
||||||
|
|
||||||
|
## Arranque
|
||||||
|
|
||||||
|
```bash
|
||||||
|
MANAGER=/srv/mesavault/40-clients/ucepsa/edge-oee-demo/ops/manage_governance_review_ui_v0314.sh
|
||||||
|
|
||||||
|
"$MANAGER" deploy
|
||||||
|
sleep 20
|
||||||
|
"$MANAGER" status
|
||||||
|
"$MANAGER" health
|
||||||
|
```
|
||||||
|
|
||||||
|
## Validación SQL
|
||||||
|
|
||||||
|
```bash
|
||||||
|
VALIDATION=/srv/mesavault/40-clients/ucepsa/edge-oee-demo/ops/validate_governance_review_ui_v0314.sql
|
||||||
|
|
||||||
|
docker exec -i mv_ucepsa_postgres_hot sh -lc \
|
||||||
|
'psql -v ON_ERROR_STOP=1 -U "$POSTGRES_USER" -d "$POSTGRES_DB"' \
|
||||||
|
< "$VALIDATION"
|
||||||
|
```
|
||||||
|
|
||||||
|
Valores obligatorios:
|
||||||
|
|
||||||
|
```text
|
||||||
|
ui_queue_rows = agenda_rows
|
||||||
|
invalid_ui_status_rows = 0
|
||||||
|
duplicate_ui_decisions = 0
|
||||||
|
pending_cases_without_timeline = 0
|
||||||
|
official_ui_action_rows = 0
|
||||||
|
official_ui_queue_rows = 0
|
||||||
|
official_operator_queue_rows = 0
|
||||||
|
```
|
||||||
|
|
||||||
|
## Primera prueba
|
||||||
|
|
||||||
|
1. Entrar como Víctor.
|
||||||
|
2. Abrir una decisión pendiente.
|
||||||
|
3. Comprobar la línea temporal y la evidencia.
|
||||||
|
4. No resolver todavía una decisión real.
|
||||||
|
5. Comprobar `/health`.
|
||||||
|
6. Validar que Grafana sigue en solo lectura.
|
||||||
|
|
||||||
|
La primera escritura real debe ser una respuesta confirmada por Juan Pablo.
|
||||||
|
|
||||||
|
|
||||||
|
## Activación en dos pasos
|
||||||
|
|
||||||
|
La variable inicial debe permanecer:
|
||||||
|
|
||||||
|
```text
|
||||||
|
GOVERNANCE_UI_WRITE_ENABLED=false
|
||||||
|
```
|
||||||
|
|
||||||
|
Primero se valida login, roles, cola, timeline y evidencia. Cuando Juan Pablo
|
||||||
|
acepte la interfaz:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
sed -i \
|
||||||
|
's/^GOVERNANCE_UI_WRITE_ENABLED=false/GOVERNANCE_UI_WRITE_ENABLED=true/' \
|
||||||
|
/srv/mesavault/edge-oee-ucepsa/governance-ui/.env
|
||||||
|
|
||||||
|
MANAGER=/srv/mesavault/40-clients/ucepsa/edge-oee-demo/ops/manage_governance_review_ui_v0314.sh
|
||||||
|
"$MANAGER" restart
|
||||||
|
```
|
||||||
@ -0,0 +1,230 @@
|
|||||||
|
# UCEPSA — Governance Review UI en dos etapas SHADOW v0.3.15
|
||||||
|
|
||||||
|
## Objetivo
|
||||||
|
|
||||||
|
Separar claramente las dos responsabilidades:
|
||||||
|
|
||||||
|
```text
|
||||||
|
Producción
|
||||||
|
→ explica qué ocurrió realmente en planta.
|
||||||
|
|
||||||
|
MESAVAULT / responsable técnico
|
||||||
|
→ decide si esa explicación puede convertirse en contexto canónico.
|
||||||
|
```
|
||||||
|
|
||||||
|
Juan Pablo deja de ver y utilizar la casilla técnica de creación de contexto.
|
||||||
|
Cuando una respuesta permite proponer un contexto, la aplicación crea una
|
||||||
|
propuesta pendiente de aprobación técnica, no el contexto definitivo.
|
||||||
|
|
||||||
|
## Novedades
|
||||||
|
|
||||||
|
```text
|
||||||
|
respuesta de Producción
|
||||||
|
→ revisión de decisión, casos y episodios
|
||||||
|
→ propuesta técnica cuando procede
|
||||||
|
→ aprobación / rechazo / solicitud de aclaración
|
||||||
|
→ creación de contexto solo al aprobar
|
||||||
|
```
|
||||||
|
|
||||||
|
Incluye además:
|
||||||
|
|
||||||
|
- estados visibles en lenguaje normal;
|
||||||
|
- `En consulta` para los casos cuya respuesta todavía no se conoce;
|
||||||
|
- aclaraciones entre Producción y el revisor técnico;
|
||||||
|
- idempotencia mediante una clave única por formulario;
|
||||||
|
- auditoría unificada;
|
||||||
|
- pruebas de roles y traducciones;
|
||||||
|
- tres paneles nuevos de Grafana.
|
||||||
|
|
||||||
|
## Estados
|
||||||
|
|
||||||
|
### Decisión de Producción
|
||||||
|
|
||||||
|
```text
|
||||||
|
Pendiente
|
||||||
|
En consulta
|
||||||
|
Resuelta
|
||||||
|
```
|
||||||
|
|
||||||
|
### Propuesta de contexto
|
||||||
|
|
||||||
|
```text
|
||||||
|
Pendiente
|
||||||
|
Requiere aclaración
|
||||||
|
Aprobada
|
||||||
|
Rechazada
|
||||||
|
```
|
||||||
|
|
||||||
|
## Contextos que pueden proponerse
|
||||||
|
|
||||||
|
```text
|
||||||
|
Producción con sistema antiguo
|
||||||
|
→ LEGACY_ERP
|
||||||
|
|
||||||
|
Prueba, ajuste o preparación
|
||||||
|
→ TEST_SETUP
|
||||||
|
|
||||||
|
Mantenimiento
|
||||||
|
→ MAINTENANCE
|
||||||
|
```
|
||||||
|
|
||||||
|
La propuesta no significa que el contexto esté creado.
|
||||||
|
|
||||||
|
## Validaciones antes de aprobar
|
||||||
|
|
||||||
|
La aplicación comprueba:
|
||||||
|
|
||||||
|
```text
|
||||||
|
intervalo terminado;
|
||||||
|
inicio y fin conocidos;
|
||||||
|
máquina identificada;
|
||||||
|
sin solapamiento con una sesión Odoo;
|
||||||
|
sin solapamiento con otro contexto explícito;
|
||||||
|
un contexto exacto por caso;
|
||||||
|
official_eligible = false.
|
||||||
|
```
|
||||||
|
|
||||||
|
## Despliegue
|
||||||
|
|
||||||
|
No reiniciar la aplicación mientras Juan Pablo tenga un formulario abierto.
|
||||||
|
Esperar a que termine la acción o indicarle que recargue después del despliegue.
|
||||||
|
|
||||||
|
### Migración 118
|
||||||
|
|
||||||
|
```bash
|
||||||
|
FILE=/srv/mesavault/40-clients/ucepsa/edge-oee-demo/sql/versions/118_ucepsa_governance_review_ui_two_stage_shadow_v0315.sql
|
||||||
|
|
||||||
|
docker exec -i mv_ucepsa_postgres_hot sh -lc \
|
||||||
|
'psql -v ON_ERROR_STOP=1 -U "$POSTGRES_USER" -d "$POSTGRES_DB"' \
|
||||||
|
< "$FILE"
|
||||||
|
```
|
||||||
|
|
||||||
|
Debe finalizar con:
|
||||||
|
|
||||||
|
```text
|
||||||
|
DO
|
||||||
|
COMMIT
|
||||||
|
```
|
||||||
|
|
||||||
|
### Instalar la aplicación conservando `.env`
|
||||||
|
|
||||||
|
```bash
|
||||||
|
INSTALLER=/srv/mesavault/40-clients/ucepsa/edge-oee-demo/ops/install_governance_review_ui_v0315.sh
|
||||||
|
|
||||||
|
"$INSTALLER"
|
||||||
|
```
|
||||||
|
|
||||||
|
### Construir y reiniciar
|
||||||
|
|
||||||
|
```bash
|
||||||
|
MANAGER=/srv/mesavault/40-clients/ucepsa/edge-oee-demo/ops/manage_governance_review_ui_v0315.sh
|
||||||
|
|
||||||
|
"$MANAGER" build
|
||||||
|
|
||||||
|
TESTS=/srv/mesavault/40-clients/ucepsa/edge-oee-demo/ops/test_governance_review_ui_v0315.sh
|
||||||
|
"$TESTS"
|
||||||
|
|
||||||
|
"$MANAGER" restart
|
||||||
|
sleep 15
|
||||||
|
"$MANAGER" status
|
||||||
|
"$MANAGER" health
|
||||||
|
```
|
||||||
|
|
||||||
|
El cambio de `SESSION_SALT` cerrará las sesiones anteriores. Los usuarios
|
||||||
|
deben volver a iniciar sesión después del despliegue.
|
||||||
|
|
||||||
|
## Validación SQL
|
||||||
|
|
||||||
|
```bash
|
||||||
|
VALIDATION=/srv/mesavault/40-clients/ucepsa/edge-oee-demo/ops/validate_governance_review_ui_v0315.sql
|
||||||
|
|
||||||
|
docker exec -i mv_ucepsa_postgres_hot sh -lc \
|
||||||
|
'psql -v ON_ERROR_STOP=1 -U "$POSTGRES_USER" -d "$POSTGRES_DB"' \
|
||||||
|
< "$VALIDATION"
|
||||||
|
```
|
||||||
|
|
||||||
|
Valores obligatorios:
|
||||||
|
|
||||||
|
```text
|
||||||
|
ui_v2_rows = agenda_rows
|
||||||
|
invalid_workflow_status_rows = 0
|
||||||
|
duplicate_open_consultations = 0
|
||||||
|
duplicate_active_proposals = 0
|
||||||
|
approved_without_context = 0
|
||||||
|
invalid_approved_contexts = 0
|
||||||
|
direct_context_creation_actions = 0
|
||||||
|
duplicate_review_requests = 0
|
||||||
|
duplicate_proposal_action_requests = 0
|
||||||
|
official_consultation_rows = 0
|
||||||
|
official_proposal_rows = 0
|
||||||
|
official_proposal_action_rows = 0
|
||||||
|
official_operator_queue_rows = 0
|
||||||
|
```
|
||||||
|
|
||||||
|
## Prueba funcional sin crear contexto
|
||||||
|
|
||||||
|
1. Entrar como Juan Pablo.
|
||||||
|
2. Abrir una decisión pendiente.
|
||||||
|
3. Comprobar que no aparece ninguna casilla para crear contexto.
|
||||||
|
4. Marcar una decisión real como `En consulta` y comprobar la cola.
|
||||||
|
5. Volver a Pendiente o resolverla cuando exista respuesta.
|
||||||
|
6. Confirmar que una respuesta `Sistema antiguo`, `Prueba` o `Mantenimiento`
|
||||||
|
crea una propuesta técnica, no un contexto.
|
||||||
|
7. Entrar como Víctor.
|
||||||
|
8. Abrir `Aprobación técnica`.
|
||||||
|
9. Validar o solicitar aclaración.
|
||||||
|
|
||||||
|
## Aprobación técnica
|
||||||
|
|
||||||
|
Al aprobar:
|
||||||
|
|
||||||
|
```text
|
||||||
|
propuesta PENDING
|
||||||
|
→ comprobación de solapamientos
|
||||||
|
→ producción_context_sessions
|
||||||
|
→ propuesta APPROVED
|
||||||
|
→ auditoría
|
||||||
|
```
|
||||||
|
|
||||||
|
Al solicitar aclaración:
|
||||||
|
|
||||||
|
```text
|
||||||
|
propuesta NEEDS_CLARIFICATION
|
||||||
|
→ aparece en Aclaraciones para Producción
|
||||||
|
→ Producción responde
|
||||||
|
→ propuesta vuelve a PENDING
|
||||||
|
```
|
||||||
|
|
||||||
|
## Idempotencia
|
||||||
|
|
||||||
|
Cada formulario lleva un UUID único. Un doble clic, reenvío o recarga no debe
|
||||||
|
crear dos revisiones ni dos contextos.
|
||||||
|
|
||||||
|
Las tablas aplican restricciones únicas sobre:
|
||||||
|
|
||||||
|
```text
|
||||||
|
review request_id
|
||||||
|
consultation request_id
|
||||||
|
proposal action request_id
|
||||||
|
proposal activa por case_id
|
||||||
|
```
|
||||||
|
|
||||||
|
## Grafana
|
||||||
|
|
||||||
|
Se mantienen los 31 paneles previos y se añaden:
|
||||||
|
|
||||||
|
```text
|
||||||
|
32 · Contextos pendientes de aprobación
|
||||||
|
33 · Decisiones en consulta
|
||||||
|
34 · Actividad de la interfaz · 30 días
|
||||||
|
```
|
||||||
|
|
||||||
|
Los paneles de decisiones utilizan `li_context_governance_ui_queue_v2`.
|
||||||
|
|
||||||
|
## Regla operativa
|
||||||
|
|
||||||
|
```text
|
||||||
|
Juan Pablo responde la realidad de planta.
|
||||||
|
Víctor valida la coherencia técnica.
|
||||||
|
Ninguna respuesta de Producción crea por sí sola un contexto autoritativo.
|
||||||
|
```
|
||||||
22
ucepsa/edge-oee-demo/env-examples/.env.odoo-sync.example
Normal file
22
ucepsa/edge-oee-demo/env-examples/.env.odoo-sync.example
Normal file
@ -0,0 +1,22 @@
|
|||||||
|
ODOO_URL=https://17.126.1.61
|
||||||
|
ODOO_DB=ucepsa_test_jp
|
||||||
|
ODOO_USER=mesavault_sync@local
|
||||||
|
ODOO_PASSWORD=CHANGE_ME
|
||||||
|
|
||||||
|
ODOO_WORKCENTER_CODE=CORT-01
|
||||||
|
ODOO_INSTANCE=odoo_ucepsa_test
|
||||||
|
ODOO_SYNC_INTERVAL_SECONDS=30
|
||||||
|
ODOO_WORKORDER_LIMIT=50
|
||||||
|
ODOO_WORKORDER_STATES=waiting,ready,progress,done
|
||||||
|
|
||||||
|
TENANT=ucepsa
|
||||||
|
SITE=ucepsa_test
|
||||||
|
MACHINE_ID=CORT-01
|
||||||
|
ASSET=revpi_oee_node_01
|
||||||
|
TARGET_CYCLE_RATE_PPM=10
|
||||||
|
|
||||||
|
PGHOST=mv_ucepsa_postgres_hot
|
||||||
|
PGPORT=5432
|
||||||
|
PGDATABASE=mesavault_ucepsa_hot
|
||||||
|
PGUSER=edge_oee_odoo_sync_writer
|
||||||
|
PGPASSWORD=CHANGE_ME
|
||||||
9
ucepsa/edge-oee-demo/env-examples/.env.operator.example
Normal file
9
ucepsa/edge-oee-demo/env-examples/.env.operator.example
Normal file
@ -0,0 +1,9 @@
|
|||||||
|
PGHOST=mv_ucepsa_postgres_hot
|
||||||
|
PGPORT=5432
|
||||||
|
PGDATABASE=mesavault_ucepsa_hot
|
||||||
|
PGUSER=edge_oee_operator_writer
|
||||||
|
PGPASSWORD=CHANGE_ME
|
||||||
|
|
||||||
|
APP_PORT=8080
|
||||||
|
APP_TITLE=MESAVAULT Edge-OEE - Consola de paros UCEPSA
|
||||||
|
DEFAULT_MACHINE_ID=CORT-01
|
||||||
18
ucepsa/edge-oee-demo/env-examples/.env.sink.example
Normal file
18
ucepsa/edge-oee-demo/env-examples/.env.sink.example
Normal file
@ -0,0 +1,18 @@
|
|||||||
|
MQTT_HOST=mv_ucepsa_mosquitto
|
||||||
|
MQTT_PORT=1883
|
||||||
|
MQTT_USER=revpi_ucepsa_edge_oee_01
|
||||||
|
MQTT_PASSWORD=CHANGE_ME
|
||||||
|
MQTT_TOPIC=vertical/ucepsa/ucepsa_test/edge_oee/+/+
|
||||||
|
|
||||||
|
PGHOST=mv_ucepsa_postgres_hot
|
||||||
|
PGPORT=5432
|
||||||
|
PGDATABASE=mesavault_ucepsa_hot
|
||||||
|
PGUSER=edge_oee_mqtt_sink_writer
|
||||||
|
PGPASSWORD=CHANGE_ME
|
||||||
|
|
||||||
|
TENANT=ucepsa
|
||||||
|
SITE=ucepsa_test
|
||||||
|
VERTICAL=edge_oee
|
||||||
|
ASSET=revpi_oee_node_01
|
||||||
|
MACHINE_ID=CORT-01
|
||||||
|
VALUE_HOUR_EUR=36
|
||||||
6
ucepsa/edge-oee-demo/governance-ui/.dockerignore
Normal file
6
ucepsa/edge-oee-demo/governance-ui/.dockerignore
Normal file
@ -0,0 +1,6 @@
|
|||||||
|
.env
|
||||||
|
.hash-venv
|
||||||
|
__pycache__
|
||||||
|
*.pyc
|
||||||
|
.pytest_cache
|
||||||
|
.git
|
||||||
9
ucepsa/edge-oee-demo/governance-ui/.env.example
Normal file
9
ucepsa/edge-oee-demo/governance-ui/.env.example
Normal file
@ -0,0 +1,9 @@
|
|||||||
|
GOVERNANCE_UI_TITLE=MESAVAULT · UCEPSA · Revisión de gobernanza
|
||||||
|
GOVERNANCE_UI_SESSION_SECRET=CAMBIAR_POR_UN_SECRETO_LARGO
|
||||||
|
GOVERNANCE_UI_SESSION_MAX_AGE_S=28800
|
||||||
|
GOVERNANCE_UI_COOKIE_SECURE=false
|
||||||
|
GOVERNANCE_UI_WRITE_ENABLED=false
|
||||||
|
|
||||||
|
# Generar cada password_hash con:
|
||||||
|
# python tools/generate_password_hash.py
|
||||||
|
GOVERNANCE_UI_USERS_JSON=[{"username":"juanpablo","display_name":"Juan Pablo","password_hash":"$2b$12$CAMBIAR","roles":["PRODUCTION"]},{"username":"victor","display_name":"Víctor Fraile","password_hash":"$2b$12$CAMBIAR","roles":["ADMIN","TECHNICAL","JOINT"]}]
|
||||||
26
ucepsa/edge-oee-demo/governance-ui/Dockerfile
Normal file
26
ucepsa/edge-oee-demo/governance-ui/Dockerfile
Normal file
@ -0,0 +1,26 @@
|
|||||||
|
FROM python:3.12-slim
|
||||||
|
|
||||||
|
ENV PYTHONDONTWRITEBYTECODE=1 \
|
||||||
|
PYTHONUNBUFFERED=1
|
||||||
|
|
||||||
|
WORKDIR /app
|
||||||
|
|
||||||
|
RUN useradd --create-home --uid 10001 appuser
|
||||||
|
|
||||||
|
COPY requirements.txt /app/requirements.txt
|
||||||
|
|
||||||
|
RUN pip install --no-cache-dir \
|
||||||
|
-r /app/requirements.txt
|
||||||
|
|
||||||
|
COPY app /app/app
|
||||||
|
COPY tools /app/tools
|
||||||
|
COPY tests /app/tests
|
||||||
|
|
||||||
|
USER appuser
|
||||||
|
|
||||||
|
EXPOSE 8080
|
||||||
|
|
||||||
|
HEALTHCHECK --interval=30s --timeout=5s --start-period=15s --retries=3 \
|
||||||
|
CMD python -c "import urllib.request; urllib.request.urlopen('http://127.0.0.1:8080/health', timeout=3)"
|
||||||
|
|
||||||
|
CMD ["uvicorn", "app.main:app", "--host", "0.0.0.0", "--port", "8080", "--proxy-headers"]
|
||||||
0
ucepsa/edge-oee-demo/governance-ui/app/__init__.py
Normal file
0
ucepsa/edge-oee-demo/governance-ui/app/__init__.py
Normal file
171
ucepsa/edge-oee-demo/governance-ui/app/auth.py
Normal file
171
ucepsa/edge-oee-demo/governance-ui/app/auth.py
Normal file
@ -0,0 +1,171 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import secrets
|
||||||
|
from dataclasses import dataclass
|
||||||
|
from typing import FrozenSet
|
||||||
|
|
||||||
|
import bcrypt
|
||||||
|
from fastapi import HTTPException, Request, status
|
||||||
|
from itsdangerous import BadSignature, SignatureExpired
|
||||||
|
from itsdangerous.url_safe import URLSafeTimedSerializer
|
||||||
|
|
||||||
|
from .settings import UserConfig, get_settings
|
||||||
|
from .policy import (
|
||||||
|
can_approve_context_roles,
|
||||||
|
can_manage_clarification_roles,
|
||||||
|
can_view_owner_roles,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
SESSION_COOKIE = "mv_governance_session"
|
||||||
|
SESSION_SALT = "mv-governance-ui-v0315"
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True)
|
||||||
|
class CurrentUser:
|
||||||
|
username: str
|
||||||
|
display_name: str
|
||||||
|
roles: FrozenSet[str]
|
||||||
|
csrf_token: str
|
||||||
|
|
||||||
|
|
||||||
|
def serializer() -> URLSafeTimedSerializer:
|
||||||
|
settings = get_settings()
|
||||||
|
return URLSafeTimedSerializer(
|
||||||
|
secret_key=settings.session_secret,
|
||||||
|
salt=SESSION_SALT,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def authenticate(
|
||||||
|
username: str,
|
||||||
|
password: str,
|
||||||
|
) -> UserConfig | None:
|
||||||
|
settings = get_settings()
|
||||||
|
user = settings.users.get(username)
|
||||||
|
|
||||||
|
if user is None:
|
||||||
|
return None
|
||||||
|
|
||||||
|
try:
|
||||||
|
valid = bcrypt.checkpw(
|
||||||
|
password.encode("utf-8"),
|
||||||
|
user.password_hash.encode("utf-8"),
|
||||||
|
)
|
||||||
|
except ValueError:
|
||||||
|
return None
|
||||||
|
|
||||||
|
return user if valid else None
|
||||||
|
|
||||||
|
|
||||||
|
def build_session_token(user: UserConfig) -> str:
|
||||||
|
payload = {
|
||||||
|
"username": user.username,
|
||||||
|
"display_name": user.display_name,
|
||||||
|
"roles": sorted(user.roles),
|
||||||
|
"csrf_token": secrets.token_urlsafe(32),
|
||||||
|
}
|
||||||
|
return serializer().dumps(payload)
|
||||||
|
|
||||||
|
|
||||||
|
def read_current_user(
|
||||||
|
request: Request,
|
||||||
|
) -> CurrentUser | None:
|
||||||
|
token = request.cookies.get(SESSION_COOKIE)
|
||||||
|
if not token:
|
||||||
|
return None
|
||||||
|
|
||||||
|
settings = get_settings()
|
||||||
|
|
||||||
|
try:
|
||||||
|
payload = serializer().loads(
|
||||||
|
token,
|
||||||
|
max_age=settings.session_max_age_s,
|
||||||
|
)
|
||||||
|
except (
|
||||||
|
BadSignature,
|
||||||
|
SignatureExpired,
|
||||||
|
):
|
||||||
|
return None
|
||||||
|
|
||||||
|
username = str(payload.get("username", ""))
|
||||||
|
configured = settings.users.get(username)
|
||||||
|
|
||||||
|
if configured is None:
|
||||||
|
return None
|
||||||
|
|
||||||
|
csrf_token = str(
|
||||||
|
payload.get("csrf_token", "")
|
||||||
|
)
|
||||||
|
if not csrf_token:
|
||||||
|
return None
|
||||||
|
|
||||||
|
return CurrentUser(
|
||||||
|
username=configured.username,
|
||||||
|
display_name=configured.display_name,
|
||||||
|
roles=configured.roles,
|
||||||
|
csrf_token=csrf_token,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def require_user(request: Request) -> CurrentUser:
|
||||||
|
user = read_current_user(request)
|
||||||
|
if user is None:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_401_UNAUTHORIZED,
|
||||||
|
detail="Sesión no válida.",
|
||||||
|
)
|
||||||
|
return user
|
||||||
|
|
||||||
|
|
||||||
|
def verify_csrf(
|
||||||
|
user: CurrentUser,
|
||||||
|
submitted_token: str,
|
||||||
|
) -> None:
|
||||||
|
if (
|
||||||
|
not submitted_token
|
||||||
|
or not secrets.compare_digest(
|
||||||
|
user.csrf_token,
|
||||||
|
submitted_token,
|
||||||
|
)
|
||||||
|
):
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_403_FORBIDDEN,
|
||||||
|
detail="Token CSRF no válido.",
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def can_view_owner(
|
||||||
|
user: CurrentUser,
|
||||||
|
review_owner: str,
|
||||||
|
) -> bool:
|
||||||
|
return can_view_owner_roles(
|
||||||
|
user.roles,
|
||||||
|
review_owner,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def can_review_owner(
|
||||||
|
user: CurrentUser,
|
||||||
|
review_owner: str,
|
||||||
|
) -> bool:
|
||||||
|
return can_view_owner(
|
||||||
|
user,
|
||||||
|
review_owner,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def can_approve_context(
|
||||||
|
user: CurrentUser,
|
||||||
|
) -> bool:
|
||||||
|
return can_approve_context_roles(
|
||||||
|
user.roles
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def can_manage_clarification(
|
||||||
|
user: CurrentUser,
|
||||||
|
) -> bool:
|
||||||
|
return can_manage_clarification_roles(
|
||||||
|
user.roles
|
||||||
|
)
|
||||||
34
ucepsa/edge-oee-demo/governance-ui/app/db.py
Normal file
34
ucepsa/edge-oee-demo/governance-ui/app/db.py
Normal file
@ -0,0 +1,34 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from contextlib import contextmanager
|
||||||
|
from typing import Iterator
|
||||||
|
|
||||||
|
import psycopg2
|
||||||
|
from psycopg2.extensions import connection as Connection
|
||||||
|
from psycopg2.extras import RealDictCursor
|
||||||
|
|
||||||
|
from .settings import get_settings
|
||||||
|
|
||||||
|
|
||||||
|
@contextmanager
|
||||||
|
def get_connection() -> Iterator[Connection]:
|
||||||
|
settings = get_settings()
|
||||||
|
connection = psycopg2.connect(
|
||||||
|
host=settings.pg_host,
|
||||||
|
port=settings.pg_port,
|
||||||
|
dbname=settings.pg_database,
|
||||||
|
user=settings.pg_user,
|
||||||
|
password=settings.pg_password,
|
||||||
|
connect_timeout=10,
|
||||||
|
application_name="governance_review_ui_v0315",
|
||||||
|
)
|
||||||
|
try:
|
||||||
|
yield connection
|
||||||
|
finally:
|
||||||
|
connection.close()
|
||||||
|
|
||||||
|
|
||||||
|
def dict_cursor(connection: Connection) -> RealDictCursor:
|
||||||
|
return connection.cursor(
|
||||||
|
cursor_factory=RealDictCursor
|
||||||
|
)
|
||||||
68
ucepsa/edge-oee-demo/governance-ui/app/labels.py
Normal file
68
ucepsa/edge-oee-demo/governance-ui/app/labels.py
Normal file
@ -0,0 +1,68 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
|
||||||
|
LABELS = {
|
||||||
|
"RECENT": "Reciente",
|
||||||
|
"ATTENTION": "Requiere atención",
|
||||||
|
"OVERDUE": "Vencida",
|
||||||
|
"APPLIED": "Aplicada",
|
||||||
|
"ERROR": "Error",
|
||||||
|
"PRODUCTION_REVIEW": "Revisión de Producción",
|
||||||
|
"CONSULTATION": "Consulta",
|
||||||
|
"CONTEXT_PROPOSAL": "Propuesta de contexto",
|
||||||
|
"MARK_IN_CONSULTATION": "Marcada En consulta",
|
||||||
|
"CLOSE_CONSULTATION": "Consulta cerrada",
|
||||||
|
"WARNING": "Aviso",
|
||||||
|
"CRITICAL": "Crítica",
|
||||||
|
"PENDING": "Pendiente",
|
||||||
|
"REVIEWED": "Resuelta",
|
||||||
|
"DISMISSED": "Descartada",
|
||||||
|
"NOT_REQUIRED": "No requiere revisión",
|
||||||
|
"OPEN": "Activa",
|
||||||
|
"CLOSED": "Terminada",
|
||||||
|
"ACTIVE": "Activa",
|
||||||
|
"ENDED": "Terminada",
|
||||||
|
"IN_CONSULTATION": "En consulta",
|
||||||
|
"PRODUCTION": "Producción",
|
||||||
|
"TECHNICAL": "Técnico",
|
||||||
|
"JOINT": "Conjunto",
|
||||||
|
"ADMIN": "Administrador",
|
||||||
|
"AUTHORIZED_LEGACY_PRODUCTION":
|
||||||
|
"Producción con el sistema antiguo",
|
||||||
|
"ODOO_START_OMITTED":
|
||||||
|
"Producción real, pero se olvidó iniciar Odoo",
|
||||||
|
"TEST_OR_SETUP": "Prueba, ajuste o preparación",
|
||||||
|
"RESIDUAL_MATERIAL":
|
||||||
|
"Material residual o vaciado de máquina",
|
||||||
|
"MAINTENANCE": "Mantenimiento",
|
||||||
|
"NOT_PRODUCTION": "No era producción",
|
||||||
|
"NOT_RELEVANT": "No era producción",
|
||||||
|
"ODOO_DOUBLE_START_ONE_VALID":
|
||||||
|
"Una orden era válida y la otra quedó abierta por error",
|
||||||
|
"ODOO_DOUBLE_START_BOTH_INVALID":
|
||||||
|
"Ninguna de las órdenes era válida",
|
||||||
|
"ODOO_CONSECUTIVE_ORDERS_NOT_CLOSED":
|
||||||
|
"Eran órdenes consecutivas y la anterior no se cerró",
|
||||||
|
"DATA_ISSUE": "Incidencia de datos o configuración",
|
||||||
|
"OTHER": "Otro",
|
||||||
|
"LEGACY_ERP": "Sistema antiguo",
|
||||||
|
"TEST_SETUP": "Prueba o ajuste",
|
||||||
|
"APPROVED": "Aprobado",
|
||||||
|
"REJECTED": "Rechazado",
|
||||||
|
"NEEDS_CLARIFICATION": "Requiere aclaración",
|
||||||
|
"CANCELLED": "Cancelado",
|
||||||
|
"APPROVE": "Aprobar contexto",
|
||||||
|
"REJECT": "Rechazar propuesta",
|
||||||
|
"REQUEST_CLARIFICATION": "Solicitar aclaración",
|
||||||
|
"CLARIFICATION_RESPONSE": "Aclaración respondida",
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def label(value: object) -> str:
|
||||||
|
if value is None:
|
||||||
|
return "—"
|
||||||
|
raw = str(value)
|
||||||
|
return LABELS.get(
|
||||||
|
raw,
|
||||||
|
raw.replace("_", " ").capitalize(),
|
||||||
|
)
|
||||||
1072
ucepsa/edge-oee-demo/governance-ui/app/main.py
Normal file
1072
ucepsa/edge-oee-demo/governance-ui/app/main.py
Normal file
File diff suppressed because it is too large
Load Diff
49
ucepsa/edge-oee-demo/governance-ui/app/policy.py
Normal file
49
ucepsa/edge-oee-demo/governance-ui/app/policy.py
Normal file
@ -0,0 +1,49 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from typing import AbstractSet
|
||||||
|
|
||||||
|
|
||||||
|
def can_view_owner_roles(
|
||||||
|
roles: AbstractSet[str],
|
||||||
|
review_owner: str,
|
||||||
|
) -> bool:
|
||||||
|
if "ADMIN" in roles:
|
||||||
|
return True
|
||||||
|
if review_owner == "PRODUCTION":
|
||||||
|
return "PRODUCTION" in roles
|
||||||
|
if review_owner == "TECHNICAL":
|
||||||
|
return "TECHNICAL" in roles
|
||||||
|
if review_owner == "JOINT":
|
||||||
|
return bool(
|
||||||
|
roles
|
||||||
|
& {
|
||||||
|
"PRODUCTION",
|
||||||
|
"TECHNICAL",
|
||||||
|
"JOINT",
|
||||||
|
}
|
||||||
|
)
|
||||||
|
return False
|
||||||
|
|
||||||
|
|
||||||
|
def can_approve_context_roles(
|
||||||
|
roles: AbstractSet[str],
|
||||||
|
) -> bool:
|
||||||
|
return bool(
|
||||||
|
roles
|
||||||
|
& {
|
||||||
|
"TECHNICAL",
|
||||||
|
"ADMIN",
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def can_manage_clarification_roles(
|
||||||
|
roles: AbstractSet[str],
|
||||||
|
) -> bool:
|
||||||
|
return bool(
|
||||||
|
roles
|
||||||
|
& {
|
||||||
|
"PRODUCTION",
|
||||||
|
"ADMIN",
|
||||||
|
}
|
||||||
|
)
|
||||||
597
ucepsa/edge-oee-demo/governance-ui/app/proposal_service.py
Normal file
597
ucepsa/edge-oee-demo/governance-ui/app/proposal_service.py
Normal file
@ -0,0 +1,597 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import uuid
|
||||||
|
from dataclasses import dataclass
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
from psycopg2.extras import Json, RealDictCursor
|
||||||
|
|
||||||
|
from .auth import CurrentUser
|
||||||
|
from .db import get_connection
|
||||||
|
from .settings import get_settings
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True)
|
||||||
|
class ProposalActionInput:
|
||||||
|
submission_key: uuid.UUID
|
||||||
|
proposal_id: int
|
||||||
|
action_code: str
|
||||||
|
notes: str
|
||||||
|
|
||||||
|
|
||||||
|
class ProposalError(RuntimeError):
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
def json_safe(value: Any) -> Any:
|
||||||
|
if isinstance(value, dict):
|
||||||
|
return {
|
||||||
|
str(key): json_safe(item)
|
||||||
|
for key, item in value.items()
|
||||||
|
}
|
||||||
|
if isinstance(value, (list, tuple, set)):
|
||||||
|
return [
|
||||||
|
json_safe(item)
|
||||||
|
for item in value
|
||||||
|
]
|
||||||
|
if hasattr(value, "isoformat"):
|
||||||
|
return value.isoformat()
|
||||||
|
return value
|
||||||
|
|
||||||
|
|
||||||
|
def fetch_proposal_for_update(
|
||||||
|
cursor: RealDictCursor,
|
||||||
|
proposal_id: int,
|
||||||
|
) -> dict[str, Any]:
|
||||||
|
cursor.execute(
|
||||||
|
"""
|
||||||
|
SELECT p.*
|
||||||
|
FROM
|
||||||
|
mv_loss_intelligence
|
||||||
|
.context_governance_context_proposals raw
|
||||||
|
JOIN
|
||||||
|
mv_reports_ucepsa_prod
|
||||||
|
.li_context_governance_context_proposals_v1 p
|
||||||
|
ON p.proposal_id = raw.proposal_id
|
||||||
|
WHERE raw.proposal_id = %s
|
||||||
|
FOR UPDATE OF raw
|
||||||
|
""",
|
||||||
|
(proposal_id,),
|
||||||
|
)
|
||||||
|
row = cursor.fetchone()
|
||||||
|
if not row:
|
||||||
|
raise ProposalError(
|
||||||
|
"La propuesta no existe."
|
||||||
|
)
|
||||||
|
return dict(row)
|
||||||
|
|
||||||
|
|
||||||
|
def existing_action(
|
||||||
|
cursor: RealDictCursor,
|
||||||
|
settings,
|
||||||
|
submission_key: uuid.UUID,
|
||||||
|
) -> dict[str, Any] | None:
|
||||||
|
cursor.execute(
|
||||||
|
"""
|
||||||
|
SELECT *
|
||||||
|
FROM
|
||||||
|
mv_loss_intelligence
|
||||||
|
.context_governance_context_proposal_actions
|
||||||
|
WHERE tenant = %s
|
||||||
|
AND site = %s
|
||||||
|
AND request_id = %s
|
||||||
|
""",
|
||||||
|
(
|
||||||
|
settings.tenant,
|
||||||
|
settings.site,
|
||||||
|
str(submission_key),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
row = cursor.fetchone()
|
||||||
|
return dict(row) if row else None
|
||||||
|
|
||||||
|
|
||||||
|
def count_authoritative_overlaps(
|
||||||
|
cursor: RealDictCursor,
|
||||||
|
proposal: dict[str, Any],
|
||||||
|
) -> int:
|
||||||
|
cursor.execute(
|
||||||
|
"""
|
||||||
|
SELECT COUNT(*) AS overlap_count
|
||||||
|
FROM (
|
||||||
|
SELECT 1
|
||||||
|
FROM
|
||||||
|
mv_loss_intelligence
|
||||||
|
.production_context_sessions s
|
||||||
|
WHERE s.machine_id = %s
|
||||||
|
AND s.started_at < %s
|
||||||
|
AND COALESCE(
|
||||||
|
s.ended_at,
|
||||||
|
'infinity'::timestamptz
|
||||||
|
) > %s
|
||||||
|
|
||||||
|
UNION ALL
|
||||||
|
|
||||||
|
SELECT 1
|
||||||
|
FROM
|
||||||
|
mv_loss_intelligence
|
||||||
|
.odoo_shopfloor_sessions o
|
||||||
|
WHERE o.machine_id = %s
|
||||||
|
AND o.started_at IS NOT NULL
|
||||||
|
AND o.started_at < %s
|
||||||
|
AND COALESCE(
|
||||||
|
o.ended_at,
|
||||||
|
'infinity'::timestamptz
|
||||||
|
) > %s
|
||||||
|
AND o.session_status IN (
|
||||||
|
'OPEN',
|
||||||
|
'CLOSED'
|
||||||
|
)
|
||||||
|
) overlaps
|
||||||
|
""",
|
||||||
|
(
|
||||||
|
proposal["machine_id"],
|
||||||
|
proposal["ended_at"],
|
||||||
|
proposal["first_seen_at"],
|
||||||
|
proposal["machine_id"],
|
||||||
|
proposal["ended_at"],
|
||||||
|
proposal["first_seen_at"],
|
||||||
|
),
|
||||||
|
)
|
||||||
|
return int(
|
||||||
|
cursor.fetchone()["overlap_count"]
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def insert_proposal_action(
|
||||||
|
cursor: RealDictCursor,
|
||||||
|
settings,
|
||||||
|
action: ProposalActionInput,
|
||||||
|
proposal: dict[str, Any],
|
||||||
|
user: CurrentUser,
|
||||||
|
resulting_status: str,
|
||||||
|
context_session_id: int | None,
|
||||||
|
source_ip: str | None,
|
||||||
|
user_agent: str | None,
|
||||||
|
) -> int:
|
||||||
|
cursor.execute(
|
||||||
|
"""
|
||||||
|
INSERT INTO
|
||||||
|
mv_loss_intelligence
|
||||||
|
.context_governance_context_proposal_actions (
|
||||||
|
tenant,
|
||||||
|
site,
|
||||||
|
request_id,
|
||||||
|
proposal_id,
|
||||||
|
action_code,
|
||||||
|
previous_status,
|
||||||
|
resulting_status,
|
||||||
|
actor_username,
|
||||||
|
actor_display_name,
|
||||||
|
actor_roles,
|
||||||
|
notes,
|
||||||
|
context_session_id,
|
||||||
|
source_ip,
|
||||||
|
user_agent,
|
||||||
|
official_eligible
|
||||||
|
)
|
||||||
|
VALUES (
|
||||||
|
%s, %s, %s, %s, %s,
|
||||||
|
%s, %s, %s, %s, %s,
|
||||||
|
%s, %s, %s, %s, false
|
||||||
|
)
|
||||||
|
RETURNING proposal_action_id
|
||||||
|
""",
|
||||||
|
(
|
||||||
|
settings.tenant,
|
||||||
|
settings.site,
|
||||||
|
str(action.submission_key),
|
||||||
|
action.proposal_id,
|
||||||
|
action.action_code,
|
||||||
|
proposal["proposal_status"],
|
||||||
|
resulting_status,
|
||||||
|
user.username,
|
||||||
|
user.display_name,
|
||||||
|
sorted(user.roles),
|
||||||
|
action.notes.strip() or None,
|
||||||
|
context_session_id,
|
||||||
|
source_ip,
|
||||||
|
user_agent,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
return int(
|
||||||
|
cursor.fetchone()[
|
||||||
|
"proposal_action_id"
|
||||||
|
]
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def approve_proposal(
|
||||||
|
action: ProposalActionInput,
|
||||||
|
user: CurrentUser,
|
||||||
|
source_ip: str | None,
|
||||||
|
user_agent: str | None,
|
||||||
|
) -> dict[str, Any]:
|
||||||
|
settings = get_settings()
|
||||||
|
|
||||||
|
with get_connection() as connection:
|
||||||
|
connection.autocommit = False
|
||||||
|
try:
|
||||||
|
with connection.cursor(
|
||||||
|
cursor_factory=RealDictCursor,
|
||||||
|
) as cursor:
|
||||||
|
cursor.execute(
|
||||||
|
"""
|
||||||
|
SELECT pg_advisory_xact_lock(
|
||||||
|
hashtext(%s)
|
||||||
|
)
|
||||||
|
""",
|
||||||
|
(
|
||||||
|
(
|
||||||
|
f"{settings.tenant}:"
|
||||||
|
f"{settings.site}:"
|
||||||
|
f"{action.submission_key}"
|
||||||
|
),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
replay = existing_action(
|
||||||
|
cursor,
|
||||||
|
settings,
|
||||||
|
action.submission_key,
|
||||||
|
)
|
||||||
|
if replay:
|
||||||
|
connection.rollback()
|
||||||
|
return {
|
||||||
|
"idempotent_replay": True,
|
||||||
|
"proposal_action_id":
|
||||||
|
replay[
|
||||||
|
"proposal_action_id"
|
||||||
|
],
|
||||||
|
"context_session_id":
|
||||||
|
replay[
|
||||||
|
"context_session_id"
|
||||||
|
],
|
||||||
|
}
|
||||||
|
|
||||||
|
proposal = fetch_proposal_for_update(
|
||||||
|
cursor,
|
||||||
|
action.proposal_id,
|
||||||
|
)
|
||||||
|
|
||||||
|
if proposal["proposal_status"] != "PENDING":
|
||||||
|
raise ProposalError(
|
||||||
|
"Solo pueden aprobarse propuestas pendientes."
|
||||||
|
)
|
||||||
|
|
||||||
|
if (
|
||||||
|
proposal["case_status"] !=
|
||||||
|
"CLOSED"
|
||||||
|
or proposal["ended_at"] is None
|
||||||
|
):
|
||||||
|
raise ProposalError(
|
||||||
|
"El intervalo todavía no ha terminado."
|
||||||
|
)
|
||||||
|
|
||||||
|
overlap_count = count_authoritative_overlaps(
|
||||||
|
cursor,
|
||||||
|
proposal,
|
||||||
|
)
|
||||||
|
|
||||||
|
if overlap_count > 0:
|
||||||
|
raise ProposalError(
|
||||||
|
"El intervalo se solapa con otro contexto autoritativo. "
|
||||||
|
"La propuesta no puede aprobarse."
|
||||||
|
)
|
||||||
|
|
||||||
|
source_ref = (
|
||||||
|
"GOVERNANCE_PROPOSAL:"
|
||||||
|
f"{proposal['proposal_id']}"
|
||||||
|
)
|
||||||
|
|
||||||
|
cursor.execute(
|
||||||
|
"""
|
||||||
|
SELECT context_session_id
|
||||||
|
FROM
|
||||||
|
mv_loss_intelligence
|
||||||
|
.production_context_sessions
|
||||||
|
WHERE source_ref = %s
|
||||||
|
""",
|
||||||
|
(source_ref,),
|
||||||
|
)
|
||||||
|
existing_context = cursor.fetchone()
|
||||||
|
|
||||||
|
if existing_context:
|
||||||
|
context_session_id = int(
|
||||||
|
existing_context[
|
||||||
|
"context_session_id"
|
||||||
|
]
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
cursor.execute(
|
||||||
|
"""
|
||||||
|
INSERT INTO
|
||||||
|
mv_loss_intelligence
|
||||||
|
.production_context_sessions (
|
||||||
|
tenant,
|
||||||
|
site,
|
||||||
|
machine_id,
|
||||||
|
source_type,
|
||||||
|
source_ref,
|
||||||
|
started_at,
|
||||||
|
ended_at,
|
||||||
|
status,
|
||||||
|
authorization_status,
|
||||||
|
confidence,
|
||||||
|
official_eligible,
|
||||||
|
authorized_by,
|
||||||
|
authorized_at,
|
||||||
|
closed_by,
|
||||||
|
notes,
|
||||||
|
evidence_json
|
||||||
|
)
|
||||||
|
VALUES (
|
||||||
|
%s, %s, %s, %s, %s,
|
||||||
|
%s, %s, 'CLOSED',
|
||||||
|
'AUTHORIZED', 'MEDIUM', false,
|
||||||
|
%s, now(), %s, %s, %s
|
||||||
|
)
|
||||||
|
RETURNING context_session_id
|
||||||
|
""",
|
||||||
|
(
|
||||||
|
settings.tenant,
|
||||||
|
settings.site,
|
||||||
|
proposal["machine_id"],
|
||||||
|
proposal[
|
||||||
|
"proposed_source_type"
|
||||||
|
],
|
||||||
|
source_ref,
|
||||||
|
proposal["first_seen_at"],
|
||||||
|
proposal["ended_at"],
|
||||||
|
user.display_name,
|
||||||
|
user.display_name,
|
||||||
|
action.notes.strip()
|
||||||
|
or proposal.get(
|
||||||
|
"technical_review_notes"
|
||||||
|
)
|
||||||
|
or "Aprobación técnica desde Governance Review UI.",
|
||||||
|
Json({
|
||||||
|
"created_with":
|
||||||
|
"governance_review_ui_v0315",
|
||||||
|
"proposal_id":
|
||||||
|
proposal["proposal_id"],
|
||||||
|
"proposal_key":
|
||||||
|
proposal["proposal_key"],
|
||||||
|
"decision_group_key":
|
||||||
|
proposal[
|
||||||
|
"decision_group_key"
|
||||||
|
],
|
||||||
|
"case_id":
|
||||||
|
proposal["case_id"],
|
||||||
|
"answer_code":
|
||||||
|
proposal["answer_code"],
|
||||||
|
"proposed_by":
|
||||||
|
proposal[
|
||||||
|
"proposed_by_display_name"
|
||||||
|
],
|
||||||
|
"approved_by":
|
||||||
|
user.display_name,
|
||||||
|
"deployment_mode":
|
||||||
|
"SHADOW",
|
||||||
|
}),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
context_session_id = int(
|
||||||
|
cursor.fetchone()[
|
||||||
|
"context_session_id"
|
||||||
|
]
|
||||||
|
)
|
||||||
|
|
||||||
|
cursor.execute(
|
||||||
|
"""
|
||||||
|
UPDATE
|
||||||
|
mv_loss_intelligence
|
||||||
|
.context_governance_context_proposals
|
||||||
|
SET
|
||||||
|
proposal_status = 'APPROVED',
|
||||||
|
technical_review_by_username = %s,
|
||||||
|
technical_review_by_display_name = %s,
|
||||||
|
technical_review_at = now(),
|
||||||
|
technical_review_notes = %s,
|
||||||
|
context_session_id = %s,
|
||||||
|
updated_at = now()
|
||||||
|
WHERE proposal_id = %s
|
||||||
|
""",
|
||||||
|
(
|
||||||
|
user.username,
|
||||||
|
user.display_name,
|
||||||
|
action.notes.strip() or None,
|
||||||
|
context_session_id,
|
||||||
|
action.proposal_id,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
proposal_action_id = insert_proposal_action(
|
||||||
|
cursor,
|
||||||
|
settings,
|
||||||
|
action,
|
||||||
|
proposal,
|
||||||
|
user,
|
||||||
|
"APPROVED",
|
||||||
|
context_session_id,
|
||||||
|
source_ip,
|
||||||
|
user_agent,
|
||||||
|
)
|
||||||
|
|
||||||
|
connection.commit()
|
||||||
|
return {
|
||||||
|
"idempotent_replay": False,
|
||||||
|
"proposal_action_id":
|
||||||
|
proposal_action_id,
|
||||||
|
"context_session_id":
|
||||||
|
context_session_id,
|
||||||
|
}
|
||||||
|
|
||||||
|
except Exception:
|
||||||
|
connection.rollback()
|
||||||
|
raise
|
||||||
|
|
||||||
|
|
||||||
|
def change_proposal_status(
|
||||||
|
action: ProposalActionInput,
|
||||||
|
user: CurrentUser,
|
||||||
|
source_ip: str | None,
|
||||||
|
user_agent: str | None,
|
||||||
|
) -> dict[str, Any]:
|
||||||
|
settings = get_settings()
|
||||||
|
|
||||||
|
target_status = {
|
||||||
|
"REJECT": "REJECTED",
|
||||||
|
"REQUEST_CLARIFICATION":
|
||||||
|
"NEEDS_CLARIFICATION",
|
||||||
|
"CLARIFICATION_RESPONSE":
|
||||||
|
"PENDING",
|
||||||
|
}.get(action.action_code)
|
||||||
|
|
||||||
|
if target_status is None:
|
||||||
|
raise ProposalError(
|
||||||
|
"Acción de propuesta no permitida."
|
||||||
|
)
|
||||||
|
|
||||||
|
if not action.notes.strip():
|
||||||
|
raise ProposalError(
|
||||||
|
"Debe escribir una explicación."
|
||||||
|
)
|
||||||
|
|
||||||
|
with get_connection() as connection:
|
||||||
|
connection.autocommit = False
|
||||||
|
try:
|
||||||
|
with connection.cursor(
|
||||||
|
cursor_factory=RealDictCursor,
|
||||||
|
) as cursor:
|
||||||
|
cursor.execute(
|
||||||
|
"""
|
||||||
|
SELECT pg_advisory_xact_lock(
|
||||||
|
hashtext(%s)
|
||||||
|
)
|
||||||
|
""",
|
||||||
|
(
|
||||||
|
(
|
||||||
|
f"{settings.tenant}:"
|
||||||
|
f"{settings.site}:"
|
||||||
|
f"{action.submission_key}"
|
||||||
|
),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
replay = existing_action(
|
||||||
|
cursor,
|
||||||
|
settings,
|
||||||
|
action.submission_key,
|
||||||
|
)
|
||||||
|
if replay:
|
||||||
|
connection.rollback()
|
||||||
|
return {
|
||||||
|
"idempotent_replay": True,
|
||||||
|
"proposal_action_id":
|
||||||
|
replay[
|
||||||
|
"proposal_action_id"
|
||||||
|
],
|
||||||
|
"resulting_status":
|
||||||
|
replay[
|
||||||
|
"resulting_status"
|
||||||
|
],
|
||||||
|
}
|
||||||
|
|
||||||
|
proposal = fetch_proposal_for_update(
|
||||||
|
cursor,
|
||||||
|
action.proposal_id,
|
||||||
|
)
|
||||||
|
|
||||||
|
expected_status = (
|
||||||
|
"NEEDS_CLARIFICATION"
|
||||||
|
if action.action_code ==
|
||||||
|
"CLARIFICATION_RESPONSE"
|
||||||
|
else "PENDING"
|
||||||
|
)
|
||||||
|
|
||||||
|
if proposal["proposal_status"] != expected_status:
|
||||||
|
raise ProposalError(
|
||||||
|
"La propuesta ha cambiado de estado. Recargue la página."
|
||||||
|
)
|
||||||
|
|
||||||
|
cursor.execute(
|
||||||
|
"""
|
||||||
|
UPDATE
|
||||||
|
mv_loss_intelligence
|
||||||
|
.context_governance_context_proposals
|
||||||
|
SET
|
||||||
|
proposal_status = %s,
|
||||||
|
technical_review_by_username =
|
||||||
|
CASE
|
||||||
|
WHEN %s =
|
||||||
|
'CLARIFICATION_RESPONSE'
|
||||||
|
THEN technical_review_by_username
|
||||||
|
ELSE %s
|
||||||
|
END,
|
||||||
|
technical_review_by_display_name =
|
||||||
|
CASE
|
||||||
|
WHEN %s =
|
||||||
|
'CLARIFICATION_RESPONSE'
|
||||||
|
THEN technical_review_by_display_name
|
||||||
|
ELSE %s
|
||||||
|
END,
|
||||||
|
technical_review_at =
|
||||||
|
CASE
|
||||||
|
WHEN %s =
|
||||||
|
'CLARIFICATION_RESPONSE'
|
||||||
|
THEN technical_review_at
|
||||||
|
ELSE now()
|
||||||
|
END,
|
||||||
|
technical_review_notes =
|
||||||
|
CASE
|
||||||
|
WHEN %s =
|
||||||
|
'CLARIFICATION_RESPONSE'
|
||||||
|
THEN technical_review_notes
|
||||||
|
ELSE %s
|
||||||
|
END,
|
||||||
|
updated_at = now()
|
||||||
|
WHERE proposal_id = %s
|
||||||
|
""",
|
||||||
|
(
|
||||||
|
target_status,
|
||||||
|
action.action_code,
|
||||||
|
user.username,
|
||||||
|
action.action_code,
|
||||||
|
user.display_name,
|
||||||
|
action.action_code,
|
||||||
|
action.action_code,
|
||||||
|
action.notes.strip(),
|
||||||
|
action.proposal_id,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
proposal_action_id = insert_proposal_action(
|
||||||
|
cursor,
|
||||||
|
settings,
|
||||||
|
action,
|
||||||
|
proposal,
|
||||||
|
user,
|
||||||
|
target_status,
|
||||||
|
None,
|
||||||
|
source_ip,
|
||||||
|
user_agent,
|
||||||
|
)
|
||||||
|
|
||||||
|
connection.commit()
|
||||||
|
return {
|
||||||
|
"idempotent_replay": False,
|
||||||
|
"proposal_action_id":
|
||||||
|
proposal_action_id,
|
||||||
|
"resulting_status":
|
||||||
|
target_status,
|
||||||
|
}
|
||||||
|
|
||||||
|
except Exception:
|
||||||
|
connection.rollback()
|
||||||
|
raise
|
||||||
1258
ucepsa/edge-oee-demo/governance-ui/app/review_service.py
Normal file
1258
ucepsa/edge-oee-demo/governance-ui/app/review_service.py
Normal file
File diff suppressed because it is too large
Load Diff
220
ucepsa/edge-oee-demo/governance-ui/app/settings.py
Normal file
220
ucepsa/edge-oee-demo/governance-ui/app/settings.py
Normal file
@ -0,0 +1,220 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import json
|
||||||
|
import os
|
||||||
|
from dataclasses import dataclass
|
||||||
|
from functools import lru_cache
|
||||||
|
from typing import FrozenSet
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True)
|
||||||
|
class UserConfig:
|
||||||
|
username: str
|
||||||
|
display_name: str
|
||||||
|
password_hash: str
|
||||||
|
roles: FrozenSet[str]
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True)
|
||||||
|
class Settings:
|
||||||
|
app_title: str
|
||||||
|
tenant: str
|
||||||
|
site: str
|
||||||
|
pg_host: str
|
||||||
|
pg_port: int
|
||||||
|
pg_database: str
|
||||||
|
pg_user: str
|
||||||
|
pg_password: str
|
||||||
|
session_secret: str
|
||||||
|
session_max_age_s: int
|
||||||
|
cookie_secure: bool
|
||||||
|
write_enabled: bool
|
||||||
|
users: dict[str, UserConfig]
|
||||||
|
|
||||||
|
|
||||||
|
def env_first(*names: str, default: str | None = None) -> str | None:
|
||||||
|
for name in names:
|
||||||
|
value = os.getenv(name)
|
||||||
|
if value not in (None, ""):
|
||||||
|
return value
|
||||||
|
return default
|
||||||
|
|
||||||
|
|
||||||
|
def parse_bool(value: str | None, default: bool = False) -> bool:
|
||||||
|
if value is None:
|
||||||
|
return default
|
||||||
|
return value.strip().lower() in {
|
||||||
|
"1",
|
||||||
|
"true",
|
||||||
|
"yes",
|
||||||
|
"on",
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def load_users(raw: str) -> dict[str, UserConfig]:
|
||||||
|
try:
|
||||||
|
payload = json.loads(raw)
|
||||||
|
except json.JSONDecodeError as exc:
|
||||||
|
raise RuntimeError(
|
||||||
|
"GOVERNANCE_UI_USERS_JSON no contiene JSON válido."
|
||||||
|
) from exc
|
||||||
|
|
||||||
|
if not isinstance(payload, list) or not payload:
|
||||||
|
raise RuntimeError(
|
||||||
|
"GOVERNANCE_UI_USERS_JSON debe ser una lista no vacía."
|
||||||
|
)
|
||||||
|
|
||||||
|
users: dict[str, UserConfig] = {}
|
||||||
|
|
||||||
|
for item in payload:
|
||||||
|
if not isinstance(item, dict):
|
||||||
|
raise RuntimeError(
|
||||||
|
"Cada usuario debe ser un objeto JSON."
|
||||||
|
)
|
||||||
|
|
||||||
|
username = str(item.get("username", "")).strip()
|
||||||
|
display_name = str(
|
||||||
|
item.get("display_name", "")
|
||||||
|
).strip()
|
||||||
|
password_hash = str(
|
||||||
|
item.get("password_hash", "")
|
||||||
|
).strip()
|
||||||
|
roles_raw = item.get("roles", [])
|
||||||
|
|
||||||
|
if (
|
||||||
|
not username
|
||||||
|
or not display_name
|
||||||
|
or not password_hash
|
||||||
|
or not isinstance(roles_raw, list)
|
||||||
|
):
|
||||||
|
raise RuntimeError(
|
||||||
|
"Cada usuario requiere username, display_name, "
|
||||||
|
"password_hash y roles."
|
||||||
|
)
|
||||||
|
|
||||||
|
roles = frozenset(
|
||||||
|
str(role).strip().upper()
|
||||||
|
for role in roles_raw
|
||||||
|
if str(role).strip()
|
||||||
|
)
|
||||||
|
|
||||||
|
allowed_roles = {
|
||||||
|
"PRODUCTION",
|
||||||
|
"TECHNICAL",
|
||||||
|
"JOINT",
|
||||||
|
"ADMIN",
|
||||||
|
}
|
||||||
|
|
||||||
|
if not roles or not roles.issubset(allowed_roles):
|
||||||
|
raise RuntimeError(
|
||||||
|
f"Roles inválidos para {username!r}: {sorted(roles)}"
|
||||||
|
)
|
||||||
|
|
||||||
|
if username in users:
|
||||||
|
raise RuntimeError(
|
||||||
|
f"Usuario duplicado: {username!r}"
|
||||||
|
)
|
||||||
|
|
||||||
|
users[username] = UserConfig(
|
||||||
|
username=username,
|
||||||
|
display_name=display_name,
|
||||||
|
password_hash=password_hash,
|
||||||
|
roles=roles,
|
||||||
|
)
|
||||||
|
|
||||||
|
return users
|
||||||
|
|
||||||
|
|
||||||
|
@lru_cache(maxsize=1)
|
||||||
|
def get_settings() -> Settings:
|
||||||
|
database = env_first(
|
||||||
|
"PGDATABASE",
|
||||||
|
"POSTGRES_DB",
|
||||||
|
)
|
||||||
|
user = env_first(
|
||||||
|
"PGUSER",
|
||||||
|
"POSTGRES_USER",
|
||||||
|
)
|
||||||
|
password = env_first(
|
||||||
|
"PGPASSWORD",
|
||||||
|
"POSTGRES_PASSWORD",
|
||||||
|
)
|
||||||
|
session_secret = env_first(
|
||||||
|
"GOVERNANCE_UI_SESSION_SECRET"
|
||||||
|
)
|
||||||
|
users_json = env_first(
|
||||||
|
"GOVERNANCE_UI_USERS_JSON"
|
||||||
|
)
|
||||||
|
|
||||||
|
missing = [
|
||||||
|
name
|
||||||
|
for name, value in (
|
||||||
|
("PGDATABASE/POSTGRES_DB", database),
|
||||||
|
("PGUSER/POSTGRES_USER", user),
|
||||||
|
("PGPASSWORD/POSTGRES_PASSWORD", password),
|
||||||
|
("GOVERNANCE_UI_SESSION_SECRET", session_secret),
|
||||||
|
("GOVERNANCE_UI_USERS_JSON", users_json),
|
||||||
|
)
|
||||||
|
if not value
|
||||||
|
]
|
||||||
|
|
||||||
|
if missing:
|
||||||
|
raise RuntimeError(
|
||||||
|
"Faltan variables: " + ", ".join(missing)
|
||||||
|
)
|
||||||
|
|
||||||
|
return Settings(
|
||||||
|
app_title=env_first(
|
||||||
|
"GOVERNANCE_UI_TITLE",
|
||||||
|
default="MESAVAULT · Revisión de gobernanza",
|
||||||
|
)
|
||||||
|
or "MESAVAULT · Revisión de gobernanza",
|
||||||
|
tenant=env_first(
|
||||||
|
"TENANT_OVERRIDE",
|
||||||
|
"TENANT",
|
||||||
|
default="ucepsa",
|
||||||
|
)
|
||||||
|
or "ucepsa",
|
||||||
|
site=env_first(
|
||||||
|
"SITE_OVERRIDE",
|
||||||
|
"SITE",
|
||||||
|
default="ucepsa_onpremise",
|
||||||
|
)
|
||||||
|
or "ucepsa_onpremise",
|
||||||
|
pg_host=env_first(
|
||||||
|
"PGHOST",
|
||||||
|
default="mv_ucepsa_postgres_hot",
|
||||||
|
)
|
||||||
|
or "mv_ucepsa_postgres_hot",
|
||||||
|
pg_port=int(
|
||||||
|
env_first(
|
||||||
|
"PGPORT",
|
||||||
|
default="5432",
|
||||||
|
)
|
||||||
|
or "5432"
|
||||||
|
),
|
||||||
|
pg_database=str(database),
|
||||||
|
pg_user=str(user),
|
||||||
|
pg_password=str(password),
|
||||||
|
session_secret=str(session_secret),
|
||||||
|
session_max_age_s=int(
|
||||||
|
env_first(
|
||||||
|
"GOVERNANCE_UI_SESSION_MAX_AGE_S",
|
||||||
|
default="28800",
|
||||||
|
)
|
||||||
|
or "28800"
|
||||||
|
),
|
||||||
|
cookie_secure=parse_bool(
|
||||||
|
env_first(
|
||||||
|
"GOVERNANCE_UI_COOKIE_SECURE",
|
||||||
|
default="false",
|
||||||
|
)
|
||||||
|
),
|
||||||
|
write_enabled=parse_bool(
|
||||||
|
env_first(
|
||||||
|
"GOVERNANCE_UI_WRITE_ENABLED",
|
||||||
|
default="false",
|
||||||
|
)
|
||||||
|
),
|
||||||
|
users=load_users(str(users_json)),
|
||||||
|
)
|
||||||
377
ucepsa/edge-oee-demo/governance-ui/app/static/style.css
Normal file
377
ucepsa/edge-oee-demo/governance-ui/app/static/style.css
Normal file
@ -0,0 +1,377 @@
|
|||||||
|
:root {
|
||||||
|
color-scheme: dark;
|
||||||
|
--bg: #0f141a;
|
||||||
|
--panel: #171e26;
|
||||||
|
--panel-2: #202936;
|
||||||
|
--text: #eef3f8;
|
||||||
|
--muted: #9eabb8;
|
||||||
|
--line: #344050;
|
||||||
|
--primary: #5ba8ff;
|
||||||
|
--success: #47c78a;
|
||||||
|
--warning: #f0a53a;
|
||||||
|
--danger: #ef6461;
|
||||||
|
}
|
||||||
|
|
||||||
|
* { box-sizing: border-box; }
|
||||||
|
|
||||||
|
body {
|
||||||
|
margin: 0;
|
||||||
|
font-family: Inter, system-ui, -apple-system, Segoe UI, sans-serif;
|
||||||
|
background: var(--bg);
|
||||||
|
color: var(--text);
|
||||||
|
}
|
||||||
|
|
||||||
|
a { color: var(--primary); }
|
||||||
|
|
||||||
|
.topbar {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: center;
|
||||||
|
gap: 1rem;
|
||||||
|
padding: 1rem 2rem;
|
||||||
|
background: #0a0f14;
|
||||||
|
border-bottom: 1px solid var(--line);
|
||||||
|
position: sticky;
|
||||||
|
top: 0;
|
||||||
|
z-index: 5;
|
||||||
|
}
|
||||||
|
|
||||||
|
.brand {
|
||||||
|
color: var(--text);
|
||||||
|
font-weight: 800;
|
||||||
|
text-decoration: none;
|
||||||
|
letter-spacing: .04em;
|
||||||
|
}
|
||||||
|
|
||||||
|
.subtitle {
|
||||||
|
color: var(--muted);
|
||||||
|
margin-left: .7rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.topbar nav {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.topbar nav a { text-decoration: none; }
|
||||||
|
.user { color: var(--muted); }
|
||||||
|
|
||||||
|
.container {
|
||||||
|
width: min(1400px, calc(100% - 2rem));
|
||||||
|
margin: 2rem auto 4rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.page-heading,
|
||||||
|
.detail-heading {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: 1rem;
|
||||||
|
align-items: flex-start;
|
||||||
|
margin-bottom: 1.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
h1, h2, h3 { margin-top: 0; }
|
||||||
|
p { line-height: 1.55; }
|
||||||
|
|
||||||
|
.summary-badge,
|
||||||
|
.machine,
|
||||||
|
.severity,
|
||||||
|
.status {
|
||||||
|
display: inline-block;
|
||||||
|
border-radius: 999px;
|
||||||
|
padding: .35rem .7rem;
|
||||||
|
background: var(--panel-2);
|
||||||
|
font-size: .88rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.machine { font-weight: 800; }
|
||||||
|
|
||||||
|
.card-grid {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(auto-fit, minmax(360px, 1fr));
|
||||||
|
gap: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.decision-card,
|
||||||
|
.decision-detail,
|
||||||
|
.login-card {
|
||||||
|
background: var(--panel);
|
||||||
|
border: 1px solid var(--line);
|
||||||
|
border-radius: 14px;
|
||||||
|
padding: 1.25rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.decision-card.severity-critical {
|
||||||
|
border-left: 5px solid var(--danger);
|
||||||
|
}
|
||||||
|
|
||||||
|
.decision-card.severity-warning {
|
||||||
|
border-left: 5px solid var(--warning);
|
||||||
|
}
|
||||||
|
|
||||||
|
.card-head,
|
||||||
|
.status-pair,
|
||||||
|
.status-stack {
|
||||||
|
display: flex;
|
||||||
|
gap: .5rem;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
justify-content: space-between;
|
||||||
|
}
|
||||||
|
|
||||||
|
.question { font-size: 1.08rem; }
|
||||||
|
|
||||||
|
.facts {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||||
|
gap: .75rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.facts div {
|
||||||
|
background: var(--panel-2);
|
||||||
|
border-radius: 10px;
|
||||||
|
padding: .7rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.facts dt {
|
||||||
|
color: var(--muted);
|
||||||
|
font-size: .8rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.facts dd { margin: .25rem 0 0; }
|
||||||
|
|
||||||
|
.button,
|
||||||
|
button {
|
||||||
|
border: 0;
|
||||||
|
border-radius: 9px;
|
||||||
|
padding: .72rem 1rem;
|
||||||
|
font: inherit;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
.primary {
|
||||||
|
background: var(--primary);
|
||||||
|
color: #06111f;
|
||||||
|
font-weight: 800;
|
||||||
|
text-decoration: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.link-button {
|
||||||
|
color: var(--primary);
|
||||||
|
background: transparent;
|
||||||
|
padding: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.inline { display: inline; }
|
||||||
|
|
||||||
|
.alert {
|
||||||
|
padding: .9rem 1rem;
|
||||||
|
border-radius: 10px;
|
||||||
|
margin-bottom: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.alert.error {
|
||||||
|
background: rgba(239, 100, 97, .16);
|
||||||
|
border: 1px solid var(--danger);
|
||||||
|
}
|
||||||
|
|
||||||
|
.alert.success {
|
||||||
|
background: rgba(71, 199, 138, .16);
|
||||||
|
border: 1px solid var(--success);
|
||||||
|
}
|
||||||
|
|
||||||
|
.empty-state {
|
||||||
|
text-align: center;
|
||||||
|
padding: 4rem 1rem;
|
||||||
|
color: var(--muted);
|
||||||
|
}
|
||||||
|
|
||||||
|
.login-card {
|
||||||
|
max-width: 460px;
|
||||||
|
margin: 7rem auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
label {
|
||||||
|
display: block;
|
||||||
|
margin: 1rem 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
input,
|
||||||
|
textarea,
|
||||||
|
select {
|
||||||
|
width: 100%;
|
||||||
|
margin-top: .4rem;
|
||||||
|
padding: .75rem;
|
||||||
|
border: 1px solid var(--line);
|
||||||
|
border-radius: 8px;
|
||||||
|
background: #0e151d;
|
||||||
|
color: var(--text);
|
||||||
|
font: inherit;
|
||||||
|
}
|
||||||
|
|
||||||
|
input[type="checkbox"],
|
||||||
|
input[type="radio"] {
|
||||||
|
width: auto;
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
fieldset {
|
||||||
|
margin: 1.25rem 0;
|
||||||
|
border: 1px solid var(--line);
|
||||||
|
border-radius: 10px;
|
||||||
|
padding: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
legend {
|
||||||
|
padding: 0 .5rem;
|
||||||
|
font-weight: 800;
|
||||||
|
}
|
||||||
|
|
||||||
|
.check-row,
|
||||||
|
.radio-row {
|
||||||
|
display: flex;
|
||||||
|
align-items: flex-start;
|
||||||
|
gap: .7rem;
|
||||||
|
margin: .65rem 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.help,
|
||||||
|
small { color: var(--muted); }
|
||||||
|
|
||||||
|
.timeline {
|
||||||
|
display: grid;
|
||||||
|
gap: .75rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.timeline-item {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: 220px 1fr;
|
||||||
|
gap: 1rem;
|
||||||
|
background: var(--panel-2);
|
||||||
|
border-radius: 10px;
|
||||||
|
padding: 1rem;
|
||||||
|
border-left: 4px solid var(--warning);
|
||||||
|
}
|
||||||
|
|
||||||
|
.timeline-item.reviewed {
|
||||||
|
border-left-color: var(--success);
|
||||||
|
opacity: .82;
|
||||||
|
}
|
||||||
|
|
||||||
|
.timeline-time {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: .35rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.question-box {
|
||||||
|
background: var(--panel-2);
|
||||||
|
border-radius: 12px;
|
||||||
|
padding: 1rem;
|
||||||
|
margin: 1rem 0 1.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
details {
|
||||||
|
margin: 1.5rem 0;
|
||||||
|
background: var(--panel-2);
|
||||||
|
padding: 1rem;
|
||||||
|
border-radius: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
details summary {
|
||||||
|
cursor: pointer;
|
||||||
|
font-weight: 800;
|
||||||
|
}
|
||||||
|
|
||||||
|
.evidence-list article {
|
||||||
|
border-top: 1px solid var(--line);
|
||||||
|
padding: 1rem 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.review-form {
|
||||||
|
border-top: 1px solid var(--line);
|
||||||
|
margin-top: 2rem;
|
||||||
|
padding-top: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.confirmation {
|
||||||
|
background: rgba(240, 165, 58, .12);
|
||||||
|
padding: 1rem;
|
||||||
|
border-radius: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.table-wrap { overflow-x: auto; }
|
||||||
|
|
||||||
|
table {
|
||||||
|
width: 100%;
|
||||||
|
border-collapse: collapse;
|
||||||
|
background: var(--panel);
|
||||||
|
}
|
||||||
|
|
||||||
|
th, td {
|
||||||
|
padding: .75rem;
|
||||||
|
border-bottom: 1px solid var(--line);
|
||||||
|
text-align: left;
|
||||||
|
vertical-align: top;
|
||||||
|
}
|
||||||
|
|
||||||
|
th { color: var(--muted); }
|
||||||
|
.back { display: inline-block; margin-bottom: 1rem; }
|
||||||
|
|
||||||
|
@media (max-width: 760px) {
|
||||||
|
.topbar,
|
||||||
|
.page-heading,
|
||||||
|
.detail-heading {
|
||||||
|
flex-direction: column;
|
||||||
|
}
|
||||||
|
|
||||||
|
.timeline-item {
|
||||||
|
grid-template-columns: 1fr;
|
||||||
|
}
|
||||||
|
|
||||||
|
.facts {
|
||||||
|
grid-template-columns: 1fr;
|
||||||
|
}
|
||||||
|
|
||||||
|
.card-grid {
|
||||||
|
grid-template-columns: 1fr;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.secondary {
|
||||||
|
background: #52616f;
|
||||||
|
color: var(--text);
|
||||||
|
font-weight: 700;
|
||||||
|
}
|
||||||
|
|
||||||
|
.danger-button {
|
||||||
|
background: var(--danger);
|
||||||
|
color: #160604;
|
||||||
|
font-weight: 800;
|
||||||
|
}
|
||||||
|
|
||||||
|
.info-box,
|
||||||
|
.consultation-box {
|
||||||
|
background: rgba(91, 168, 255, .10);
|
||||||
|
border: 1px solid rgba(91, 168, 255, .35);
|
||||||
|
border-radius: 10px;
|
||||||
|
padding: 1rem;
|
||||||
|
margin: 1rem 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.consultation-box {
|
||||||
|
background: rgba(240, 165, 58, .10);
|
||||||
|
border-color: rgba(240, 165, 58, .35);
|
||||||
|
}
|
||||||
|
|
||||||
|
.consultation-form {
|
||||||
|
border-color: rgba(240, 165, 58, .45);
|
||||||
|
}
|
||||||
|
|
||||||
|
.compact-section {
|
||||||
|
margin-bottom: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.topbar nav {
|
||||||
|
flex-wrap: wrap;
|
||||||
|
}
|
||||||
@ -0,0 +1,82 @@
|
|||||||
|
{% extends "base.html" %}
|
||||||
|
{% block content %}
|
||||||
|
<a class="back" href="/approvals">← Volver a aprobación técnica</a>
|
||||||
|
|
||||||
|
{% if error %}
|
||||||
|
<div class="alert error">{{ error }}</div>
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
|
<section class="decision-detail">
|
||||||
|
<div class="detail-heading">
|
||||||
|
<div>
|
||||||
|
<span class="machine">{{ proposal.machine_id }}</span>
|
||||||
|
<span class="status">{{ proposal.proposal_status|label }}</span>
|
||||||
|
<h1>Propuesta de contexto: {{ proposal.proposed_source_type|label }}</h1>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="question-box">
|
||||||
|
<h2>Respuesta aportada por Producción</h2>
|
||||||
|
<p><strong>{{ proposal.answer_code|label }}</strong></p>
|
||||||
|
<p>Respondido por {{ proposal.proposed_by_display_name }} el {{ proposal.proposed_at|datetime_es }}</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<dl class="facts">
|
||||||
|
<div><dt>Inicio exacto</dt><dd>{{ proposal.first_seen_at|datetime_es }}</dd></div>
|
||||||
|
<div><dt>Fin exacto</dt><dd>{{ proposal.ended_at|datetime_es }}</dd></div>
|
||||||
|
<div><dt>Estado del intervalo</dt><dd>{{ proposal.case_status|label }}</dd></div>
|
||||||
|
<div><dt>Estado de revisión</dt><dd>{{ proposal.case_review_status|label }}</dd></div>
|
||||||
|
<div><dt>Órdenes</dt><dd>{{ proposal.production_orders|join(", ") if proposal.production_orders else "—" }}</dd></div>
|
||||||
|
<div><dt>Workorders</dt><dd>{{ proposal.odoo_workorder_ids|join(", ") if proposal.odoo_workorder_ids else "—" }}</dd></div>
|
||||||
|
</dl>
|
||||||
|
|
||||||
|
{% if proposal.last_action_notes %}
|
||||||
|
<div class="consultation-box">
|
||||||
|
<strong>Último intercambio</strong>
|
||||||
|
<p>{{ proposal.last_action_notes }}</p>
|
||||||
|
<small>{{ proposal.last_action_by }} · {{ proposal.last_action_at|datetime_es }}</small>
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
|
<div class="info-box">
|
||||||
|
<strong>Validaciones al aprobar</strong>
|
||||||
|
<p>La aplicación comprobará que el intervalo ha terminado y que no se solapa con Odoo ni con otro contexto autoritativo. El contexto se creará en SHADOW y con elegibilidad oficial desactivada.</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{% if not write_enabled %}
|
||||||
|
<div class="alert error">La interfaz está en modo solo lectura.</div>
|
||||||
|
{% elif proposal.proposal_status == 'PENDING' %}
|
||||||
|
<form method="post" action="/approvals/{{ proposal.proposal_id }}/approve" class="review-form">
|
||||||
|
<input type="hidden" name="csrf_token" value="{{ user.csrf_token }}">
|
||||||
|
<input type="hidden" name="submission_key" value="{{ submission_key }}">
|
||||||
|
<label>
|
||||||
|
Observaciones técnicas
|
||||||
|
<textarea name="notes" rows="4" placeholder="Ejemplo: intervalo cerrado, sin solapamientos y coherente con la respuesta de Producción."></textarea>
|
||||||
|
</label>
|
||||||
|
<button class="primary" type="submit">Aprobar y crear contexto</button>
|
||||||
|
</form>
|
||||||
|
|
||||||
|
<form method="post" action="/approvals/{{ proposal.proposal_id }}/request_clarification" class="review-form">
|
||||||
|
<input type="hidden" name="csrf_token" value="{{ user.csrf_token }}">
|
||||||
|
<input type="hidden" name="submission_key" value="{{ submission_key }}">
|
||||||
|
<label>
|
||||||
|
Aclaración necesaria
|
||||||
|
<textarea name="notes" rows="4" required placeholder="Indique qué dato debe aclarar Producción."></textarea>
|
||||||
|
</label>
|
||||||
|
<button class="secondary" type="submit">Solicitar aclaración</button>
|
||||||
|
</form>
|
||||||
|
|
||||||
|
<form method="post" action="/approvals/{{ proposal.proposal_id }}/reject" class="review-form">
|
||||||
|
<input type="hidden" name="csrf_token" value="{{ user.csrf_token }}">
|
||||||
|
<input type="hidden" name="submission_key" value="{{ submission_key }}">
|
||||||
|
<label>
|
||||||
|
Motivo del rechazo
|
||||||
|
<textarea name="notes" rows="4" required placeholder="Explique por qué no debe crearse el contexto."></textarea>
|
||||||
|
</label>
|
||||||
|
<button class="danger-button" type="submit">Rechazar propuesta</button>
|
||||||
|
</form>
|
||||||
|
{% else %}
|
||||||
|
<div class="alert success">La propuesta ya no está pendiente: {{ proposal.proposal_status|label }}.</div>
|
||||||
|
{% endif %}
|
||||||
|
</section>
|
||||||
|
{% endblock %}
|
||||||
@ -0,0 +1,49 @@
|
|||||||
|
{% extends "base.html" %}
|
||||||
|
{% block content %}
|
||||||
|
<div class="page-heading">
|
||||||
|
<div>
|
||||||
|
<h1>Aprobación técnica de contextos</h1>
|
||||||
|
<p>Producción ya explicó qué ocurrió. Esta cola valida si la respuesta puede convertirse en contexto canónico sin crear solapamientos.</p>
|
||||||
|
</div>
|
||||||
|
<div class="summary-badge">{{ proposals|length }} propuestas</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{% if message %}
|
||||||
|
<div class="alert success">{{ message }}</div>
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
|
{% if not proposals %}
|
||||||
|
<div class="empty-state">
|
||||||
|
<h2>No hay propuestas pendientes</h2>
|
||||||
|
<p>Las respuestas de Producción que no necesitan contexto no aparecen aquí.</p>
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
|
<div class="card-grid">
|
||||||
|
{% for proposal in proposals %}
|
||||||
|
<article class="decision-card">
|
||||||
|
<div class="card-head">
|
||||||
|
<div>
|
||||||
|
<span class="machine">{{ proposal.machine_id }}</span>
|
||||||
|
<span class="status">{{ proposal.proposal_status|label }}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<h2>{{ proposal.proposed_source_type|label }}</h2>
|
||||||
|
<p><strong>Respuesta de Producción:</strong> {{ proposal.answer_code|label }}</p>
|
||||||
|
<dl class="facts">
|
||||||
|
<div><dt>Inicio</dt><dd>{{ proposal.first_seen_at|datetime_es }}</dd></div>
|
||||||
|
<div><dt>Fin</dt><dd>{{ proposal.ended_at|datetime_es }}</dd></div>
|
||||||
|
<div><dt>Propuesto por</dt><dd>{{ proposal.proposed_by_display_name }}</dd></div>
|
||||||
|
<div><dt>Caso</dt><dd>{{ proposal.case_id }}</dd></div>
|
||||||
|
</dl>
|
||||||
|
{% if proposal.last_action_notes %}
|
||||||
|
<div class="consultation-box">
|
||||||
|
<strong>Último intercambio</strong>
|
||||||
|
<p>{{ proposal.last_action_notes }}</p>
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
<a class="primary button" href="/approvals/{{ proposal.proposal_id }}">Revisar propuesta</a>
|
||||||
|
</article>
|
||||||
|
{% endfor %}
|
||||||
|
</div>
|
||||||
|
{% endblock %}
|
||||||
41
ucepsa/edge-oee-demo/governance-ui/app/templates/base.html
Normal file
41
ucepsa/edge-oee-demo/governance-ui/app/templates/base.html
Normal file
@ -0,0 +1,41 @@
|
|||||||
|
<!doctype html>
|
||||||
|
<html lang="es">
|
||||||
|
<head>
|
||||||
|
<meta charset="utf-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||||
|
<title>{{ settings.app_title }}</title>
|
||||||
|
<link rel="stylesheet" href="{{ url_for('static', path='/style.css') }}">
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
{% if user %}
|
||||||
|
<header class="topbar">
|
||||||
|
<div>
|
||||||
|
<a class="brand" href="/">MESAVAULT</a>
|
||||||
|
<span class="subtitle">Revisión de gobernanza</span>
|
||||||
|
</div>
|
||||||
|
<nav>
|
||||||
|
<a href="/">Pendientes</a>
|
||||||
|
{% if 'TECHNICAL' in user.roles or 'ADMIN' in user.roles %}
|
||||||
|
<a href="/approvals">Aprobación técnica</a>
|
||||||
|
{% endif %}
|
||||||
|
{% if 'PRODUCTION' in user.roles or 'ADMIN' in user.roles %}
|
||||||
|
<a href="/clarifications">Aclaraciones</a>
|
||||||
|
{% endif %}
|
||||||
|
<a href="/history">Historial</a>
|
||||||
|
<span class="user">{{ user.display_name }}</span>
|
||||||
|
{% if not settings.write_enabled %}
|
||||||
|
<span class="status">Solo lectura</span>
|
||||||
|
{% endif %}
|
||||||
|
<form class="inline" method="post" action="/logout">
|
||||||
|
<input type="hidden" name="csrf_token" value="{{ user.csrf_token }}">
|
||||||
|
<button class="link-button" type="submit">Salir</button>
|
||||||
|
</form>
|
||||||
|
</nav>
|
||||||
|
</header>
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
|
<main class="container">
|
||||||
|
{% block content %}{% endblock %}
|
||||||
|
</main>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
@ -0,0 +1,46 @@
|
|||||||
|
{% extends "base.html" %}
|
||||||
|
{% block content %}
|
||||||
|
<div class="page-heading">
|
||||||
|
<div>
|
||||||
|
<h1>Aclaraciones solicitadas</h1>
|
||||||
|
<p>El revisor técnico necesita un dato adicional antes de aprobar o rechazar el contexto propuesto.</p>
|
||||||
|
</div>
|
||||||
|
<div class="summary-badge">{{ proposals|length }} pendientes</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{% if message %}
|
||||||
|
<div class="alert success">{{ message }}</div>
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
|
{% if not proposals %}
|
||||||
|
<div class="empty-state">
|
||||||
|
<h2>No hay aclaraciones pendientes</h2>
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
|
{% for proposal in proposals %}
|
||||||
|
<section class="decision-detail compact-section">
|
||||||
|
<div class="detail-heading">
|
||||||
|
<div>
|
||||||
|
<span class="machine">{{ proposal.machine_id }}</span>
|
||||||
|
<h2>{{ proposal.proposed_source_type|label }}</h2>
|
||||||
|
</div>
|
||||||
|
<span class="status">{{ proposal.proposal_status|label }}</span>
|
||||||
|
</div>
|
||||||
|
<p><strong>Pregunta técnica:</strong> {{ proposal.last_action_notes }}</p>
|
||||||
|
<p><strong>Intervalo:</strong> {{ proposal.first_seen_at|datetime_es }} → {{ proposal.ended_at|datetime_es }}</p>
|
||||||
|
|
||||||
|
{% if write_enabled %}
|
||||||
|
<form method="post" action="/clarifications/{{ proposal.proposal_id }}/respond">
|
||||||
|
<input type="hidden" name="csrf_token" value="{{ user.csrf_token }}">
|
||||||
|
<input type="hidden" name="submission_key" value="{{ submission_keys[proposal.proposal_id] }}">
|
||||||
|
<label>
|
||||||
|
Respuesta o aclaración
|
||||||
|
<textarea name="notes" rows="4" required></textarea>
|
||||||
|
</label>
|
||||||
|
<button class="primary" type="submit">Enviar aclaración</button>
|
||||||
|
</form>
|
||||||
|
{% endif %}
|
||||||
|
</section>
|
||||||
|
{% endfor %}
|
||||||
|
{% endblock %}
|
||||||
184
ucepsa/edge-oee-demo/governance-ui/app/templates/decision.html
Normal file
184
ucepsa/edge-oee-demo/governance-ui/app/templates/decision.html
Normal file
@ -0,0 +1,184 @@
|
|||||||
|
{% extends "base.html" %}
|
||||||
|
{% block content %}
|
||||||
|
<a class="back" href="/">← Volver a pendientes</a>
|
||||||
|
|
||||||
|
{% if error %}
|
||||||
|
<div class="alert error">{{ error }}</div>
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
|
<section class="decision-detail">
|
||||||
|
<div class="detail-heading">
|
||||||
|
<div>
|
||||||
|
<span class="machine">{{ decision.machine_id or "GLOBAL" }}</span>
|
||||||
|
<span class="severity">{{ decision.max_severity|label }}</span>
|
||||||
|
<h1>{{ decision.decision_title }}</h1>
|
||||||
|
</div>
|
||||||
|
<div class="status-stack">
|
||||||
|
<span class="status">Incidencia: {{ decision.decision_status|label }}</span>
|
||||||
|
<span class="status">Revisión: {{ decision.review_status|label }}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="question-box">
|
||||||
|
<h2>Pregunta</h2>
|
||||||
|
<p>{{ decision.question_text }}</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{% if consultation %}
|
||||||
|
<div class="consultation-box">
|
||||||
|
<h2>En consulta</h2>
|
||||||
|
<p>{{ consultation.reason }}</p>
|
||||||
|
{% if consultation.follow_up_with %}
|
||||||
|
<p><strong>Consultar con:</strong> {{ consultation.follow_up_with }}</p>
|
||||||
|
{% endif %}
|
||||||
|
<small>Marcado por {{ consultation.created_by_display_name }} el {{ consultation.created_at|datetime_es }}</small>
|
||||||
|
|
||||||
|
{% if write_enabled %}
|
||||||
|
<form method="post" action="/decisions/{{ decision.decision_group_key|urlencode }}/resume">
|
||||||
|
<input type="hidden" name="csrf_token" value="{{ user.csrf_token }}">
|
||||||
|
<input type="hidden" name="submission_key" value="{{ resume_submission_key }}">
|
||||||
|
<button class="secondary" type="submit">Ya tengo la información</button>
|
||||||
|
</form>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
|
<section>
|
||||||
|
<h2>Intervalos y casos</h2>
|
||||||
|
<div class="timeline">
|
||||||
|
{% for case in cases %}
|
||||||
|
<article class="timeline-item {% if case.case_review_status != 'PENDING' %}reviewed{% endif %}">
|
||||||
|
<div class="timeline-time">
|
||||||
|
<strong>{{ case.first_seen_at|datetime_es }}</strong>
|
||||||
|
<span>→ {{ case.ended_at|datetime_es }}</span>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<h3>Caso {{ case.case_id }} · {{ case.case_title }}</h3>
|
||||||
|
<p>
|
||||||
|
Incidencia: {{ case.case_status|label }}
|
||||||
|
· Revisión: {{ case.case_review_status|label }}
|
||||||
|
</p>
|
||||||
|
{% if case.case_resolution_classification %}
|
||||||
|
<p><strong>Periodo ya aclarado:</strong> {{ case.case_resolution_classification|label }}</p>
|
||||||
|
{% endif %}
|
||||||
|
{% if case.production_orders %}
|
||||||
|
<p><strong>Órdenes:</strong> {{ case.production_orders|join(", ") }}</p>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
</article>
|
||||||
|
{% endfor %}
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<details>
|
||||||
|
<summary>Evidencia técnica</summary>
|
||||||
|
<div class="evidence-list">
|
||||||
|
{% for item in decision.episode_evidence %}
|
||||||
|
<article>
|
||||||
|
<strong>Episodio {{ item.episode_id }} · {{ item.incident_title }}</strong>
|
||||||
|
<p>{{ item.latest_detail or "Sin detalle adicional." }}</p>
|
||||||
|
<small>
|
||||||
|
{{ item.first_seen_at }} → {{ item.ended_at or "abierto" }}
|
||||||
|
{% if item.production_order %} · {{ item.production_order }}{% endif %}
|
||||||
|
{% if item.operator_name %} · {{ item.operator_name }}{% endif %}
|
||||||
|
</small>
|
||||||
|
</article>
|
||||||
|
{% endfor %}
|
||||||
|
</div>
|
||||||
|
</details>
|
||||||
|
|
||||||
|
{% if not write_enabled %}
|
||||||
|
<div class="alert error">
|
||||||
|
La interfaz está en modo solo lectura. Puede validar la información, pero todavía no registrar respuestas.
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
|
{% if can_review and pending_cases %}
|
||||||
|
{% if not consultation %}
|
||||||
|
<details class="consultation-form">
|
||||||
|
<summary>No conozco todavía la respuesta</summary>
|
||||||
|
<form method="post" action="/decisions/{{ decision.decision_group_key|urlencode }}/consultation">
|
||||||
|
<input type="hidden" name="csrf_token" value="{{ user.csrf_token }}">
|
||||||
|
<input type="hidden" name="submission_key" value="{{ consultation_submission_key }}">
|
||||||
|
<label>
|
||||||
|
Qué información falta
|
||||||
|
<textarea name="reason" rows="3" required placeholder="Ejemplo: preguntar a la operaria del turno de tarde qué estaba haciendo la máquina."></textarea>
|
||||||
|
</label>
|
||||||
|
<label>
|
||||||
|
A quién hay que consultar
|
||||||
|
<input name="follow_up_with" placeholder="Ejemplo: María / responsable del turno anterior">
|
||||||
|
</label>
|
||||||
|
<button class="secondary" type="submit">Marcar En consulta</button>
|
||||||
|
</form>
|
||||||
|
</details>
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
|
<form class="review-form" method="post" action="/decisions/{{ decision.decision_group_key|urlencode }}/review">
|
||||||
|
<input type="hidden" name="csrf_token" value="{{ user.csrf_token }}">
|
||||||
|
<input type="hidden" name="submission_key" value="{{ review_submission_key }}">
|
||||||
|
|
||||||
|
<fieldset>
|
||||||
|
<legend>1. Intervalos a los que se aplica la respuesta</legend>
|
||||||
|
{% for case in pending_cases %}
|
||||||
|
<label class="check-row">
|
||||||
|
<input type="checkbox" name="case_ids" value="{{ case.case_id }}" checked>
|
||||||
|
<span>
|
||||||
|
Caso {{ case.case_id }}:
|
||||||
|
{{ case.first_seen_at|datetime_es }}
|
||||||
|
→ {{ case.ended_at|datetime_es }}
|
||||||
|
</span>
|
||||||
|
</label>
|
||||||
|
{% endfor %}
|
||||||
|
<p class="help">Desmarque un intervalo cuando la causa no sea la misma. Los demás seguirán pendientes.</p>
|
||||||
|
</fieldset>
|
||||||
|
|
||||||
|
<fieldset>
|
||||||
|
<legend>2. Qué ocurrió</legend>
|
||||||
|
{% for code, text in answer_options %}
|
||||||
|
<label class="radio-row">
|
||||||
|
<input type="radio" name="answer_code" value="{{ code }}" required>
|
||||||
|
<span>{{ text }}</span>
|
||||||
|
</label>
|
||||||
|
{% endfor %}
|
||||||
|
</fieldset>
|
||||||
|
|
||||||
|
{% if decision.decision_family == "ODOO_ORDER_START_CONFLICT" %}
|
||||||
|
<fieldset>
|
||||||
|
<legend>3. Orden válida</legend>
|
||||||
|
{% for order_ref, workorder_id in order_pairs %}
|
||||||
|
<label class="radio-row">
|
||||||
|
<input type="radio" name="selected_order" value="{{ order_ref }}|{{ workorder_id }}">
|
||||||
|
<span>{{ order_ref }} · WO {{ workorder_id }}</span>
|
||||||
|
</label>
|
||||||
|
{% endfor %}
|
||||||
|
<label class="check-row">
|
||||||
|
<input type="checkbox" name="corrected_in_odoo" value="true">
|
||||||
|
<span>La incoherencia ya está corregida en Odoo</span>
|
||||||
|
</label>
|
||||||
|
</fieldset>
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
|
<div class="info-box">
|
||||||
|
<strong>Qué ocurrirá después</strong>
|
||||||
|
<p>Su respuesta cerrará la pregunta de Producción. Cuando la respuesta permita crear un contexto histórico, se enviará una propuesta a validación técnica. Usted no crea ni aprueba contextos.</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<label>
|
||||||
|
Observaciones
|
||||||
|
<textarea name="notes" rows="5" placeholder="Explique brevemente qué ocurrió y, cuando proceda, qué se corrigió."></textarea>
|
||||||
|
</label>
|
||||||
|
|
||||||
|
<label class="check-row confirmation">
|
||||||
|
<input type="checkbox" name="confirmation" value="true" required>
|
||||||
|
<span>Confirmo que la respuesta refleja la realidad de planta para los intervalos seleccionados.</span>
|
||||||
|
</label>
|
||||||
|
|
||||||
|
<button class="primary" type="submit">Resolver selección</button>
|
||||||
|
</form>
|
||||||
|
{% elif not pending_cases %}
|
||||||
|
<div class="alert success">Esta decisión ya no tiene casos pendientes.</div>
|
||||||
|
{% elif write_enabled %}
|
||||||
|
<div class="alert error">Su rol puede consultar esta decisión, pero no resolverla.</div>
|
||||||
|
{% endif %}
|
||||||
|
</section>
|
||||||
|
{% endblock %}
|
||||||
@ -0,0 +1,38 @@
|
|||||||
|
{% extends "base.html" %}
|
||||||
|
{% block content %}
|
||||||
|
<div class="page-heading">
|
||||||
|
<div>
|
||||||
|
<h1>Historial de actividad</h1>
|
||||||
|
<p>Revisiones de Producción, consultas y decisiones técnicas durante los últimos 30 días.</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="table-wrap">
|
||||||
|
<table>
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th>Fecha</th>
|
||||||
|
<th>Tipo</th>
|
||||||
|
<th>Usuario</th>
|
||||||
|
<th>Decisión</th>
|
||||||
|
<th>Acción</th>
|
||||||
|
<th>Estado</th>
|
||||||
|
<th>Notas</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{% for activity in activities %}
|
||||||
|
<tr>
|
||||||
|
<td>{{ activity.created_at|datetime_es }}</td>
|
||||||
|
<td>{{ activity.activity_type|label }}</td>
|
||||||
|
<td>{{ activity.actor_display_name }}</td>
|
||||||
|
<td>{{ activity.decision_group_key }}</td>
|
||||||
|
<td>{{ activity.action_code|label }}</td>
|
||||||
|
<td>{{ activity.status|label }}</td>
|
||||||
|
<td>{{ activity.notes or "—" }}</td>
|
||||||
|
</tr>
|
||||||
|
{% endfor %}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
{% endblock %}
|
||||||
23
ucepsa/edge-oee-demo/governance-ui/app/templates/login.html
Normal file
23
ucepsa/edge-oee-demo/governance-ui/app/templates/login.html
Normal file
@ -0,0 +1,23 @@
|
|||||||
|
{% extends "base.html" %}
|
||||||
|
{% block content %}
|
||||||
|
<section class="login-card">
|
||||||
|
<h1>{{ settings.app_title }}</h1>
|
||||||
|
<p>Acceso interno para revisar y resolver excepciones operativas.</p>
|
||||||
|
|
||||||
|
{% if error %}
|
||||||
|
<div class="alert error">{{ error }}</div>
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
|
<form method="post" action="/login">
|
||||||
|
<label>
|
||||||
|
Usuario
|
||||||
|
<input name="username" autocomplete="username" required>
|
||||||
|
</label>
|
||||||
|
<label>
|
||||||
|
Contraseña
|
||||||
|
<input name="password" type="password" autocomplete="current-password" required>
|
||||||
|
</label>
|
||||||
|
<button class="primary" type="submit">Entrar</button>
|
||||||
|
</form>
|
||||||
|
</section>
|
||||||
|
{% endblock %}
|
||||||
68
ucepsa/edge-oee-demo/governance-ui/app/templates/queue.html
Normal file
68
ucepsa/edge-oee-demo/governance-ui/app/templates/queue.html
Normal file
@ -0,0 +1,68 @@
|
|||||||
|
{% extends "base.html" %}
|
||||||
|
{% block content %}
|
||||||
|
<div class="page-heading">
|
||||||
|
<div>
|
||||||
|
<h1>Decisiones pendientes</h1>
|
||||||
|
<p>Una tarjeta equivale a una pregunta humana. Producción explica lo ocurrido; la creación de contexto se valida después por separado.</p>
|
||||||
|
</div>
|
||||||
|
<div class="summary-badge">{{ rows|length }} pendientes</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{% if message %}
|
||||||
|
<div class="alert success">{{ message }}</div>
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
|
{% if not rows %}
|
||||||
|
<div class="empty-state">
|
||||||
|
<h2>No hay decisiones pendientes para su rol</h2>
|
||||||
|
<p>La cola se actualizará automáticamente cuando aparezca una excepción que necesite una explicación humana.</p>
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
|
<div class="card-grid">
|
||||||
|
{% for row in rows %}
|
||||||
|
<article class="decision-card severity-{{ row.max_severity|lower }}">
|
||||||
|
<div class="card-head">
|
||||||
|
<div>
|
||||||
|
<span class="machine">{{ row.machine_id or "GLOBAL" }}</span>
|
||||||
|
<span class="severity">{{ row.max_severity|label }}</span>
|
||||||
|
</div>
|
||||||
|
<div class="status-pair">
|
||||||
|
<span class="status">Incidencia: {{ row.incident_status|label }}</span>
|
||||||
|
<span class="status">Revisión: {{ row.workflow_status|label }}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<h2>{{ row.decision_title }}</h2>
|
||||||
|
<p class="question">{{ row.question_text }}</p>
|
||||||
|
|
||||||
|
{% if row.workflow_status == 'IN_CONSULTATION' %}
|
||||||
|
<div class="consultation-box">
|
||||||
|
<strong>En consulta</strong>
|
||||||
|
<p>{{ row.consultation_reason }}</p>
|
||||||
|
{% if row.follow_up_with %}
|
||||||
|
<small>Consultar con: {{ row.follow_up_with }}</small>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
|
<dl class="facts">
|
||||||
|
<div><dt>Inicio</dt><dd>{{ row.first_seen_at|datetime_es }}</dd></div>
|
||||||
|
<div><dt>Fin</dt><dd>{{ row.ended_at|datetime_es }}</dd></div>
|
||||||
|
<div><dt>Responsable</dt><dd>{{ row.review_owner|label }}</dd></div>
|
||||||
|
<div><dt>Casos pendientes</dt><dd>{{ row.pending_root_case_count }}</dd></div>
|
||||||
|
<div><dt>Evidencias</dt><dd>{{ row.linked_episode_count }}</dd></div>
|
||||||
|
<div><dt>Atención</dt><dd>{{ row.attention_status|label }}</dd></div>
|
||||||
|
</dl>
|
||||||
|
|
||||||
|
{% if row.production_orders %}
|
||||||
|
<p><strong>Órdenes:</strong> {{ row.production_orders|join(", ") }}</p>
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
|
<a class="primary button" href="/decisions/{{ row.decision_group_key|urlencode }}">
|
||||||
|
Revisar
|
||||||
|
</a>
|
||||||
|
</article>
|
||||||
|
{% endfor %}
|
||||||
|
</div>
|
||||||
|
{% endblock %}
|
||||||
8
ucepsa/edge-oee-demo/governance-ui/requirements.txt
Normal file
8
ucepsa/edge-oee-demo/governance-ui/requirements.txt
Normal file
@ -0,0 +1,8 @@
|
|||||||
|
fastapi>=0.115,<1
|
||||||
|
uvicorn[standard]>=0.34,<1
|
||||||
|
jinja2>=3.1,<4
|
||||||
|
psycopg2-binary>=2.9,<3
|
||||||
|
python-multipart>=0.0.20,<1
|
||||||
|
itsdangerous>=2.2,<3
|
||||||
|
bcrypt>=4.1,<5
|
||||||
|
pytest>=8,<9
|
||||||
@ -0,0 +1,81 @@
|
|||||||
|
from app.labels import label
|
||||||
|
from app.policy import (
|
||||||
|
can_approve_context_roles,
|
||||||
|
can_manage_clarification_roles,
|
||||||
|
can_view_owner_roles,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def test_production_permissions() -> None:
|
||||||
|
roles = {"PRODUCTION"}
|
||||||
|
assert can_view_owner_roles(
|
||||||
|
roles,
|
||||||
|
"PRODUCTION",
|
||||||
|
)
|
||||||
|
assert can_view_owner_roles(
|
||||||
|
roles,
|
||||||
|
"JOINT",
|
||||||
|
)
|
||||||
|
assert not can_view_owner_roles(
|
||||||
|
roles,
|
||||||
|
"TECHNICAL",
|
||||||
|
)
|
||||||
|
assert not can_approve_context_roles(
|
||||||
|
roles
|
||||||
|
)
|
||||||
|
assert can_manage_clarification_roles(
|
||||||
|
roles
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def test_technical_permissions() -> None:
|
||||||
|
roles = {"TECHNICAL"}
|
||||||
|
assert can_view_owner_roles(
|
||||||
|
roles,
|
||||||
|
"TECHNICAL",
|
||||||
|
)
|
||||||
|
assert can_view_owner_roles(
|
||||||
|
roles,
|
||||||
|
"JOINT",
|
||||||
|
)
|
||||||
|
assert not can_view_owner_roles(
|
||||||
|
roles,
|
||||||
|
"PRODUCTION",
|
||||||
|
)
|
||||||
|
assert can_approve_context_roles(
|
||||||
|
roles
|
||||||
|
)
|
||||||
|
assert not can_manage_clarification_roles(
|
||||||
|
roles
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def test_admin_permissions() -> None:
|
||||||
|
roles = {"ADMIN"}
|
||||||
|
for owner in (
|
||||||
|
"PRODUCTION",
|
||||||
|
"TECHNICAL",
|
||||||
|
"JOINT",
|
||||||
|
):
|
||||||
|
assert can_view_owner_roles(
|
||||||
|
roles,
|
||||||
|
owner,
|
||||||
|
)
|
||||||
|
assert can_approve_context_roles(
|
||||||
|
roles
|
||||||
|
)
|
||||||
|
assert can_manage_clarification_roles(
|
||||||
|
roles
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def test_human_labels() -> None:
|
||||||
|
assert label("WARNING") == "Aviso"
|
||||||
|
assert label("PENDING") == "Pendiente"
|
||||||
|
assert (
|
||||||
|
label(
|
||||||
|
"AUTHORIZED_LEGACY_PRODUCTION"
|
||||||
|
)
|
||||||
|
== "Producción con el sistema antiguo"
|
||||||
|
)
|
||||||
|
assert label("IN_CONSULTATION") == "En consulta"
|
||||||
@ -0,0 +1,33 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import getpass
|
||||||
|
|
||||||
|
import bcrypt
|
||||||
|
|
||||||
|
|
||||||
|
def main() -> int:
|
||||||
|
first = getpass.getpass("Contraseña: ")
|
||||||
|
second = getpass.getpass("Repetir contraseña: ")
|
||||||
|
|
||||||
|
if first != second:
|
||||||
|
raise SystemExit(
|
||||||
|
"Las contraseñas no coinciden."
|
||||||
|
)
|
||||||
|
|
||||||
|
if len(first) < 12:
|
||||||
|
raise SystemExit(
|
||||||
|
"Use al menos 12 caracteres."
|
||||||
|
)
|
||||||
|
|
||||||
|
password_hash = bcrypt.hashpw(
|
||||||
|
first.encode("utf-8"),
|
||||||
|
bcrypt.gensalt(rounds=12),
|
||||||
|
).decode("utf-8")
|
||||||
|
|
||||||
|
print(password_hash)
|
||||||
|
return 0
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
raise SystemExit(main())
|
||||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
5677
ucepsa/edge-oee-demo/grafana/import/platform40-dashboard.raw.json
Normal file
5677
ucepsa/edge-oee-demo/grafana/import/platform40-dashboard.raw.json
Normal file
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
524
ucepsa/edge-oee-demo/grafana/scripts/create_machine_dashboard.py
Executable file
524
ucepsa/edge-oee-demo/grafana/scripts/create_machine_dashboard.py
Executable file
@ -0,0 +1,524 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
import base64
|
||||||
|
import json
|
||||||
|
import sys
|
||||||
|
import urllib.error
|
||||||
|
import urllib.request
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
|
||||||
|
BASE_URL = "http://127.0.0.1:3000"
|
||||||
|
DASHBOARD_UID = "ucepsa-edge-oee-machine"
|
||||||
|
|
||||||
|
|
||||||
|
def read_env(path=".env"):
|
||||||
|
env = {}
|
||||||
|
p = Path(path)
|
||||||
|
if not p.exists():
|
||||||
|
return env
|
||||||
|
|
||||||
|
for raw in p.read_text().splitlines():
|
||||||
|
line = raw.strip()
|
||||||
|
if not line or line.startswith("#") or "=" not in line:
|
||||||
|
continue
|
||||||
|
key, value = line.split("=", 1)
|
||||||
|
env[key.strip()] = value.strip().strip('"').strip("'")
|
||||||
|
return env
|
||||||
|
|
||||||
|
|
||||||
|
env = read_env()
|
||||||
|
user = env.get("GRAFANA_ADMIN_USER", "admin")
|
||||||
|
password = env.get("GRAFANA_ADMIN_PASSWORD")
|
||||||
|
|
||||||
|
if not password:
|
||||||
|
print("ERROR: no encuentro GRAFANA_ADMIN_PASSWORD en .env", file=sys.stderr)
|
||||||
|
sys.exit(1)
|
||||||
|
|
||||||
|
token = base64.b64encode(f"{user}:{password}".encode()).decode()
|
||||||
|
|
||||||
|
|
||||||
|
def api(method, path, payload=None):
|
||||||
|
data = None
|
||||||
|
headers = {
|
||||||
|
"Authorization": f"Basic {token}",
|
||||||
|
"Content-Type": "application/json",
|
||||||
|
"Accept": "application/json",
|
||||||
|
}
|
||||||
|
|
||||||
|
if payload is not None:
|
||||||
|
data = json.dumps(payload).encode()
|
||||||
|
|
||||||
|
req = urllib.request.Request(
|
||||||
|
BASE_URL + path,
|
||||||
|
data=data,
|
||||||
|
headers=headers,
|
||||||
|
method=method,
|
||||||
|
)
|
||||||
|
|
||||||
|
try:
|
||||||
|
with urllib.request.urlopen(req, timeout=20) as resp:
|
||||||
|
body = resp.read().decode()
|
||||||
|
return json.loads(body) if body else {}
|
||||||
|
except urllib.error.HTTPError as e:
|
||||||
|
print("HTTP ERROR:", e.code, e.read().decode(), file=sys.stderr)
|
||||||
|
raise
|
||||||
|
|
||||||
|
|
||||||
|
datasources = api("GET", "/api/datasources")
|
||||||
|
|
||||||
|
print("Datasources encontrados:")
|
||||||
|
for d in datasources:
|
||||||
|
print(f"- name={d.get('name')} type={d.get('type')} uid={d.get('uid')} url={d.get('url')} database={d.get('database')}")
|
||||||
|
|
||||||
|
pg = next(
|
||||||
|
(
|
||||||
|
d for d in datasources
|
||||||
|
if d.get("type") in ("postgres", "grafana-postgresql-datasource")
|
||||||
|
or "postgres" in (d.get("type") or "").lower()
|
||||||
|
or "postgres" in (d.get("name") or "").lower()
|
||||||
|
or "mesavault" in (d.get("name") or "").lower()
|
||||||
|
),
|
||||||
|
None,
|
||||||
|
)
|
||||||
|
|
||||||
|
if not pg:
|
||||||
|
print("ERROR: no encuentro datasource PostgreSQL/MESAVAULT en Grafana", file=sys.stderr)
|
||||||
|
sys.exit(1)
|
||||||
|
|
||||||
|
DS = {
|
||||||
|
"type": "postgres",
|
||||||
|
"uid": pg["uid"],
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def target(sql, ref="A", fmt="table"):
|
||||||
|
return {
|
||||||
|
"refId": ref,
|
||||||
|
"datasource": DS,
|
||||||
|
"format": fmt,
|
||||||
|
"rawQuery": True,
|
||||||
|
"rawSql": sql,
|
||||||
|
"editorMode": "code",
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def stat_panel(panel_id, title, x, y, w, h, sql, unit="none", mappings=None):
|
||||||
|
field_defaults = {
|
||||||
|
"unit": unit,
|
||||||
|
"thresholds": {
|
||||||
|
"mode": "absolute",
|
||||||
|
"steps": [
|
||||||
|
{"color": "green", "value": None},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
if mappings:
|
||||||
|
field_defaults["mappings"] = mappings
|
||||||
|
|
||||||
|
return {
|
||||||
|
"id": panel_id,
|
||||||
|
"type": "stat",
|
||||||
|
"title": title,
|
||||||
|
"datasource": DS,
|
||||||
|
"gridPos": {"x": x, "y": y, "w": w, "h": h},
|
||||||
|
"targets": [target(sql)],
|
||||||
|
"options": {
|
||||||
|
"reduceOptions": {
|
||||||
|
"values": False,
|
||||||
|
"calcs": ["lastNotNull"],
|
||||||
|
"fields": "",
|
||||||
|
},
|
||||||
|
"orientation": "auto",
|
||||||
|
"textMode": "auto",
|
||||||
|
"colorMode": "value",
|
||||||
|
"graphMode": "none",
|
||||||
|
"justifyMode": "auto",
|
||||||
|
},
|
||||||
|
"fieldConfig": {
|
||||||
|
"defaults": field_defaults,
|
||||||
|
"overrides": [],
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def timeseries_panel(panel_id, title, x, y, w, h, sql, unit="none"):
|
||||||
|
return {
|
||||||
|
"id": panel_id,
|
||||||
|
"type": "timeseries",
|
||||||
|
"title": title,
|
||||||
|
"datasource": DS,
|
||||||
|
"gridPos": {"x": x, "y": y, "w": w, "h": h},
|
||||||
|
"targets": [target(sql, fmt="time_series")],
|
||||||
|
"fieldConfig": {
|
||||||
|
"defaults": {
|
||||||
|
"unit": unit,
|
||||||
|
"custom": {
|
||||||
|
"drawStyle": "line",
|
||||||
|
"lineInterpolation": "linear",
|
||||||
|
"lineWidth": 1,
|
||||||
|
"fillOpacity": 5,
|
||||||
|
"showPoints": "never",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
"overrides": [],
|
||||||
|
},
|
||||||
|
"options": {
|
||||||
|
"legend": {
|
||||||
|
"displayMode": "list",
|
||||||
|
"placement": "bottom",
|
||||||
|
"showLegend": True,
|
||||||
|
},
|
||||||
|
"tooltip": {
|
||||||
|
"mode": "multi",
|
||||||
|
"sort": "none",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def table_panel(panel_id, title, x, y, w, h, sql):
|
||||||
|
return {
|
||||||
|
"id": panel_id,
|
||||||
|
"type": "table",
|
||||||
|
"title": title,
|
||||||
|
"datasource": DS,
|
||||||
|
"gridPos": {"x": x, "y": y, "w": w, "h": h},
|
||||||
|
"targets": [target(sql)],
|
||||||
|
"options": {
|
||||||
|
"showHeader": True,
|
||||||
|
"cellHeight": "sm",
|
||||||
|
},
|
||||||
|
"fieldConfig": {
|
||||||
|
"defaults": {},
|
||||||
|
"overrides": [],
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
comm_mapping = [
|
||||||
|
{
|
||||||
|
"type": "value",
|
||||||
|
"options": {
|
||||||
|
"1": {"text": "OK", "color": "green"},
|
||||||
|
"0": {"text": "NO OK", "color": "red"},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
]
|
||||||
|
|
||||||
|
run_mapping = [
|
||||||
|
{
|
||||||
|
"type": "value",
|
||||||
|
"options": {
|
||||||
|
"1": {"text": "EN MARCHA", "color": "green"},
|
||||||
|
"0": {"text": "PARADA", "color": "yellow"},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
]
|
||||||
|
|
||||||
|
dashboard = {
|
||||||
|
"id": None,
|
||||||
|
"uid": DASHBOARD_UID,
|
||||||
|
"title": "MESAVAULT Edge-OEE — Máquina",
|
||||||
|
"tags": ["mesavault", "ucepsa", "edge-oee"],
|
||||||
|
"timezone": "browser",
|
||||||
|
"schemaVersion": 39,
|
||||||
|
"version": 1,
|
||||||
|
"refresh": "5s",
|
||||||
|
"time": {
|
||||||
|
"from": "now-30m",
|
||||||
|
"to": "now",
|
||||||
|
},
|
||||||
|
"templating": {
|
||||||
|
"list": [
|
||||||
|
{
|
||||||
|
"name": "machine_id",
|
||||||
|
"type": "query",
|
||||||
|
"label": "Máquina",
|
||||||
|
"datasource": DS,
|
||||||
|
"refresh": 1,
|
||||||
|
"sort": 1,
|
||||||
|
"multi": False,
|
||||||
|
"includeAll": False,
|
||||||
|
"query": "SELECT machine_id FROM mv_reports_ucepsa_demo.machine_state_latest_multi ORDER BY machine_id",
|
||||||
|
"current": {
|
||||||
|
"text": "CORT-00",
|
||||||
|
"value": "CORT-00",
|
||||||
|
"selected": True,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"panels": [
|
||||||
|
stat_panel(
|
||||||
|
1,
|
||||||
|
"Comunicación",
|
||||||
|
0,
|
||||||
|
0,
|
||||||
|
6,
|
||||||
|
4,
|
||||||
|
"""
|
||||||
|
SELECT
|
||||||
|
CASE
|
||||||
|
WHEN age_s < 30 AND comm_ok = true AND COALESCE(sample_valid, true) = true THEN 1
|
||||||
|
ELSE 0
|
||||||
|
END AS value
|
||||||
|
FROM mv_reports_ucepsa_demo.machine_state_latest_multi
|
||||||
|
WHERE machine_id = '$machine_id'
|
||||||
|
""",
|
||||||
|
mappings=comm_mapping,
|
||||||
|
),
|
||||||
|
stat_panel(
|
||||||
|
2,
|
||||||
|
"Edad último dato",
|
||||||
|
6,
|
||||||
|
0,
|
||||||
|
6,
|
||||||
|
4,
|
||||||
|
"""
|
||||||
|
SELECT age_s AS value
|
||||||
|
FROM mv_reports_ucepsa_demo.machine_state_latest_multi
|
||||||
|
WHERE machine_id = '$machine_id'
|
||||||
|
""",
|
||||||
|
unit="s",
|
||||||
|
),
|
||||||
|
stat_panel(
|
||||||
|
3,
|
||||||
|
"Estado máquina",
|
||||||
|
12,
|
||||||
|
0,
|
||||||
|
6,
|
||||||
|
4,
|
||||||
|
"""
|
||||||
|
SELECT
|
||||||
|
CASE WHEN machine_running = true THEN 1 ELSE 0 END AS value
|
||||||
|
FROM mv_reports_ucepsa_demo.machine_state_latest_multi
|
||||||
|
WHERE machine_id = '$machine_id'
|
||||||
|
""",
|
||||||
|
mappings=run_mapping,
|
||||||
|
),
|
||||||
|
stat_panel(
|
||||||
|
4,
|
||||||
|
"Ciclos/min",
|
||||||
|
18,
|
||||||
|
0,
|
||||||
|
6,
|
||||||
|
4,
|
||||||
|
"""
|
||||||
|
SELECT cycle_rate_ppm AS value
|
||||||
|
FROM mv_reports_ucepsa_demo.machine_state_latest_multi
|
||||||
|
WHERE machine_id = '$machine_id'
|
||||||
|
""",
|
||||||
|
unit="cpm",
|
||||||
|
),
|
||||||
|
stat_panel(
|
||||||
|
5,
|
||||||
|
"Potencia total",
|
||||||
|
0,
|
||||||
|
4,
|
||||||
|
6,
|
||||||
|
4,
|
||||||
|
"""
|
||||||
|
SELECT power_total_kw AS value
|
||||||
|
FROM mv_reports_ucepsa_demo.machine_state_latest_multi
|
||||||
|
WHERE machine_id = '$machine_id'
|
||||||
|
""",
|
||||||
|
unit="kwatt",
|
||||||
|
),
|
||||||
|
stat_panel(
|
||||||
|
6,
|
||||||
|
"Energía importada",
|
||||||
|
6,
|
||||||
|
4,
|
||||||
|
6,
|
||||||
|
4,
|
||||||
|
"""
|
||||||
|
SELECT import_kwh AS value
|
||||||
|
FROM mv_reports_ucepsa_demo.machine_state_latest_multi
|
||||||
|
WHERE machine_id = '$machine_id'
|
||||||
|
""",
|
||||||
|
unit="kwatth",
|
||||||
|
),
|
||||||
|
stat_panel(
|
||||||
|
7,
|
||||||
|
"Frecuencia",
|
||||||
|
12,
|
||||||
|
4,
|
||||||
|
6,
|
||||||
|
4,
|
||||||
|
"""
|
||||||
|
SELECT frequency_hz AS value
|
||||||
|
FROM mv_reports_ucepsa_demo.machine_state_latest_multi
|
||||||
|
WHERE machine_id = '$machine_id'
|
||||||
|
""",
|
||||||
|
unit="hertz",
|
||||||
|
),
|
||||||
|
stat_panel(
|
||||||
|
8,
|
||||||
|
"Pulsos acumulados",
|
||||||
|
18,
|
||||||
|
4,
|
||||||
|
6,
|
||||||
|
4,
|
||||||
|
"""
|
||||||
|
SELECT cycle_pulse_count AS value
|
||||||
|
FROM mv_reports_ucepsa_demo.machine_state_latest_multi
|
||||||
|
WHERE machine_id = '$machine_id'
|
||||||
|
""",
|
||||||
|
unit="none",
|
||||||
|
),
|
||||||
|
timeseries_panel(
|
||||||
|
9,
|
||||||
|
"Potencia por fase",
|
||||||
|
0,
|
||||||
|
8,
|
||||||
|
12,
|
||||||
|
8,
|
||||||
|
"""
|
||||||
|
SELECT
|
||||||
|
ts AS "time",
|
||||||
|
active_power_l1_w AS "L1 W",
|
||||||
|
active_power_l2_w AS "L2 W",
|
||||||
|
active_power_l3_w AS "L3 W",
|
||||||
|
active_power_w AS "Total W"
|
||||||
|
FROM mv_reports_ucepsa_demo.energy_3phase_timeseries
|
||||||
|
WHERE $__timeFilter(ts)
|
||||||
|
AND site = 'ucepsa_onpremise'
|
||||||
|
AND machine_id = '$machine_id'
|
||||||
|
ORDER BY ts
|
||||||
|
""",
|
||||||
|
unit="watt",
|
||||||
|
),
|
||||||
|
timeseries_panel(
|
||||||
|
10,
|
||||||
|
"Tensiones L1/L2/L3",
|
||||||
|
12,
|
||||||
|
8,
|
||||||
|
12,
|
||||||
|
8,
|
||||||
|
"""
|
||||||
|
SELECT
|
||||||
|
ts AS "time",
|
||||||
|
voltage_l1_v AS "L1 V",
|
||||||
|
voltage_l2_v AS "L2 V",
|
||||||
|
voltage_l3_v AS "L3 V"
|
||||||
|
FROM mv_reports_ucepsa_demo.energy_3phase_timeseries
|
||||||
|
WHERE $__timeFilter(ts)
|
||||||
|
AND site = 'ucepsa_onpremise'
|
||||||
|
AND machine_id = '$machine_id'
|
||||||
|
ORDER BY ts
|
||||||
|
""",
|
||||||
|
unit="volt",
|
||||||
|
),
|
||||||
|
timeseries_panel(
|
||||||
|
11,
|
||||||
|
"Corrientes L1/L2/L3",
|
||||||
|
0,
|
||||||
|
16,
|
||||||
|
12,
|
||||||
|
8,
|
||||||
|
"""
|
||||||
|
SELECT
|
||||||
|
ts AS "time",
|
||||||
|
current_l1_a AS "L1 A",
|
||||||
|
current_l2_a AS "L2 A",
|
||||||
|
current_l3_a AS "L3 A"
|
||||||
|
FROM mv_reports_ucepsa_demo.energy_3phase_timeseries
|
||||||
|
WHERE $__timeFilter(ts)
|
||||||
|
AND site = 'ucepsa_onpremise'
|
||||||
|
AND machine_id = '$machine_id'
|
||||||
|
ORDER BY ts
|
||||||
|
""",
|
||||||
|
unit="amp",
|
||||||
|
),
|
||||||
|
table_panel(
|
||||||
|
12,
|
||||||
|
"Última lectura trifásica",
|
||||||
|
12,
|
||||||
|
16,
|
||||||
|
12,
|
||||||
|
8,
|
||||||
|
"""
|
||||||
|
SELECT
|
||||||
|
machine_id,
|
||||||
|
round(age_s::numeric, 1) AS age_s,
|
||||||
|
comm_ok,
|
||||||
|
sample_valid,
|
||||||
|
energy_map,
|
||||||
|
machine_running,
|
||||||
|
cycle_input_state,
|
||||||
|
cycle_pulse_count,
|
||||||
|
wise_cycle_counter_raw,
|
||||||
|
round(voltage_l1_v::numeric, 2) AS v_l1,
|
||||||
|
round(voltage_l2_v::numeric, 2) AS v_l2,
|
||||||
|
round(voltage_l3_v::numeric, 2) AS v_l3,
|
||||||
|
round(current_l1_a::numeric, 3) AS i_l1,
|
||||||
|
round(current_l2_a::numeric, 3) AS i_l2,
|
||||||
|
round(current_l3_a::numeric, 3) AS i_l3,
|
||||||
|
round(power_total_kw::numeric, 4) AS kw_total,
|
||||||
|
digital_inputs
|
||||||
|
FROM mv_reports_ucepsa_demo.machine_state_latest_multi
|
||||||
|
WHERE machine_id = '$machine_id'
|
||||||
|
""",
|
||||||
|
),
|
||||||
|
table_panel(
|
||||||
|
13,
|
||||||
|
"Odoo vs máquina",
|
||||||
|
0,
|
||||||
|
24,
|
||||||
|
12,
|
||||||
|
7,
|
||||||
|
"""
|
||||||
|
SELECT
|
||||||
|
machine_id,
|
||||||
|
order_ref,
|
||||||
|
product_name,
|
||||||
|
odoo_state,
|
||||||
|
machine_running,
|
||||||
|
telemetry_age_s,
|
||||||
|
cycle_rate_ppm,
|
||||||
|
status_check,
|
||||||
|
diagnostic_message
|
||||||
|
FROM mv_reports_ucepsa_demo.odoo_vs_machine_demo
|
||||||
|
WHERE machine_id = '$machine_id'
|
||||||
|
""",
|
||||||
|
),
|
||||||
|
table_panel(
|
||||||
|
14,
|
||||||
|
"Loss Intelligence",
|
||||||
|
12,
|
||||||
|
24,
|
||||||
|
12,
|
||||||
|
7,
|
||||||
|
"""
|
||||||
|
SELECT
|
||||||
|
priority_rank,
|
||||||
|
priority_score,
|
||||||
|
priority_level,
|
||||||
|
machine_id,
|
||||||
|
order_ref,
|
||||||
|
loss_type,
|
||||||
|
severity,
|
||||||
|
duration_min,
|
||||||
|
cost_eur,
|
||||||
|
status,
|
||||||
|
description,
|
||||||
|
recommended_action
|
||||||
|
FROM mv_reports_ucepsa_demo.loss_priority_board
|
||||||
|
WHERE machine_id = '$machine_id'
|
||||||
|
ORDER BY priority_rank
|
||||||
|
""",
|
||||||
|
),
|
||||||
|
],
|
||||||
|
}
|
||||||
|
|
||||||
|
payload = {
|
||||||
|
"dashboard": dashboard,
|
||||||
|
"overwrite": True,
|
||||||
|
"message": "Create MESAVAULT Edge-OEE machine dashboard with machine selector",
|
||||||
|
}
|
||||||
|
|
||||||
|
result = api("POST", "/api/dashboards/db", payload)
|
||||||
|
|
||||||
|
print("Dashboard creado/actualizado:")
|
||||||
|
print(BASE_URL + result.get("url", f"/d/{DASHBOARD_UID}"))
|
||||||
114
ucepsa/edge-oee-demo/grafana/scripts/patch_machine_dashboard_pareto_clean.py
Executable file
114
ucepsa/edge-oee-demo/grafana/scripts/patch_machine_dashboard_pareto_clean.py
Executable file
@ -0,0 +1,114 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
import base64
|
||||||
|
import json
|
||||||
|
import sys
|
||||||
|
import urllib.error
|
||||||
|
import urllib.request
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
|
||||||
|
BASE_URL = "http://127.0.0.1:3000"
|
||||||
|
DASHBOARD_UID = "ucepsa-edge-oee-machine"
|
||||||
|
|
||||||
|
|
||||||
|
def read_env(path=".env"):
|
||||||
|
env = {}
|
||||||
|
p = Path(path)
|
||||||
|
if not p.exists():
|
||||||
|
return env
|
||||||
|
for raw in p.read_text().splitlines():
|
||||||
|
line = raw.strip()
|
||||||
|
if not line or line.startswith("#") or "=" not in line:
|
||||||
|
continue
|
||||||
|
key, value = line.split("=", 1)
|
||||||
|
env[key.strip()] = value.strip().strip('"').strip("'")
|
||||||
|
return env
|
||||||
|
|
||||||
|
|
||||||
|
env = read_env()
|
||||||
|
user = env.get("GRAFANA_ADMIN_USER", "admin")
|
||||||
|
password = env.get("GRAFANA_ADMIN_PASSWORD")
|
||||||
|
|
||||||
|
if not password:
|
||||||
|
print("ERROR: no encuentro GRAFANA_ADMIN_PASSWORD en .env", file=sys.stderr)
|
||||||
|
sys.exit(1)
|
||||||
|
|
||||||
|
token = base64.b64encode(f"{user}:{password}".encode()).decode()
|
||||||
|
|
||||||
|
|
||||||
|
def api(method, path, payload=None):
|
||||||
|
data = None
|
||||||
|
headers = {
|
||||||
|
"Authorization": f"Basic {token}",
|
||||||
|
"Content-Type": "application/json",
|
||||||
|
"Accept": "application/json",
|
||||||
|
}
|
||||||
|
|
||||||
|
if payload is not None:
|
||||||
|
data = json.dumps(payload).encode("utf-8")
|
||||||
|
|
||||||
|
req = urllib.request.Request(
|
||||||
|
BASE_URL + path,
|
||||||
|
data=data,
|
||||||
|
headers=headers,
|
||||||
|
method=method,
|
||||||
|
)
|
||||||
|
|
||||||
|
try:
|
||||||
|
with urllib.request.urlopen(req, timeout=30) as resp:
|
||||||
|
body = resp.read().decode("utf-8")
|
||||||
|
return json.loads(body) if body else {}
|
||||||
|
except urllib.error.HTTPError as e:
|
||||||
|
print("HTTP ERROR:", e.code, e.read().decode("utf-8"), file=sys.stderr)
|
||||||
|
raise
|
||||||
|
|
||||||
|
|
||||||
|
def walk_panels(panels):
|
||||||
|
for p in panels or []:
|
||||||
|
yield p
|
||||||
|
for child in walk_panels(p.get("panels", [])):
|
||||||
|
yield child
|
||||||
|
|
||||||
|
|
||||||
|
doc = api("GET", f"/api/dashboards/uid/{DASHBOARD_UID}")
|
||||||
|
dashboard = doc["dashboard"]
|
||||||
|
meta = doc.get("meta", {})
|
||||||
|
|
||||||
|
changed = 0
|
||||||
|
|
||||||
|
for panel in walk_panels(dashboard.get("panels", [])):
|
||||||
|
if panel.get("title") in ("Pareto causas de paro", "Pareto causas clasificadas"):
|
||||||
|
panel["title"] = "Pareto causas clasificadas por minutos"
|
||||||
|
|
||||||
|
for target in panel.get("targets", []):
|
||||||
|
target["rawSql"] = """
|
||||||
|
SELECT
|
||||||
|
cause_name AS "Causa",
|
||||||
|
ROUND(SUM(duration_min)::numeric, 2) AS "Minutos"
|
||||||
|
FROM mv_reports_ucepsa_demo.machine_stops_grafana
|
||||||
|
WHERE machine_id = '$machine_id'
|
||||||
|
AND classification_status = 'CLASSIFIED'
|
||||||
|
AND status = 'CLOSED'
|
||||||
|
GROUP BY cause_name
|
||||||
|
ORDER BY SUM(duration_min) DESC
|
||||||
|
LIMIT 10
|
||||||
|
"""
|
||||||
|
target["format"] = "table"
|
||||||
|
|
||||||
|
panel.setdefault("fieldConfig", {}).setdefault("defaults", {})["unit"] = "min"
|
||||||
|
changed += 1
|
||||||
|
|
||||||
|
payload = {
|
||||||
|
"dashboard": dashboard,
|
||||||
|
"overwrite": True,
|
||||||
|
"message": "Clean stop cause pareto panel",
|
||||||
|
}
|
||||||
|
|
||||||
|
if meta.get("folderUid"):
|
||||||
|
payload["folderUid"] = meta["folderUid"]
|
||||||
|
|
||||||
|
result = api("POST", "/api/dashboards/db", payload)
|
||||||
|
|
||||||
|
print(f"Paneles modificados: {changed}")
|
||||||
|
print("Dashboard actualizado:")
|
||||||
|
print(BASE_URL + result.get("url", f"/d/{DASHBOARD_UID}"))
|
||||||
507
ucepsa/edge-oee-demo/grafana/scripts/patch_machine_dashboard_stops.py
Executable file
507
ucepsa/edge-oee-demo/grafana/scripts/patch_machine_dashboard_stops.py
Executable file
@ -0,0 +1,507 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
import base64
|
||||||
|
import json
|
||||||
|
import sys
|
||||||
|
import urllib.error
|
||||||
|
import urllib.request
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
|
||||||
|
BASE_URL = "http://127.0.0.1:3000"
|
||||||
|
DASHBOARD_UID = "ucepsa-edge-oee-machine"
|
||||||
|
|
||||||
|
STOP_PANEL_IDS = set(range(300, 320))
|
||||||
|
STOP_PANEL_TITLES = {
|
||||||
|
"Informe de paros",
|
||||||
|
"Paros abiertos",
|
||||||
|
"Pendientes clasificar",
|
||||||
|
"Nº paros",
|
||||||
|
"Minutos paro",
|
||||||
|
"Coste paros",
|
||||||
|
"Duración media",
|
||||||
|
"Pareto causas de paro",
|
||||||
|
"Minutos de paro por hora",
|
||||||
|
"Paros pendientes de clasificación",
|
||||||
|
"Detalle completo de paros",
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def read_env(path=".env"):
|
||||||
|
env = {}
|
||||||
|
p = Path(path)
|
||||||
|
if not p.exists():
|
||||||
|
return env
|
||||||
|
for raw in p.read_text().splitlines():
|
||||||
|
line = raw.strip()
|
||||||
|
if not line or line.startswith("#") or "=" not in line:
|
||||||
|
continue
|
||||||
|
key, value = line.split("=", 1)
|
||||||
|
env[key.strip()] = value.strip().strip('"').strip("'")
|
||||||
|
return env
|
||||||
|
|
||||||
|
|
||||||
|
env = read_env()
|
||||||
|
user = env.get("GRAFANA_ADMIN_USER", "admin")
|
||||||
|
password = env.get("GRAFANA_ADMIN_PASSWORD")
|
||||||
|
|
||||||
|
if not password:
|
||||||
|
print("ERROR: no encuentro GRAFANA_ADMIN_PASSWORD en .env", file=sys.stderr)
|
||||||
|
sys.exit(1)
|
||||||
|
|
||||||
|
token = base64.b64encode(f"{user}:{password}".encode()).decode()
|
||||||
|
|
||||||
|
|
||||||
|
def api(method, path, payload=None):
|
||||||
|
data = None
|
||||||
|
headers = {
|
||||||
|
"Authorization": f"Basic {token}",
|
||||||
|
"Content-Type": "application/json",
|
||||||
|
"Accept": "application/json",
|
||||||
|
}
|
||||||
|
if payload is not None:
|
||||||
|
data = json.dumps(payload).encode("utf-8")
|
||||||
|
|
||||||
|
req = urllib.request.Request(
|
||||||
|
BASE_URL + path,
|
||||||
|
data=data,
|
||||||
|
headers=headers,
|
||||||
|
method=method,
|
||||||
|
)
|
||||||
|
|
||||||
|
try:
|
||||||
|
with urllib.request.urlopen(req, timeout=30) as resp:
|
||||||
|
body = resp.read().decode("utf-8")
|
||||||
|
return json.loads(body) if body else {}
|
||||||
|
except urllib.error.HTTPError as e:
|
||||||
|
print("HTTP ERROR:", e.code, e.read().decode("utf-8"), file=sys.stderr)
|
||||||
|
raise
|
||||||
|
|
||||||
|
|
||||||
|
def flatten_panels(panels):
|
||||||
|
result = []
|
||||||
|
for p in panels or []:
|
||||||
|
result.append(p)
|
||||||
|
if p.get("panels"):
|
||||||
|
result.extend(flatten_panels(p.get("panels")))
|
||||||
|
return result
|
||||||
|
|
||||||
|
|
||||||
|
def find_datasource_from_dashboard(dashboard):
|
||||||
|
for panel in flatten_panels(dashboard.get("panels", [])):
|
||||||
|
ds = panel.get("datasource")
|
||||||
|
if isinstance(ds, dict) and ds.get("uid"):
|
||||||
|
return {
|
||||||
|
"type": ds.get("type", "postgres"),
|
||||||
|
"uid": ds["uid"],
|
||||||
|
}
|
||||||
|
|
||||||
|
for target in panel.get("targets", []) or []:
|
||||||
|
tds = target.get("datasource")
|
||||||
|
if isinstance(tds, dict) and tds.get("uid"):
|
||||||
|
return {
|
||||||
|
"type": tds.get("type", "postgres"),
|
||||||
|
"uid": tds["uid"],
|
||||||
|
}
|
||||||
|
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def find_datasource_fallback():
|
||||||
|
datasources = api("GET", "/api/datasources")
|
||||||
|
for d in datasources:
|
||||||
|
if (
|
||||||
|
d.get("type") in ("postgres", "grafana-postgresql-datasource")
|
||||||
|
or "postgres" in (d.get("type") or "").lower()
|
||||||
|
or "postgres" in (d.get("name") or "").lower()
|
||||||
|
or "mesavault" in (d.get("name") or "").lower()
|
||||||
|
):
|
||||||
|
return {
|
||||||
|
"type": d.get("type", "postgres"),
|
||||||
|
"uid": d["uid"],
|
||||||
|
}
|
||||||
|
|
||||||
|
print("Datasources encontrados:")
|
||||||
|
for d in datasources:
|
||||||
|
print(f"- name={d.get('name')} type={d.get('type')} uid={d.get('uid')}")
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def target(sql, ref="A", fmt="table"):
|
||||||
|
return {
|
||||||
|
"refId": ref,
|
||||||
|
"datasource": DS,
|
||||||
|
"format": fmt,
|
||||||
|
"rawQuery": True,
|
||||||
|
"rawSql": sql,
|
||||||
|
"editorMode": "code",
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def stat_panel(panel_id, title, x, y, w, h, sql, unit="none"):
|
||||||
|
return {
|
||||||
|
"id": panel_id,
|
||||||
|
"type": "stat",
|
||||||
|
"title": title,
|
||||||
|
"datasource": DS,
|
||||||
|
"gridPos": {"x": x, "y": y, "w": w, "h": h},
|
||||||
|
"targets": [target(sql, fmt="table")],
|
||||||
|
"options": {
|
||||||
|
"reduceOptions": {
|
||||||
|
"values": False,
|
||||||
|
"calcs": ["lastNotNull"],
|
||||||
|
"fields": "",
|
||||||
|
},
|
||||||
|
"orientation": "auto",
|
||||||
|
"textMode": "auto",
|
||||||
|
"colorMode": "value",
|
||||||
|
"graphMode": "none",
|
||||||
|
"justifyMode": "auto",
|
||||||
|
},
|
||||||
|
"fieldConfig": {
|
||||||
|
"defaults": {
|
||||||
|
"unit": unit,
|
||||||
|
"thresholds": {
|
||||||
|
"mode": "absolute",
|
||||||
|
"steps": [
|
||||||
|
{"color": "green", "value": None},
|
||||||
|
{"color": "yellow", "value": 1},
|
||||||
|
{"color": "red", "value": 5},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
"overrides": [],
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def table_panel(panel_id, title, x, y, w, h, sql):
|
||||||
|
return {
|
||||||
|
"id": panel_id,
|
||||||
|
"type": "table",
|
||||||
|
"title": title,
|
||||||
|
"datasource": DS,
|
||||||
|
"gridPos": {"x": x, "y": y, "w": w, "h": h},
|
||||||
|
"targets": [target(sql, fmt="table")],
|
||||||
|
"options": {
|
||||||
|
"showHeader": True,
|
||||||
|
"cellHeight": "sm",
|
||||||
|
},
|
||||||
|
"fieldConfig": {
|
||||||
|
"defaults": {},
|
||||||
|
"overrides": [],
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def timeseries_panel(panel_id, title, x, y, w, h, sql, unit="none"):
|
||||||
|
return {
|
||||||
|
"id": panel_id,
|
||||||
|
"type": "timeseries",
|
||||||
|
"title": title,
|
||||||
|
"datasource": DS,
|
||||||
|
"gridPos": {"x": x, "y": y, "w": w, "h": h},
|
||||||
|
"targets": [target(sql, fmt="time_series")],
|
||||||
|
"fieldConfig": {
|
||||||
|
"defaults": {
|
||||||
|
"unit": unit,
|
||||||
|
"custom": {
|
||||||
|
"drawStyle": "line",
|
||||||
|
"lineInterpolation": "linear",
|
||||||
|
"lineWidth": 1,
|
||||||
|
"fillOpacity": 5,
|
||||||
|
"showPoints": "never",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
"overrides": [],
|
||||||
|
},
|
||||||
|
"options": {
|
||||||
|
"legend": {
|
||||||
|
"displayMode": "list",
|
||||||
|
"placement": "bottom",
|
||||||
|
"showLegend": True,
|
||||||
|
},
|
||||||
|
"tooltip": {
|
||||||
|
"mode": "multi",
|
||||||
|
"sort": "none",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def barchart_panel(panel_id, title, x, y, w, h, sql, unit="none"):
|
||||||
|
return {
|
||||||
|
"id": panel_id,
|
||||||
|
"type": "barchart",
|
||||||
|
"title": title,
|
||||||
|
"datasource": DS,
|
||||||
|
"gridPos": {"x": x, "y": y, "w": w, "h": h},
|
||||||
|
"targets": [target(sql, fmt="table")],
|
||||||
|
"fieldConfig": {
|
||||||
|
"defaults": {
|
||||||
|
"unit": unit,
|
||||||
|
},
|
||||||
|
"overrides": [],
|
||||||
|
},
|
||||||
|
"options": {
|
||||||
|
"orientation": "horizontal",
|
||||||
|
"xField": "Causa",
|
||||||
|
"showValue": "always",
|
||||||
|
"legend": {
|
||||||
|
"displayMode": "list",
|
||||||
|
"placement": "bottom",
|
||||||
|
"showLegend": True,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def max_y(panels):
|
||||||
|
y = 0
|
||||||
|
for p in panels:
|
||||||
|
gp = p.get("gridPos") or {}
|
||||||
|
y = max(y, int(gp.get("y", 0)) + int(gp.get("h", 1)))
|
||||||
|
return y
|
||||||
|
|
||||||
|
|
||||||
|
doc = api("GET", f"/api/dashboards/uid/{DASHBOARD_UID}")
|
||||||
|
dashboard = doc["dashboard"]
|
||||||
|
meta = doc.get("meta", {})
|
||||||
|
|
||||||
|
DS = find_datasource_from_dashboard(dashboard) or find_datasource_fallback()
|
||||||
|
|
||||||
|
if not DS:
|
||||||
|
print("ERROR: no encuentro datasource PostgreSQL/MESAVAULT", file=sys.stderr)
|
||||||
|
sys.exit(1)
|
||||||
|
|
||||||
|
print(f"Datasource usado: type={DS.get('type')} uid={DS.get('uid')}")
|
||||||
|
|
||||||
|
existing = dashboard.get("panels", [])
|
||||||
|
|
||||||
|
filtered = []
|
||||||
|
for p in existing:
|
||||||
|
title = p.get("title")
|
||||||
|
pid = p.get("id")
|
||||||
|
if pid in STOP_PANEL_IDS or title in STOP_PANEL_TITLES:
|
||||||
|
continue
|
||||||
|
filtered.append(p)
|
||||||
|
|
||||||
|
base_y = max_y(filtered) + 1
|
||||||
|
|
||||||
|
row = {
|
||||||
|
"id": 300,
|
||||||
|
"type": "row",
|
||||||
|
"title": "Informe de paros",
|
||||||
|
"collapsed": False,
|
||||||
|
"gridPos": {"x": 0, "y": base_y, "w": 24, "h": 1},
|
||||||
|
"panels": [],
|
||||||
|
}
|
||||||
|
|
||||||
|
panels = [
|
||||||
|
row,
|
||||||
|
|
||||||
|
stat_panel(
|
||||||
|
301,
|
||||||
|
"Paros abiertos",
|
||||||
|
0,
|
||||||
|
base_y + 1,
|
||||||
|
4,
|
||||||
|
4,
|
||||||
|
"""
|
||||||
|
SELECT COUNT(*) AS value
|
||||||
|
FROM mv_reports_ucepsa_demo.machine_stops_grafana
|
||||||
|
WHERE machine_id = '$machine_id'
|
||||||
|
AND status = 'OPEN'
|
||||||
|
""",
|
||||||
|
),
|
||||||
|
|
||||||
|
stat_panel(
|
||||||
|
302,
|
||||||
|
"Pendientes clasificar",
|
||||||
|
4,
|
||||||
|
base_y + 1,
|
||||||
|
4,
|
||||||
|
4,
|
||||||
|
"""
|
||||||
|
SELECT COUNT(*) AS value
|
||||||
|
FROM mv_reports_ucepsa_demo.machine_stops_grafana
|
||||||
|
WHERE machine_id = '$machine_id'
|
||||||
|
AND classification_status = 'PENDING'
|
||||||
|
""",
|
||||||
|
),
|
||||||
|
|
||||||
|
stat_panel(
|
||||||
|
303,
|
||||||
|
"Nº paros",
|
||||||
|
8,
|
||||||
|
base_y + 1,
|
||||||
|
4,
|
||||||
|
4,
|
||||||
|
"""
|
||||||
|
SELECT COUNT(*) AS value
|
||||||
|
FROM mv_reports_ucepsa_demo.machine_stops_grafana
|
||||||
|
WHERE machine_id = '$machine_id'
|
||||||
|
AND $__timeFilter(started_at)
|
||||||
|
""",
|
||||||
|
),
|
||||||
|
|
||||||
|
stat_panel(
|
||||||
|
304,
|
||||||
|
"Minutos paro",
|
||||||
|
12,
|
||||||
|
base_y + 1,
|
||||||
|
4,
|
||||||
|
4,
|
||||||
|
"""
|
||||||
|
SELECT COALESCE(SUM(duration_min), 0) AS value
|
||||||
|
FROM mv_reports_ucepsa_demo.machine_stops_grafana
|
||||||
|
WHERE machine_id = '$machine_id'
|
||||||
|
AND $__timeFilter(started_at)
|
||||||
|
""",
|
||||||
|
unit="min",
|
||||||
|
),
|
||||||
|
|
||||||
|
stat_panel(
|
||||||
|
305,
|
||||||
|
"Coste paros",
|
||||||
|
16,
|
||||||
|
base_y + 1,
|
||||||
|
4,
|
||||||
|
4,
|
||||||
|
"""
|
||||||
|
SELECT COALESCE(SUM(cost_eur), 0) AS value
|
||||||
|
FROM mv_reports_ucepsa_demo.machine_stops_grafana
|
||||||
|
WHERE machine_id = '$machine_id'
|
||||||
|
AND $__timeFilter(started_at)
|
||||||
|
""",
|
||||||
|
unit="currencyEUR",
|
||||||
|
),
|
||||||
|
|
||||||
|
stat_panel(
|
||||||
|
306,
|
||||||
|
"Duración media",
|
||||||
|
20,
|
||||||
|
base_y + 1,
|
||||||
|
4,
|
||||||
|
4,
|
||||||
|
"""
|
||||||
|
SELECT COALESCE(AVG(duration_min), 0) AS value
|
||||||
|
FROM mv_reports_ucepsa_demo.machine_stops_grafana
|
||||||
|
WHERE machine_id = '$machine_id'
|
||||||
|
AND $__timeFilter(started_at)
|
||||||
|
""",
|
||||||
|
unit="min",
|
||||||
|
),
|
||||||
|
|
||||||
|
barchart_panel(
|
||||||
|
307,
|
||||||
|
"Pareto causas de paro",
|
||||||
|
0,
|
||||||
|
base_y + 5,
|
||||||
|
12,
|
||||||
|
8,
|
||||||
|
"""
|
||||||
|
SELECT
|
||||||
|
cause_name AS "Causa",
|
||||||
|
ROUND(SUM(duration_min)::numeric, 2) AS "Minutos",
|
||||||
|
COUNT(*) AS "Nº paros",
|
||||||
|
ROUND(SUM(cost_eur)::numeric, 2) AS "Coste €"
|
||||||
|
FROM mv_reports_ucepsa_demo.machine_stops_grafana
|
||||||
|
WHERE machine_id = '$machine_id'
|
||||||
|
AND $__timeFilter(started_at)
|
||||||
|
GROUP BY cause_name
|
||||||
|
ORDER BY SUM(duration_min) DESC
|
||||||
|
LIMIT 10
|
||||||
|
""",
|
||||||
|
unit="min",
|
||||||
|
),
|
||||||
|
|
||||||
|
timeseries_panel(
|
||||||
|
308,
|
||||||
|
"Minutos de paro por hora",
|
||||||
|
12,
|
||||||
|
base_y + 5,
|
||||||
|
12,
|
||||||
|
8,
|
||||||
|
"""
|
||||||
|
SELECT
|
||||||
|
date_trunc('hour', started_at) AS "time",
|
||||||
|
SUM(duration_min) AS "Minutos de paro"
|
||||||
|
FROM mv_reports_ucepsa_demo.machine_stops_grafana
|
||||||
|
WHERE machine_id = '$machine_id'
|
||||||
|
AND $__timeFilter(started_at)
|
||||||
|
GROUP BY 1
|
||||||
|
ORDER BY 1
|
||||||
|
""",
|
||||||
|
unit="min",
|
||||||
|
),
|
||||||
|
|
||||||
|
table_panel(
|
||||||
|
309,
|
||||||
|
"Paros pendientes de clasificación",
|
||||||
|
0,
|
||||||
|
base_y + 13,
|
||||||
|
24,
|
||||||
|
6,
|
||||||
|
"""
|
||||||
|
SELECT
|
||||||
|
id AS "ID",
|
||||||
|
started_at AS "Inicio",
|
||||||
|
ROUND(duration_min::numeric, 2) AS "Min",
|
||||||
|
cost_eur AS "Coste €",
|
||||||
|
status AS "Estado",
|
||||||
|
classification_status AS "Clasificación",
|
||||||
|
cause_name AS "Causa",
|
||||||
|
operator_note AS "Nota operario"
|
||||||
|
FROM mv_reports_ucepsa_demo.machine_stops_grafana
|
||||||
|
WHERE machine_id = '$machine_id'
|
||||||
|
AND classification_status = 'PENDING'
|
||||||
|
ORDER BY started_at DESC
|
||||||
|
""",
|
||||||
|
),
|
||||||
|
|
||||||
|
table_panel(
|
||||||
|
310,
|
||||||
|
"Detalle completo de paros",
|
||||||
|
0,
|
||||||
|
base_y + 19,
|
||||||
|
24,
|
||||||
|
10,
|
||||||
|
"""
|
||||||
|
SELECT
|
||||||
|
id AS "ID",
|
||||||
|
machine_id AS "Máquina",
|
||||||
|
started_at AS "Inicio",
|
||||||
|
ended_at AS "Fin",
|
||||||
|
duration_min AS "Min",
|
||||||
|
cost_eur AS "Coste €",
|
||||||
|
stop_status_label AS "Estado",
|
||||||
|
classification_status AS "Clasificación",
|
||||||
|
cause_code AS "Código causa",
|
||||||
|
cause_name AS "Causa",
|
||||||
|
start_event_name AS "Evento inicio",
|
||||||
|
end_event_name AS "Evento fin",
|
||||||
|
operator_note AS "Nota operario"
|
||||||
|
FROM mv_reports_ucepsa_demo.machine_stops_grafana
|
||||||
|
WHERE machine_id = '$machine_id'
|
||||||
|
AND $__timeFilter(started_at)
|
||||||
|
ORDER BY started_at DESC
|
||||||
|
LIMIT 100
|
||||||
|
""",
|
||||||
|
),
|
||||||
|
]
|
||||||
|
|
||||||
|
dashboard["panels"] = filtered + panels
|
||||||
|
|
||||||
|
payload = {
|
||||||
|
"dashboard": dashboard,
|
||||||
|
"overwrite": True,
|
||||||
|
"message": "Add stop report section to machine dashboard",
|
||||||
|
}
|
||||||
|
|
||||||
|
if meta.get("folderUid"):
|
||||||
|
payload["folderUid"] = meta["folderUid"]
|
||||||
|
|
||||||
|
result = api("POST", "/api/dashboards/db", payload)
|
||||||
|
|
||||||
|
print("Dashboard actualizado:")
|
||||||
|
print(BASE_URL + result.get("url", f"/d/{DASHBOARD_UID}"))
|
||||||
151
ucepsa/edge-oee-demo/grafana/scripts/patch_machine_dashboard_stops_pareto.py
Executable file
151
ucepsa/edge-oee-demo/grafana/scripts/patch_machine_dashboard_stops_pareto.py
Executable file
@ -0,0 +1,151 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
import base64
|
||||||
|
import json
|
||||||
|
import sys
|
||||||
|
import urllib.error
|
||||||
|
import urllib.request
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
|
||||||
|
BASE_URL = "http://127.0.0.1:3000"
|
||||||
|
DASHBOARD_UID = "ucepsa-edge-oee-machine"
|
||||||
|
|
||||||
|
|
||||||
|
def read_env(path=".env"):
|
||||||
|
env = {}
|
||||||
|
p = Path(path)
|
||||||
|
if not p.exists():
|
||||||
|
return env
|
||||||
|
for raw in p.read_text().splitlines():
|
||||||
|
line = raw.strip()
|
||||||
|
if not line or line.startswith("#") or "=" not in line:
|
||||||
|
continue
|
||||||
|
key, value = line.split("=", 1)
|
||||||
|
env[key.strip()] = value.strip().strip('"').strip("'")
|
||||||
|
return env
|
||||||
|
|
||||||
|
|
||||||
|
env = read_env()
|
||||||
|
user = env.get("GRAFANA_ADMIN_USER", "admin")
|
||||||
|
password = env.get("GRAFANA_ADMIN_PASSWORD")
|
||||||
|
|
||||||
|
if not password:
|
||||||
|
print("ERROR: no encuentro GRAFANA_ADMIN_PASSWORD en .env", file=sys.stderr)
|
||||||
|
sys.exit(1)
|
||||||
|
|
||||||
|
token = base64.b64encode(f"{user}:{password}".encode()).decode()
|
||||||
|
|
||||||
|
|
||||||
|
def api(method, path, payload=None):
|
||||||
|
data = None
|
||||||
|
headers = {
|
||||||
|
"Authorization": f"Basic {token}",
|
||||||
|
"Content-Type": "application/json",
|
||||||
|
"Accept": "application/json",
|
||||||
|
}
|
||||||
|
|
||||||
|
if payload is not None:
|
||||||
|
data = json.dumps(payload).encode("utf-8")
|
||||||
|
|
||||||
|
req = urllib.request.Request(
|
||||||
|
BASE_URL + path,
|
||||||
|
data=data,
|
||||||
|
headers=headers,
|
||||||
|
method=method,
|
||||||
|
)
|
||||||
|
|
||||||
|
try:
|
||||||
|
with urllib.request.urlopen(req, timeout=30) as resp:
|
||||||
|
body = resp.read().decode("utf-8")
|
||||||
|
return json.loads(body) if body else {}
|
||||||
|
except urllib.error.HTTPError as e:
|
||||||
|
print("HTTP ERROR:", e.code, e.read().decode("utf-8"), file=sys.stderr)
|
||||||
|
raise
|
||||||
|
|
||||||
|
|
||||||
|
def all_panels(panels):
|
||||||
|
out = []
|
||||||
|
for p in panels or []:
|
||||||
|
out.append(p)
|
||||||
|
out.extend(all_panels(p.get("panels", [])))
|
||||||
|
return out
|
||||||
|
|
||||||
|
|
||||||
|
doc = api("GET", f"/api/dashboards/uid/{DASHBOARD_UID}")
|
||||||
|
dashboard = doc["dashboard"]
|
||||||
|
meta = doc.get("meta", {})
|
||||||
|
|
||||||
|
changed = 0
|
||||||
|
|
||||||
|
for panel in all_panels(dashboard.get("panels", [])):
|
||||||
|
title = panel.get("title", "")
|
||||||
|
|
||||||
|
if title == "Nº paros":
|
||||||
|
panel["title"] = "Nº paros en rango"
|
||||||
|
changed += 1
|
||||||
|
|
||||||
|
elif title == "Minutos paro":
|
||||||
|
panel["title"] = "Minutos paro en rango"
|
||||||
|
changed += 1
|
||||||
|
|
||||||
|
elif title == "Coste paros":
|
||||||
|
panel["title"] = "Coste paros en rango"
|
||||||
|
changed += 1
|
||||||
|
|
||||||
|
elif title == "Duración media":
|
||||||
|
panel["title"] = "Duración media en rango"
|
||||||
|
changed += 1
|
||||||
|
|
||||||
|
elif title == "Pareto causas de paro":
|
||||||
|
panel["title"] = "Pareto causas clasificadas"
|
||||||
|
for target in panel.get("targets", []):
|
||||||
|
target["rawSql"] = """
|
||||||
|
SELECT
|
||||||
|
cause_name AS "Causa",
|
||||||
|
ROUND(SUM(duration_min)::numeric, 2) AS "Minutos",
|
||||||
|
COUNT(*) AS "Nº paros",
|
||||||
|
ROUND(SUM(cost_eur)::numeric, 2) AS "Coste €"
|
||||||
|
FROM mv_reports_ucepsa_demo.machine_stops_grafana
|
||||||
|
WHERE machine_id = '$machine_id'
|
||||||
|
AND classification_status = 'CLASSIFIED'
|
||||||
|
GROUP BY cause_name
|
||||||
|
ORDER BY SUM(duration_min) DESC
|
||||||
|
LIMIT 10
|
||||||
|
"""
|
||||||
|
target["format"] = "table"
|
||||||
|
changed += 1
|
||||||
|
|
||||||
|
elif title == "Paros pendientes de clasificación":
|
||||||
|
# Dejamos este panel sin filtro temporal: es backlog operativo.
|
||||||
|
for target in panel.get("targets", []):
|
||||||
|
target["rawSql"] = """
|
||||||
|
SELECT
|
||||||
|
id AS "ID",
|
||||||
|
started_at AS "Inicio",
|
||||||
|
ROUND(duration_min::numeric, 2) AS "Min",
|
||||||
|
cost_eur AS "Coste €",
|
||||||
|
status AS "Estado",
|
||||||
|
classification_status AS "Clasificación",
|
||||||
|
cause_name AS "Causa",
|
||||||
|
operator_note AS "Nota operario"
|
||||||
|
FROM mv_reports_ucepsa_demo.machine_stops_grafana
|
||||||
|
WHERE machine_id = '$machine_id'
|
||||||
|
AND classification_status = 'PENDING'
|
||||||
|
ORDER BY started_at DESC
|
||||||
|
"""
|
||||||
|
changed += 1
|
||||||
|
|
||||||
|
payload = {
|
||||||
|
"dashboard": dashboard,
|
||||||
|
"overwrite": True,
|
||||||
|
"message": "Fix stop report pareto and clarify time-scoped panels",
|
||||||
|
}
|
||||||
|
|
||||||
|
if meta.get("folderUid"):
|
||||||
|
payload["folderUid"] = meta["folderUid"]
|
||||||
|
|
||||||
|
result = api("POST", "/api/dashboards/db", payload)
|
||||||
|
|
||||||
|
print(f"Paneles modificados: {changed}")
|
||||||
|
print("Dashboard actualizado:")
|
||||||
|
print(BASE_URL + result.get("url", f"/d/{DASHBOARD_UID}"))
|
||||||
318
ucepsa/edge-oee-demo/ops/cleanup_grafana_dashboards_v0361.sh
Executable file
318
ucepsa/edge-oee-demo/ops/cleanup_grafana_dashboards_v0361.sh
Executable file
@ -0,0 +1,318 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
set -euo pipefail
|
||||||
|
|
||||||
|
ACTION="${1:-plan}"
|
||||||
|
|
||||||
|
GRAFANA_CONTAINER="${GRAFANA_CONTAINER:-mv_ucepsa_grafana}"
|
||||||
|
BACKUP_BASE="${GRAFANA_CLEANUP_BACKUP_BASE:-/srv/mesavault/backups/ucepsa-grafana-cleanup}"
|
||||||
|
|
||||||
|
KEEP_UIDS=(
|
||||||
|
"ucepsa-shopfloor-context-health"
|
||||||
|
"ucepsa-edge-oee-machine"
|
||||||
|
)
|
||||||
|
|
||||||
|
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 ni guardarlo en Git:
|
||||||
|
|
||||||
|
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)"
|
||||||
|
export BACKUP_BASE
|
||||||
|
export KEEP_UIDS_JSON
|
||||||
|
KEEP_UIDS_JSON="$(
|
||||||
|
printf '%s\n' "${KEEP_UIDS[@]}" \
|
||||||
|
| python3 -c 'import json,sys; print(json.dumps([x.strip() for x in sys.stdin if x.strip()]))'
|
||||||
|
)"
|
||||||
|
|
||||||
|
python3 - "$ACTION" <<'PY'
|
||||||
|
import datetime as dt
|
||||||
|
import hashlib
|
||||||
|
import json
|
||||||
|
import os
|
||||||
|
import pathlib
|
||||||
|
import re
|
||||||
|
import sys
|
||||||
|
import urllib.error
|
||||||
|
import urllib.parse
|
||||||
|
import urllib.request
|
||||||
|
|
||||||
|
action = sys.argv[1]
|
||||||
|
base_url = os.environ["GRAFANA_URL_RESOLVED"].rstrip("/")
|
||||||
|
token = os.environ["GRAFANA_API_TOKEN"]
|
||||||
|
backup_base = pathlib.Path(os.environ["BACKUP_BASE"])
|
||||||
|
keep_uids = set(json.loads(os.environ["KEEP_UIDS_JSON"]))
|
||||||
|
|
||||||
|
confirm_value = os.environ.get("GRAFANA_CLEANUP_CONFIRM", "")
|
||||||
|
required_confirm = "DELETE_NON_CANONICAL_DASHBOARDS"
|
||||||
|
|
||||||
|
|
||||||
|
def request(method, path, payload=None, allow_404=False):
|
||||||
|
url = base_url + path
|
||||||
|
data = None
|
||||||
|
headers = {
|
||||||
|
"Accept": "application/json",
|
||||||
|
"Authorization": f"Bearer {token}",
|
||||||
|
}
|
||||||
|
|
||||||
|
if payload is not None:
|
||||||
|
data = json.dumps(payload).encode("utf-8")
|
||||||
|
headers["Content-Type"] = "application/json"
|
||||||
|
|
||||||
|
req = urllib.request.Request(
|
||||||
|
url,
|
||||||
|
data=data,
|
||||||
|
headers=headers,
|
||||||
|
method=method,
|
||||||
|
)
|
||||||
|
|
||||||
|
try:
|
||||||
|
with urllib.request.urlopen(req, timeout=20) as response:
|
||||||
|
raw = response.read()
|
||||||
|
return json.loads(raw.decode("utf-8")) if raw else None
|
||||||
|
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
|
||||||
|
|
||||||
|
|
||||||
|
def search(kind):
|
||||||
|
encoded = urllib.parse.urlencode({
|
||||||
|
"type": kind,
|
||||||
|
"limit": 5000,
|
||||||
|
})
|
||||||
|
return request("GET", f"/api/search?{encoded}") or []
|
||||||
|
|
||||||
|
|
||||||
|
def dashboard_row(item):
|
||||||
|
return {
|
||||||
|
"uid": item.get("uid"),
|
||||||
|
"title": item.get("title"),
|
||||||
|
"folder_uid": item.get("folderUid"),
|
||||||
|
"folder_title": item.get("folderTitle") or "General",
|
||||||
|
"url": item.get("url"),
|
||||||
|
"tags": item.get("tags") or [],
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
health = request("GET", "/api/health")
|
||||||
|
if not health or health.get("database") != "ok":
|
||||||
|
raise RuntimeError(f"Grafana no está saludable: {health!r}")
|
||||||
|
|
||||||
|
dashboards = [dashboard_row(x) for x in search("dash-db")]
|
||||||
|
folders = search("dash-folder")
|
||||||
|
|
||||||
|
found_uids = {row["uid"] for row in dashboards}
|
||||||
|
missing = sorted(keep_uids - found_uids)
|
||||||
|
|
||||||
|
if missing:
|
||||||
|
raise RuntimeError(
|
||||||
|
"Faltan dashboards canónicos; se aborta para no borrar nada: "
|
||||||
|
+ ", ".join(missing)
|
||||||
|
)
|
||||||
|
|
||||||
|
rows = []
|
||||||
|
for row in sorted(
|
||||||
|
dashboards,
|
||||||
|
key=lambda x: (
|
||||||
|
x["folder_title"].lower(),
|
||||||
|
x["title"].lower(),
|
||||||
|
x["uid"],
|
||||||
|
),
|
||||||
|
):
|
||||||
|
rows.append({
|
||||||
|
**row,
|
||||||
|
"decision": "KEEP" if row["uid"] in keep_uids else "DELETE",
|
||||||
|
})
|
||||||
|
|
||||||
|
print("=== PLAN DE LIMPIEZA ===")
|
||||||
|
print(json.dumps(rows, ensure_ascii=False, indent=2))
|
||||||
|
print()
|
||||||
|
print(json.dumps({
|
||||||
|
"grafana_url": base_url,
|
||||||
|
"dashboard_count": len(rows),
|
||||||
|
"keep_count": sum(r["decision"] == "KEEP" for r in rows),
|
||||||
|
"delete_count": sum(r["decision"] == "DELETE" for r in rows),
|
||||||
|
"keep_uids": sorted(keep_uids),
|
||||||
|
}, ensure_ascii=False, indent=2))
|
||||||
|
|
||||||
|
if action == "plan":
|
||||||
|
print()
|
||||||
|
print("PLAN ONLY: no se ha modificado Grafana.")
|
||||||
|
raise SystemExit(0)
|
||||||
|
|
||||||
|
if action == "apply":
|
||||||
|
if confirm_value != required_confirm:
|
||||||
|
raise RuntimeError(
|
||||||
|
"Confirmación ausente. Ejecute:\n"
|
||||||
|
f" export GRAFANA_CLEANUP_CONFIRM='{required_confirm}'"
|
||||||
|
)
|
||||||
|
|
||||||
|
timestamp = dt.datetime.now().strftime("%Y%m%d_%H%M%S")
|
||||||
|
backup_dir = backup_base / timestamp
|
||||||
|
dashboard_dir = backup_dir / "dashboards"
|
||||||
|
dashboard_dir.mkdir(parents=True, exist_ok=False)
|
||||||
|
|
||||||
|
manifest = {
|
||||||
|
"created_at": dt.datetime.now(dt.timezone.utc).isoformat(),
|
||||||
|
"grafana_url": base_url,
|
||||||
|
"keep_uids": sorted(keep_uids),
|
||||||
|
"dashboards_before": rows,
|
||||||
|
"folders_before": folders,
|
||||||
|
}
|
||||||
|
|
||||||
|
(backup_dir / "manifest.json").write_text(
|
||||||
|
json.dumps(manifest, ensure_ascii=False, indent=2) + "\n",
|
||||||
|
encoding="utf-8",
|
||||||
|
)
|
||||||
|
|
||||||
|
for row in rows:
|
||||||
|
uid = row["uid"]
|
||||||
|
payload = request(
|
||||||
|
"GET",
|
||||||
|
f"/api/dashboards/uid/{urllib.parse.quote(uid)}",
|
||||||
|
)
|
||||||
|
safe_uid = re.sub(r"[^A-Za-z0-9._-]+", "_", uid)
|
||||||
|
(dashboard_dir / f"{safe_uid}.json").write_text(
|
||||||
|
json.dumps(payload, ensure_ascii=False, indent=2) + "\n",
|
||||||
|
encoding="utf-8",
|
||||||
|
)
|
||||||
|
|
||||||
|
checksums = []
|
||||||
|
for path in sorted(backup_dir.rglob("*.json")):
|
||||||
|
digest = hashlib.sha256(path.read_bytes()).hexdigest()
|
||||||
|
checksums.append(
|
||||||
|
f"{digest} {path.relative_to(backup_dir)}"
|
||||||
|
)
|
||||||
|
|
||||||
|
(backup_dir / "SHA256SUMS").write_text(
|
||||||
|
"\n".join(checksums) + "\n",
|
||||||
|
encoding="utf-8",
|
||||||
|
)
|
||||||
|
|
||||||
|
deleted = []
|
||||||
|
for row in rows:
|
||||||
|
if row["decision"] != "DELETE":
|
||||||
|
continue
|
||||||
|
uid = row["uid"]
|
||||||
|
result = request(
|
||||||
|
"DELETE",
|
||||||
|
f"/api/dashboards/uid/{urllib.parse.quote(uid)}",
|
||||||
|
)
|
||||||
|
deleted.append({
|
||||||
|
"uid": uid,
|
||||||
|
"title": row["title"],
|
||||||
|
"folder_title": row["folder_title"],
|
||||||
|
"response": result,
|
||||||
|
})
|
||||||
|
|
||||||
|
remaining = [dashboard_row(x) for x in search("dash-db")]
|
||||||
|
remaining_uids = {x["uid"] for x in remaining}
|
||||||
|
|
||||||
|
if remaining_uids != keep_uids:
|
||||||
|
raise RuntimeError(
|
||||||
|
"La limpieza terminó con un conjunto inesperado.\n"
|
||||||
|
f"Esperado: {sorted(keep_uids)}\n"
|
||||||
|
f"Actual: {sorted(remaining_uids)}"
|
||||||
|
)
|
||||||
|
|
||||||
|
used_folder_uids = {
|
||||||
|
x["folder_uid"]
|
||||||
|
for x in remaining
|
||||||
|
if x.get("folder_uid")
|
||||||
|
}
|
||||||
|
|
||||||
|
empty_folders = [
|
||||||
|
{
|
||||||
|
"uid": folder.get("uid"),
|
||||||
|
"title": folder.get("title"),
|
||||||
|
}
|
||||||
|
for folder in folders
|
||||||
|
if folder.get("uid")
|
||||||
|
and folder.get("uid") not in used_folder_uids
|
||||||
|
]
|
||||||
|
|
||||||
|
result = {
|
||||||
|
"status": "ok",
|
||||||
|
"backup_dir": str(backup_dir),
|
||||||
|
"deleted_count": len(deleted),
|
||||||
|
"deleted": deleted,
|
||||||
|
"remaining": remaining,
|
||||||
|
"empty_folders_not_deleted": empty_folders,
|
||||||
|
}
|
||||||
|
|
||||||
|
(backup_dir / "result.json").write_text(
|
||||||
|
json.dumps(result, ensure_ascii=False, indent=2) + "\n",
|
||||||
|
encoding="utf-8",
|
||||||
|
)
|
||||||
|
|
||||||
|
print("\n=== RESULTADO ===")
|
||||||
|
print(json.dumps(result, ensure_ascii=False, indent=2))
|
||||||
|
raise SystemExit(0)
|
||||||
|
|
||||||
|
if action == "verify":
|
||||||
|
remaining_uids = {row["uid"] for row in dashboards}
|
||||||
|
errors = []
|
||||||
|
|
||||||
|
if remaining_uids != keep_uids:
|
||||||
|
errors.append({
|
||||||
|
"expected": sorted(keep_uids),
|
||||||
|
"actual": sorted(remaining_uids),
|
||||||
|
})
|
||||||
|
|
||||||
|
print(json.dumps({
|
||||||
|
"status": "ok" if not errors else "error",
|
||||||
|
"dashboard_count": len(dashboards),
|
||||||
|
"dashboards": dashboards,
|
||||||
|
"errors": errors,
|
||||||
|
}, ensure_ascii=False, indent=2))
|
||||||
|
|
||||||
|
raise SystemExit(1 if errors else 0)
|
||||||
|
|
||||||
|
raise SystemExit(
|
||||||
|
"Uso: cleanup_grafana_dashboards_v0361.sh {plan|apply|verify}"
|
||||||
|
)
|
||||||
|
PY
|
||||||
257
ucepsa/edge-oee-demo/ops/deploy_canonical_context_arbitration_dashboard_v038.sh
Executable file
257
ucepsa/edge-oee-demo/ops/deploy_canonical_context_arbitration_dashboard_v038.sh
Executable file
@ -0,0 +1,257 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
set -euo pipefail
|
||||||
|
|
||||||
|
ACTION="${1:-deploy}"
|
||||||
|
GRAFANA_CONTAINER="${GRAFANA_CONTAINER:-mv_ucepsa_grafana}"
|
||||||
|
REPO_ROOT="${CANONICAL_DASHBOARD_REPO_ROOT:-/srv/mesavault/40-clients/ucepsa/edge-oee-demo}"
|
||||||
|
DASHBOARD_FILE="${CANONICAL_DASHBOARD_FILE:-$REPO_ROOT/grafana/dashboards/ucepsa-shopfloor-context-health.dashboard.json}"
|
||||||
|
FOLDER_UID="${CANONICAL_DASHBOARD_FOLDER_UID:-ucepsa-mesavault}"
|
||||||
|
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 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.
|
||||||
|
|
||||||
|
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" \
|
||||||
|
"$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,
|
||||||
|
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):
|
||||||
|
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(
|
||||||
|
base_url + path,
|
||||||
|
data=body,
|
||||||
|
headers=headers,
|
||||||
|
method=method,
|
||||||
|
)
|
||||||
|
|
||||||
|
try:
|
||||||
|
with urllib.request.urlopen(
|
||||||
|
req,
|
||||||
|
timeout=20,
|
||||||
|
) as response:
|
||||||
|
raw = response.read()
|
||||||
|
return (
|
||||||
|
json.loads(raw.decode("utf-8"))
|
||||||
|
if raw
|
||||||
|
else None
|
||||||
|
)
|
||||||
|
except urllib.error.HTTPError as exc:
|
||||||
|
detail = exc.read().decode(
|
||||||
|
"utf-8",
|
||||||
|
errors="replace",
|
||||||
|
)
|
||||||
|
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}"
|
||||||
|
)
|
||||||
|
|
||||||
|
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 canonical context "
|
||||||
|
"arbitration SHADOW v0.3.8"
|
||||||
|
),
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
print(json.dumps(
|
||||||
|
{
|
||||||
|
"status": "deployed",
|
||||||
|
"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 len(dashboard.get("panels", [])) != 16:
|
||||||
|
errors.append(
|
||||||
|
"El dashboard no tiene 16 paneles"
|
||||||
|
)
|
||||||
|
|
||||||
|
if datasource_uids != [datasource_uid]:
|
||||||
|
errors.append(
|
||||||
|
f"Datasource inesperado: {datasource_uids!r}"
|
||||||
|
)
|
||||||
|
|
||||||
|
expected = {
|
||||||
|
14: "li_machine_canonical_context_live_v1",
|
||||||
|
15: "li_context_arbitration_conflicts_v1",
|
||||||
|
16: "li_canonical_context_coverage_v1",
|
||||||
|
}
|
||||||
|
|
||||||
|
for panel_id, fragment in expected.items():
|
||||||
|
sql = (
|
||||||
|
panels.get(panel_id, {})
|
||||||
|
.get("targets", [{}])[0]
|
||||||
|
.get("rawSql", "")
|
||||||
|
)
|
||||||
|
if fragment not in sql:
|
||||||
|
errors.append(
|
||||||
|
f"Panel {panel_id} no consulta {fragment}"
|
||||||
|
)
|
||||||
|
|
||||||
|
if dashboard.get("refresh") != "15s":
|
||||||
|
errors.append(
|
||||||
|
"El refresco no está alineado a 15s"
|
||||||
|
)
|
||||||
|
|
||||||
|
output = {
|
||||||
|
"status": "ok" if not errors else "error",
|
||||||
|
"dashboard_uid": dashboard.get("uid"),
|
||||||
|
"folder_uid": result.get("meta", {}).get(
|
||||||
|
"folderUid"
|
||||||
|
),
|
||||||
|
"panel_count": len(
|
||||||
|
dashboard.get("panels", [])
|
||||||
|
),
|
||||||
|
"refresh": dashboard.get("refresh"),
|
||||||
|
"datasource_uids": datasource_uids,
|
||||||
|
"version": dashboard.get("version"),
|
||||||
|
"url": result.get("meta", {}).get("url"),
|
||||||
|
"canonical_panels": {
|
||||||
|
"live": 14 in panels,
|
||||||
|
"conflicts": 15 in panels,
|
||||||
|
"coverage": 16 in panels,
|
||||||
|
},
|
||||||
|
"errors": errors,
|
||||||
|
}
|
||||||
|
|
||||||
|
print(json.dumps(
|
||||||
|
output,
|
||||||
|
ensure_ascii=False,
|
||||||
|
indent=2,
|
||||||
|
))
|
||||||
|
|
||||||
|
if errors:
|
||||||
|
raise SystemExit(1)
|
||||||
|
|
||||||
|
else:
|
||||||
|
raise SystemExit(
|
||||||
|
"Uso: "
|
||||||
|
"deploy_canonical_context_arbitration_dashboard_v038.sh "
|
||||||
|
"{deploy|validate}"
|
||||||
|
)
|
||||||
|
PY
|
||||||
259
ucepsa/edge-oee-demo/ops/deploy_context_daily_governance_dashboard_v0312.sh
Executable file
259
ucepsa/edge-oee-demo/ops/deploy_context_daily_governance_dashboard_v0312.sh
Executable file
@ -0,0 +1,259 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
set -euo pipefail
|
||||||
|
|
||||||
|
ACTION="${1:-deploy}"
|
||||||
|
GRAFANA_CONTAINER="${GRAFANA_CONTAINER:-mv_ucepsa_grafana}"
|
||||||
|
REPO_ROOT="${DAILY_GOVERNANCE_DASHBOARD_REPO_ROOT:-/srv/mesavault/40-clients/ucepsa/edge-oee-demo}"
|
||||||
|
DASHBOARD_FILE="${DAILY_GOVERNANCE_DASHBOARD_FILE:-$REPO_ROOT/grafana/dashboards/ucepsa-shopfloor-context-health.dashboard.json}"
|
||||||
|
FOLDER_UID="${DAILY_GOVERNANCE_DASHBOARD_FOLDER_UID:-ucepsa-mesavault}"
|
||||||
|
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 Grafana." >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
printf 'http://%s:3000' "$container_ip"
|
||||||
|
}
|
||||||
|
|
||||||
|
if [[ -z "${GRAFANA_API_TOKEN:-}" ]]; then
|
||||||
|
echo "ERROR: falta GRAFANA_API_TOKEN." >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
export GRAFANA_URL_RESOLVED
|
||||||
|
GRAFANA_URL_RESOLVED="$(resolve_url)"
|
||||||
|
|
||||||
|
python3 - \
|
||||||
|
"$ACTION" \
|
||||||
|
"$DASHBOARD_FILE" \
|
||||||
|
"$FOLDER_UID" \
|
||||||
|
"$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, 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):
|
||||||
|
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(
|
||||||
|
base_url + path,
|
||||||
|
data=body,
|
||||||
|
headers=headers,
|
||||||
|
method=method,
|
||||||
|
)
|
||||||
|
|
||||||
|
try:
|
||||||
|
with urllib.request.urlopen(
|
||||||
|
req,
|
||||||
|
timeout=20,
|
||||||
|
) as response:
|
||||||
|
raw = response.read()
|
||||||
|
return (
|
||||||
|
json.loads(raw.decode("utf-8"))
|
||||||
|
if raw
|
||||||
|
else None
|
||||||
|
)
|
||||||
|
except urllib.error.HTTPError as exc:
|
||||||
|
detail = exc.read().decode(
|
||||||
|
"utf-8",
|
||||||
|
errors="replace",
|
||||||
|
)
|
||||||
|
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}"
|
||||||
|
)
|
||||||
|
|
||||||
|
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 daily governance agenda "
|
||||||
|
"SHADOW v0.3.12"
|
||||||
|
),
|
||||||
|
},
|
||||||
|
)
|
||||||
|
print(json.dumps(
|
||||||
|
{
|
||||||
|
"status": "deployed",
|
||||||
|
"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 len(dashboard.get("panels", [])) != 28:
|
||||||
|
errors.append(
|
||||||
|
"El dashboard no tiene 28 paneles"
|
||||||
|
)
|
||||||
|
|
||||||
|
if dashboard.get("refresh") != "15s":
|
||||||
|
errors.append(
|
||||||
|
"El refresco no está en 15s"
|
||||||
|
)
|
||||||
|
|
||||||
|
if datasource_uids != [datasource_uid]:
|
||||||
|
errors.append(
|
||||||
|
f"Datasource inesperado: {datasource_uids!r}"
|
||||||
|
)
|
||||||
|
|
||||||
|
expected = {
|
||||||
|
26: "li_context_governance_daily_agenda_v1",
|
||||||
|
27: "li_context_governance_decision_evidence_pack_v1",
|
||||||
|
28: "li_context_governance_daily_summary_v1",
|
||||||
|
}
|
||||||
|
|
||||||
|
for panel_id, fragment in expected.items():
|
||||||
|
sql = (
|
||||||
|
panels.get(panel_id, {})
|
||||||
|
.get("targets", [{}])[0]
|
||||||
|
.get("rawSql", "")
|
||||||
|
)
|
||||||
|
if fragment not in sql:
|
||||||
|
errors.append(
|
||||||
|
f"Panel {panel_id} no consulta {fragment}"
|
||||||
|
)
|
||||||
|
|
||||||
|
output = {
|
||||||
|
"status":
|
||||||
|
"ok" if not errors else "error",
|
||||||
|
"dashboard_uid":
|
||||||
|
dashboard.get("uid"),
|
||||||
|
"folder_uid":
|
||||||
|
result.get(
|
||||||
|
"meta",
|
||||||
|
{},
|
||||||
|
).get("folderUid"),
|
||||||
|
"panel_count":
|
||||||
|
len(
|
||||||
|
dashboard.get(
|
||||||
|
"panels",
|
||||||
|
[],
|
||||||
|
)
|
||||||
|
),
|
||||||
|
"refresh":
|
||||||
|
dashboard.get("refresh"),
|
||||||
|
"datasource_uids":
|
||||||
|
datasource_uids,
|
||||||
|
"version":
|
||||||
|
dashboard.get("version"),
|
||||||
|
"url":
|
||||||
|
result.get(
|
||||||
|
"meta",
|
||||||
|
{},
|
||||||
|
).get("url"),
|
||||||
|
"daily_governance_panels": {
|
||||||
|
"agenda": 26 in panels,
|
||||||
|
"evidence_pack": 27 in panels,
|
||||||
|
"summary": 28 in panels,
|
||||||
|
},
|
||||||
|
"errors":
|
||||||
|
errors,
|
||||||
|
}
|
||||||
|
|
||||||
|
print(json.dumps(
|
||||||
|
output,
|
||||||
|
ensure_ascii=False,
|
||||||
|
indent=2,
|
||||||
|
))
|
||||||
|
|
||||||
|
if errors:
|
||||||
|
raise SystemExit(1)
|
||||||
|
|
||||||
|
else:
|
||||||
|
raise SystemExit(
|
||||||
|
"Uso: deploy_context_daily_governance_dashboard_v0312.sh "
|
||||||
|
"{deploy|validate}"
|
||||||
|
)
|
||||||
|
PY
|
||||||
275
ucepsa/edge-oee-demo/ops/deploy_context_decision_groups_dashboard_v0311.sh
Executable file
275
ucepsa/edge-oee-demo/ops/deploy_context_decision_groups_dashboard_v0311.sh
Executable file
@ -0,0 +1,275 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
set -euo pipefail
|
||||||
|
|
||||||
|
ACTION="${1:-deploy}"
|
||||||
|
GRAFANA_CONTAINER="${GRAFANA_CONTAINER:-mv_ucepsa_grafana}"
|
||||||
|
REPO_ROOT="${DECISION_DASHBOARD_REPO_ROOT:-/srv/mesavault/40-clients/ucepsa/edge-oee-demo}"
|
||||||
|
DASHBOARD_FILE="${DECISION_DASHBOARD_FILE:-$REPO_ROOT/grafana/dashboards/ucepsa-shopfloor-context-health.dashboard.json}"
|
||||||
|
FOLDER_UID="${DECISION_DASHBOARD_FOLDER_UID:-ucepsa-mesavault}"
|
||||||
|
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 Grafana." >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
printf 'http://%s:3000' "$container_ip"
|
||||||
|
}
|
||||||
|
|
||||||
|
if [[ -z "${GRAFANA_API_TOKEN:-}" ]]; then
|
||||||
|
echo "ERROR: falta GRAFANA_API_TOKEN." >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
export GRAFANA_URL_RESOLVED
|
||||||
|
GRAFANA_URL_RESOLVED="$(resolve_url)"
|
||||||
|
|
||||||
|
python3 - \
|
||||||
|
"$ACTION" \
|
||||||
|
"$DASHBOARD_FILE" \
|
||||||
|
"$FOLDER_UID" \
|
||||||
|
"$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, 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):
|
||||||
|
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(
|
||||||
|
base_url + path,
|
||||||
|
data=body,
|
||||||
|
headers=headers,
|
||||||
|
method=method,
|
||||||
|
)
|
||||||
|
|
||||||
|
try:
|
||||||
|
with urllib.request.urlopen(
|
||||||
|
req,
|
||||||
|
timeout=20,
|
||||||
|
) as response:
|
||||||
|
raw = response.read()
|
||||||
|
return (
|
||||||
|
json.loads(raw.decode("utf-8"))
|
||||||
|
if raw
|
||||||
|
else None
|
||||||
|
)
|
||||||
|
except urllib.error.HTTPError as exc:
|
||||||
|
detail = exc.read().decode(
|
||||||
|
"utf-8",
|
||||||
|
errors="replace",
|
||||||
|
)
|
||||||
|
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}"
|
||||||
|
)
|
||||||
|
|
||||||
|
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 decision groups "
|
||||||
|
"SHADOW v0.3.11"
|
||||||
|
),
|
||||||
|
},
|
||||||
|
)
|
||||||
|
print(json.dumps(
|
||||||
|
{
|
||||||
|
"status": "deployed",
|
||||||
|
"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 len(dashboard.get("panels", [])) != 25:
|
||||||
|
errors.append(
|
||||||
|
"El dashboard no tiene 25 paneles"
|
||||||
|
)
|
||||||
|
|
||||||
|
if dashboard.get("refresh") != "15s":
|
||||||
|
errors.append(
|
||||||
|
"El refresco no está en 15s"
|
||||||
|
)
|
||||||
|
|
||||||
|
if datasource_uids != [datasource_uid]:
|
||||||
|
errors.append(
|
||||||
|
f"Datasource inesperado: {datasource_uids!r}"
|
||||||
|
)
|
||||||
|
|
||||||
|
expected = {
|
||||||
|
23: "li_context_decision_group_backlog_v1",
|
||||||
|
24: "li_context_decision_group_cases_v1",
|
||||||
|
25: "li_context_decision_group_history_v1",
|
||||||
|
}
|
||||||
|
|
||||||
|
for panel_id, fragment in expected.items():
|
||||||
|
sql = (
|
||||||
|
panels.get(panel_id, {})
|
||||||
|
.get("targets", [{}])[0]
|
||||||
|
.get("rawSql", "")
|
||||||
|
)
|
||||||
|
if fragment not in sql:
|
||||||
|
errors.append(
|
||||||
|
f"Panel {panel_id} no consulta {fragment}"
|
||||||
|
)
|
||||||
|
|
||||||
|
readiness_sql = (
|
||||||
|
panels.get(17, {})
|
||||||
|
.get("targets", [{}])[0]
|
||||||
|
.get("rawSql", "")
|
||||||
|
)
|
||||||
|
if (
|
||||||
|
"li_canonical_context_readiness_gate_v3"
|
||||||
|
not in readiness_sql
|
||||||
|
):
|
||||||
|
errors.append(
|
||||||
|
"El panel readiness no usa gate v3"
|
||||||
|
)
|
||||||
|
|
||||||
|
output = {
|
||||||
|
"status":
|
||||||
|
"ok" if not errors else "error",
|
||||||
|
"dashboard_uid":
|
||||||
|
dashboard.get("uid"),
|
||||||
|
"folder_uid":
|
||||||
|
result.get(
|
||||||
|
"meta",
|
||||||
|
{},
|
||||||
|
).get("folderUid"),
|
||||||
|
"panel_count":
|
||||||
|
len(
|
||||||
|
dashboard.get(
|
||||||
|
"panels",
|
||||||
|
[],
|
||||||
|
)
|
||||||
|
),
|
||||||
|
"refresh":
|
||||||
|
dashboard.get("refresh"),
|
||||||
|
"datasource_uids":
|
||||||
|
datasource_uids,
|
||||||
|
"version":
|
||||||
|
dashboard.get("version"),
|
||||||
|
"url":
|
||||||
|
result.get(
|
||||||
|
"meta",
|
||||||
|
{},
|
||||||
|
).get("url"),
|
||||||
|
"decision_group_panels": {
|
||||||
|
"backlog": 23 in panels,
|
||||||
|
"root_cases": 24 in panels,
|
||||||
|
"history": 25 in panels,
|
||||||
|
"readiness_v3":
|
||||||
|
"li_canonical_context_readiness_gate_v3"
|
||||||
|
in readiness_sql,
|
||||||
|
},
|
||||||
|
"errors":
|
||||||
|
errors,
|
||||||
|
}
|
||||||
|
|
||||||
|
print(json.dumps(
|
||||||
|
output,
|
||||||
|
ensure_ascii=False,
|
||||||
|
indent=2,
|
||||||
|
))
|
||||||
|
|
||||||
|
if errors:
|
||||||
|
raise SystemExit(1)
|
||||||
|
|
||||||
|
else:
|
||||||
|
raise SystemExit(
|
||||||
|
"Uso: deploy_context_decision_groups_dashboard_v0311.sh "
|
||||||
|
"{deploy|validate}"
|
||||||
|
)
|
||||||
|
PY
|
||||||
259
ucepsa/edge-oee-demo/ops/deploy_context_regression_dashboard_v0313.sh
Executable file
259
ucepsa/edge-oee-demo/ops/deploy_context_regression_dashboard_v0313.sh
Executable file
@ -0,0 +1,259 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
set -euo pipefail
|
||||||
|
|
||||||
|
ACTION="${1:-deploy}"
|
||||||
|
GRAFANA_CONTAINER="${GRAFANA_CONTAINER:-mv_ucepsa_grafana}"
|
||||||
|
REPO_ROOT="${REGRESSION_DASHBOARD_REPO_ROOT:-/srv/mesavault/40-clients/ucepsa/edge-oee-demo}"
|
||||||
|
DASHBOARD_FILE="${REGRESSION_DASHBOARD_FILE:-$REPO_ROOT/grafana/dashboards/ucepsa-shopfloor-context-health.dashboard.json}"
|
||||||
|
FOLDER_UID="${REGRESSION_DASHBOARD_FOLDER_UID:-ucepsa-mesavault}"
|
||||||
|
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 Grafana." >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
printf 'http://%s:3000' "$container_ip"
|
||||||
|
}
|
||||||
|
|
||||||
|
if [[ -z "${GRAFANA_API_TOKEN:-}" ]]; then
|
||||||
|
echo "ERROR: falta GRAFANA_API_TOKEN." >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
export GRAFANA_URL_RESOLVED
|
||||||
|
GRAFANA_URL_RESOLVED="$(resolve_url)"
|
||||||
|
|
||||||
|
python3 - \
|
||||||
|
"$ACTION" \
|
||||||
|
"$DASHBOARD_FILE" \
|
||||||
|
"$FOLDER_UID" \
|
||||||
|
"$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, 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):
|
||||||
|
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(
|
||||||
|
base_url + path,
|
||||||
|
data=body,
|
||||||
|
headers=headers,
|
||||||
|
method=method,
|
||||||
|
)
|
||||||
|
|
||||||
|
try:
|
||||||
|
with urllib.request.urlopen(
|
||||||
|
req,
|
||||||
|
timeout=20,
|
||||||
|
) as response:
|
||||||
|
raw = response.read()
|
||||||
|
return (
|
||||||
|
json.loads(raw.decode("utf-8"))
|
||||||
|
if raw
|
||||||
|
else None
|
||||||
|
)
|
||||||
|
except urllib.error.HTTPError as exc:
|
||||||
|
detail = exc.read().decode(
|
||||||
|
"utf-8",
|
||||||
|
errors="replace",
|
||||||
|
)
|
||||||
|
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}"
|
||||||
|
)
|
||||||
|
|
||||||
|
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 regression hardening "
|
||||||
|
"SHADOW v0.3.13"
|
||||||
|
),
|
||||||
|
},
|
||||||
|
)
|
||||||
|
print(json.dumps(
|
||||||
|
{
|
||||||
|
"status": "deployed",
|
||||||
|
"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 len(dashboard.get("panels", [])) != 31:
|
||||||
|
errors.append(
|
||||||
|
"El dashboard no tiene 31 paneles"
|
||||||
|
)
|
||||||
|
|
||||||
|
if dashboard.get("refresh") != "15s":
|
||||||
|
errors.append(
|
||||||
|
"El refresco no está en 15s"
|
||||||
|
)
|
||||||
|
|
||||||
|
if datasource_uids != [datasource_uid]:
|
||||||
|
errors.append(
|
||||||
|
f"Datasource inesperado: {datasource_uids!r}"
|
||||||
|
)
|
||||||
|
|
||||||
|
expected = {
|
||||||
|
29: "li_context_regression_latest_run_v1",
|
||||||
|
30: "li_context_regression_failures_v1",
|
||||||
|
31: "li_context_regression_run_history_v1",
|
||||||
|
}
|
||||||
|
|
||||||
|
for panel_id, fragment in expected.items():
|
||||||
|
sql = (
|
||||||
|
panels.get(panel_id, {})
|
||||||
|
.get("targets", [{}])[0]
|
||||||
|
.get("rawSql", "")
|
||||||
|
)
|
||||||
|
if fragment not in sql:
|
||||||
|
errors.append(
|
||||||
|
f"Panel {panel_id} no consulta {fragment}"
|
||||||
|
)
|
||||||
|
|
||||||
|
output = {
|
||||||
|
"status":
|
||||||
|
"ok" if not errors else "error",
|
||||||
|
"dashboard_uid":
|
||||||
|
dashboard.get("uid"),
|
||||||
|
"folder_uid":
|
||||||
|
result.get(
|
||||||
|
"meta",
|
||||||
|
{},
|
||||||
|
).get("folderUid"),
|
||||||
|
"panel_count":
|
||||||
|
len(
|
||||||
|
dashboard.get(
|
||||||
|
"panels",
|
||||||
|
[],
|
||||||
|
)
|
||||||
|
),
|
||||||
|
"refresh":
|
||||||
|
dashboard.get("refresh"),
|
||||||
|
"datasource_uids":
|
||||||
|
datasource_uids,
|
||||||
|
"version":
|
||||||
|
dashboard.get("version"),
|
||||||
|
"url":
|
||||||
|
result.get(
|
||||||
|
"meta",
|
||||||
|
{},
|
||||||
|
).get("url"),
|
||||||
|
"regression_panels": {
|
||||||
|
"latest_run": 29 in panels,
|
||||||
|
"issues": 30 in panels,
|
||||||
|
"history": 31 in panels,
|
||||||
|
},
|
||||||
|
"errors":
|
||||||
|
errors,
|
||||||
|
}
|
||||||
|
|
||||||
|
print(json.dumps(
|
||||||
|
output,
|
||||||
|
ensure_ascii=False,
|
||||||
|
indent=2,
|
||||||
|
))
|
||||||
|
|
||||||
|
if errors:
|
||||||
|
raise SystemExit(1)
|
||||||
|
|
||||||
|
else:
|
||||||
|
raise SystemExit(
|
||||||
|
"Uso: deploy_context_regression_dashboard_v0313.sh "
|
||||||
|
"{deploy|validate}"
|
||||||
|
)
|
||||||
|
PY
|
||||||
315
ucepsa/edge-oee-demo/ops/deploy_context_rollout_readiness_dashboard_v039.sh
Executable file
315
ucepsa/edge-oee-demo/ops/deploy_context_rollout_readiness_dashboard_v039.sh
Executable file
@ -0,0 +1,315 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
set -euo pipefail
|
||||||
|
|
||||||
|
ACTION="${1:-deploy}"
|
||||||
|
GRAFANA_CONTAINER="${GRAFANA_CONTAINER:-mv_ucepsa_grafana}"
|
||||||
|
REPO_ROOT="${READINESS_DASHBOARD_REPO_ROOT:-/srv/mesavault/40-clients/ucepsa/edge-oee-demo}"
|
||||||
|
DASHBOARD_FILE="${READINESS_DASHBOARD_FILE:-$REPO_ROOT/grafana/dashboards/ucepsa-shopfloor-context-health.dashboard.json}"
|
||||||
|
FOLDER_UID="${READINESS_DASHBOARD_FOLDER_UID:-ucepsa-mesavault}"
|
||||||
|
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 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.
|
||||||
|
|
||||||
|
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" \
|
||||||
|
"$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,
|
||||||
|
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):
|
||||||
|
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(
|
||||||
|
base_url + path,
|
||||||
|
data=body,
|
||||||
|
headers=headers,
|
||||||
|
method=method,
|
||||||
|
)
|
||||||
|
|
||||||
|
try:
|
||||||
|
with urllib.request.urlopen(
|
||||||
|
req,
|
||||||
|
timeout=20,
|
||||||
|
) as response:
|
||||||
|
raw = response.read()
|
||||||
|
return (
|
||||||
|
json.loads(
|
||||||
|
raw.decode("utf-8")
|
||||||
|
)
|
||||||
|
if raw
|
||||||
|
else None
|
||||||
|
)
|
||||||
|
except urllib.error.HTTPError as exc:
|
||||||
|
detail = exc.read().decode(
|
||||||
|
"utf-8",
|
||||||
|
errors="replace",
|
||||||
|
)
|
||||||
|
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}"
|
||||||
|
)
|
||||||
|
|
||||||
|
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 context rollout "
|
||||||
|
"readiness SHADOW v0.3.9"
|
||||||
|
),
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
print(json.dumps(
|
||||||
|
{
|
||||||
|
"status": "deployed",
|
||||||
|
"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 len(
|
||||||
|
dashboard.get(
|
||||||
|
"panels",
|
||||||
|
[],
|
||||||
|
)
|
||||||
|
) != 19:
|
||||||
|
errors.append(
|
||||||
|
"El dashboard no tiene 19 paneles"
|
||||||
|
)
|
||||||
|
|
||||||
|
if dashboard.get("refresh") != "15s":
|
||||||
|
errors.append(
|
||||||
|
"El refresco no está en 15s"
|
||||||
|
)
|
||||||
|
|
||||||
|
if datasource_uids != [datasource_uid]:
|
||||||
|
errors.append(
|
||||||
|
f"Datasource inesperado: "
|
||||||
|
f"{datasource_uids!r}"
|
||||||
|
)
|
||||||
|
|
||||||
|
expected = {
|
||||||
|
17:
|
||||||
|
"li_canonical_context_readiness_gate_v1",
|
||||||
|
18:
|
||||||
|
"li_context_rollout_blockers_v1",
|
||||||
|
19:
|
||||||
|
"li_context_rollout_readiness_summary_v1",
|
||||||
|
}
|
||||||
|
|
||||||
|
for panel_id, fragment in expected.items():
|
||||||
|
sql = (
|
||||||
|
panels.get(
|
||||||
|
panel_id,
|
||||||
|
{},
|
||||||
|
)
|
||||||
|
.get(
|
||||||
|
"targets",
|
||||||
|
[{}],
|
||||||
|
)[0]
|
||||||
|
.get(
|
||||||
|
"rawSql",
|
||||||
|
"",
|
||||||
|
)
|
||||||
|
)
|
||||||
|
if fragment not in sql:
|
||||||
|
errors.append(
|
||||||
|
f"Panel {panel_id} "
|
||||||
|
f"no consulta {fragment}"
|
||||||
|
)
|
||||||
|
|
||||||
|
output = {
|
||||||
|
"status":
|
||||||
|
"ok" if not errors else "error",
|
||||||
|
"dashboard_uid":
|
||||||
|
dashboard.get("uid"),
|
||||||
|
"folder_uid":
|
||||||
|
result.get(
|
||||||
|
"meta",
|
||||||
|
{},
|
||||||
|
).get("folderUid"),
|
||||||
|
"panel_count":
|
||||||
|
len(
|
||||||
|
dashboard.get(
|
||||||
|
"panels",
|
||||||
|
[],
|
||||||
|
)
|
||||||
|
),
|
||||||
|
"refresh":
|
||||||
|
dashboard.get("refresh"),
|
||||||
|
"datasource_uids":
|
||||||
|
datasource_uids,
|
||||||
|
"version":
|
||||||
|
dashboard.get("version"),
|
||||||
|
"url":
|
||||||
|
result.get(
|
||||||
|
"meta",
|
||||||
|
{},
|
||||||
|
).get("url"),
|
||||||
|
"readiness_panels": {
|
||||||
|
"machine_gate":
|
||||||
|
17 in panels,
|
||||||
|
"blockers":
|
||||||
|
18 in panels,
|
||||||
|
"summary":
|
||||||
|
19 in panels,
|
||||||
|
},
|
||||||
|
"errors":
|
||||||
|
errors,
|
||||||
|
}
|
||||||
|
|
||||||
|
print(json.dumps(
|
||||||
|
output,
|
||||||
|
ensure_ascii=False,
|
||||||
|
indent=2,
|
||||||
|
))
|
||||||
|
|
||||||
|
if errors:
|
||||||
|
raise SystemExit(1)
|
||||||
|
|
||||||
|
else:
|
||||||
|
raise SystemExit(
|
||||||
|
"Uso: "
|
||||||
|
"deploy_context_rollout_readiness_dashboard_v039.sh "
|
||||||
|
"{deploy|validate}"
|
||||||
|
)
|
||||||
|
PY
|
||||||
298
ucepsa/edge-oee-demo/ops/deploy_context_root_case_dashboard_v0310.sh
Executable file
298
ucepsa/edge-oee-demo/ops/deploy_context_root_case_dashboard_v0310.sh
Executable file
@ -0,0 +1,298 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
set -euo pipefail
|
||||||
|
|
||||||
|
ACTION="${1:-deploy}"
|
||||||
|
GRAFANA_CONTAINER="${GRAFANA_CONTAINER:-mv_ucepsa_grafana}"
|
||||||
|
REPO_ROOT="${ROOT_CASE_DASHBOARD_REPO_ROOT:-/srv/mesavault/40-clients/ucepsa/edge-oee-demo}"
|
||||||
|
DASHBOARD_FILE="${ROOT_CASE_DASHBOARD_FILE:-$REPO_ROOT/grafana/dashboards/ucepsa-shopfloor-context-health.dashboard.json}"
|
||||||
|
FOLDER_UID="${ROOT_CASE_DASHBOARD_FOLDER_UID:-ucepsa-mesavault}"
|
||||||
|
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 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.
|
||||||
|
|
||||||
|
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" \
|
||||||
|
"$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,
|
||||||
|
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):
|
||||||
|
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(
|
||||||
|
base_url + path,
|
||||||
|
data=body,
|
||||||
|
headers=headers,
|
||||||
|
method=method,
|
||||||
|
)
|
||||||
|
|
||||||
|
try:
|
||||||
|
with urllib.request.urlopen(
|
||||||
|
req,
|
||||||
|
timeout=20,
|
||||||
|
) as response:
|
||||||
|
raw = response.read()
|
||||||
|
return (
|
||||||
|
json.loads(raw.decode("utf-8"))
|
||||||
|
if raw
|
||||||
|
else None
|
||||||
|
)
|
||||||
|
except urllib.error.HTTPError as exc:
|
||||||
|
detail = exc.read().decode(
|
||||||
|
"utf-8",
|
||||||
|
errors="replace",
|
||||||
|
)
|
||||||
|
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}"
|
||||||
|
)
|
||||||
|
|
||||||
|
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 root case queue "
|
||||||
|
"SHADOW v0.3.10"
|
||||||
|
),
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
print(json.dumps(
|
||||||
|
{
|
||||||
|
"status": "deployed",
|
||||||
|
"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 len(dashboard.get("panels", [])) != 22:
|
||||||
|
errors.append(
|
||||||
|
"El dashboard no tiene 22 paneles"
|
||||||
|
)
|
||||||
|
|
||||||
|
if dashboard.get("refresh") != "15s":
|
||||||
|
errors.append(
|
||||||
|
"El refresco no está en 15s"
|
||||||
|
)
|
||||||
|
|
||||||
|
if datasource_uids != [datasource_uid]:
|
||||||
|
errors.append(
|
||||||
|
f"Datasource inesperado: {datasource_uids!r}"
|
||||||
|
)
|
||||||
|
|
||||||
|
expected = {
|
||||||
|
20: "li_context_review_case_backlog_v1",
|
||||||
|
21: "li_context_review_case_evidence_v1",
|
||||||
|
22: "li_context_review_case_history_v1",
|
||||||
|
}
|
||||||
|
|
||||||
|
for panel_id, fragment in expected.items():
|
||||||
|
sql = (
|
||||||
|
panels.get(panel_id, {})
|
||||||
|
.get("targets", [{}])[0]
|
||||||
|
.get("rawSql", "")
|
||||||
|
)
|
||||||
|
if fragment not in sql:
|
||||||
|
errors.append(
|
||||||
|
f"Panel {panel_id} "
|
||||||
|
f"no consulta {fragment}"
|
||||||
|
)
|
||||||
|
|
||||||
|
readiness_sql = (
|
||||||
|
panels.get(17, {})
|
||||||
|
.get("targets", [{}])[0]
|
||||||
|
.get("rawSql", "")
|
||||||
|
)
|
||||||
|
if (
|
||||||
|
"li_canonical_context_readiness_gate_v2"
|
||||||
|
not in readiness_sql
|
||||||
|
):
|
||||||
|
errors.append(
|
||||||
|
"El panel readiness no usa gate v2"
|
||||||
|
)
|
||||||
|
|
||||||
|
output = {
|
||||||
|
"status":
|
||||||
|
"ok" if not errors else "error",
|
||||||
|
"dashboard_uid":
|
||||||
|
dashboard.get("uid"),
|
||||||
|
"folder_uid":
|
||||||
|
result.get(
|
||||||
|
"meta",
|
||||||
|
{},
|
||||||
|
).get("folderUid"),
|
||||||
|
"panel_count":
|
||||||
|
len(
|
||||||
|
dashboard.get(
|
||||||
|
"panels",
|
||||||
|
[],
|
||||||
|
)
|
||||||
|
),
|
||||||
|
"refresh":
|
||||||
|
dashboard.get("refresh"),
|
||||||
|
"datasource_uids":
|
||||||
|
datasource_uids,
|
||||||
|
"version":
|
||||||
|
dashboard.get("version"),
|
||||||
|
"url":
|
||||||
|
result.get(
|
||||||
|
"meta",
|
||||||
|
{},
|
||||||
|
).get("url"),
|
||||||
|
"root_case_panels": {
|
||||||
|
"backlog": 20 in panels,
|
||||||
|
"evidence": 21 in panels,
|
||||||
|
"history": 22 in panels,
|
||||||
|
"readiness_v2":
|
||||||
|
"li_canonical_context_readiness_gate_v2"
|
||||||
|
in readiness_sql,
|
||||||
|
},
|
||||||
|
"errors":
|
||||||
|
errors,
|
||||||
|
}
|
||||||
|
|
||||||
|
print(json.dumps(
|
||||||
|
output,
|
||||||
|
ensure_ascii=False,
|
||||||
|
indent=2,
|
||||||
|
))
|
||||||
|
|
||||||
|
if errors:
|
||||||
|
raise SystemExit(1)
|
||||||
|
|
||||||
|
else:
|
||||||
|
raise SystemExit(
|
||||||
|
"Uso: "
|
||||||
|
"deploy_context_root_case_dashboard_v0310.sh "
|
||||||
|
"{deploy|validate}"
|
||||||
|
)
|
||||||
|
PY
|
||||||
301
ucepsa/edge-oee-demo/ops/deploy_governance_review_ui_dashboard_v0314.sh
Executable file
301
ucepsa/edge-oee-demo/ops/deploy_governance_review_ui_dashboard_v0314.sh
Executable file
@ -0,0 +1,301 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
set -euo pipefail
|
||||||
|
|
||||||
|
ACTION="${1:-deploy}"
|
||||||
|
GRAFANA_CONTAINER="${GRAFANA_CONTAINER:-mv_ucepsa_grafana}"
|
||||||
|
REPO_ROOT="${REGRESSION_DASHBOARD_REPO_ROOT:-/srv/mesavault/40-clients/ucepsa/edge-oee-demo}"
|
||||||
|
DASHBOARD_FILE="${REGRESSION_DASHBOARD_FILE:-$REPO_ROOT/grafana/dashboards/ucepsa-shopfloor-context-health.dashboard.json}"
|
||||||
|
FOLDER_UID="${REGRESSION_DASHBOARD_FOLDER_UID:-ucepsa-mesavault}"
|
||||||
|
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 Grafana." >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
printf 'http://%s:3000' "$container_ip"
|
||||||
|
}
|
||||||
|
|
||||||
|
if [[ -z "${GRAFANA_API_TOKEN:-}" ]]; then
|
||||||
|
echo "ERROR: falta GRAFANA_API_TOKEN." >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
export GRAFANA_URL_RESOLVED
|
||||||
|
GRAFANA_URL_RESOLVED="$(resolve_url)"
|
||||||
|
|
||||||
|
python3 - \
|
||||||
|
"$ACTION" \
|
||||||
|
"$DASHBOARD_FILE" \
|
||||||
|
"$FOLDER_UID" \
|
||||||
|
"$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, 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):
|
||||||
|
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(
|
||||||
|
base_url + path,
|
||||||
|
data=body,
|
||||||
|
headers=headers,
|
||||||
|
method=method,
|
||||||
|
)
|
||||||
|
|
||||||
|
try:
|
||||||
|
with urllib.request.urlopen(
|
||||||
|
req,
|
||||||
|
timeout=20,
|
||||||
|
) as response:
|
||||||
|
raw = response.read()
|
||||||
|
return (
|
||||||
|
json.loads(raw.decode("utf-8"))
|
||||||
|
if raw
|
||||||
|
else None
|
||||||
|
)
|
||||||
|
except urllib.error.HTTPError as exc:
|
||||||
|
detail = exc.read().decode(
|
||||||
|
"utf-8",
|
||||||
|
errors="replace",
|
||||||
|
)
|
||||||
|
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}"
|
||||||
|
)
|
||||||
|
|
||||||
|
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 governance review UI "
|
||||||
|
"SHADOW v0.3.14"
|
||||||
|
),
|
||||||
|
},
|
||||||
|
)
|
||||||
|
print(json.dumps(
|
||||||
|
{
|
||||||
|
"status": "deployed",
|
||||||
|
"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 len(dashboard.get("panels", [])) != 31:
|
||||||
|
errors.append(
|
||||||
|
"El dashboard no tiene 31 paneles"
|
||||||
|
)
|
||||||
|
|
||||||
|
if dashboard.get("refresh") != "15s":
|
||||||
|
errors.append(
|
||||||
|
"El refresco no está en 15s"
|
||||||
|
)
|
||||||
|
|
||||||
|
if datasource_uids != [datasource_uid]:
|
||||||
|
errors.append(
|
||||||
|
f"Datasource inesperado: {datasource_uids!r}"
|
||||||
|
)
|
||||||
|
|
||||||
|
expected = {
|
||||||
|
29: "li_context_regression_latest_run_v1",
|
||||||
|
30: "li_context_regression_failures_v1",
|
||||||
|
31: "li_context_regression_run_history_v1",
|
||||||
|
}
|
||||||
|
|
||||||
|
for panel_id, fragment in expected.items():
|
||||||
|
sql = (
|
||||||
|
panels.get(panel_id, {})
|
||||||
|
.get("targets", [{}])[0]
|
||||||
|
.get("rawSql", "")
|
||||||
|
)
|
||||||
|
if fragment not in sql:
|
||||||
|
errors.append(
|
||||||
|
f"Panel {panel_id} no consulta {fragment}"
|
||||||
|
)
|
||||||
|
|
||||||
|
governance_expected = {
|
||||||
|
23: "li_context_governance_ui_queue_v1",
|
||||||
|
26: "li_context_governance_ui_queue_v1",
|
||||||
|
}
|
||||||
|
|
||||||
|
for panel_id, fragment in governance_expected.items():
|
||||||
|
sql = (
|
||||||
|
panels.get(panel_id, {})
|
||||||
|
.get("targets", [{}])[0]
|
||||||
|
.get("rawSql", "")
|
||||||
|
)
|
||||||
|
if fragment not in sql:
|
||||||
|
errors.append(
|
||||||
|
f"Panel {panel_id} no consulta {fragment}"
|
||||||
|
)
|
||||||
|
if (
|
||||||
|
'"Incidencia"' not in sql
|
||||||
|
or '"Revisión"' not in sql
|
||||||
|
):
|
||||||
|
errors.append(
|
||||||
|
f"Panel {panel_id} no separa Incidencia y Revisión"
|
||||||
|
)
|
||||||
|
|
||||||
|
output = {
|
||||||
|
"status":
|
||||||
|
"ok" if not errors else "error",
|
||||||
|
"dashboard_uid":
|
||||||
|
dashboard.get("uid"),
|
||||||
|
"folder_uid":
|
||||||
|
result.get(
|
||||||
|
"meta",
|
||||||
|
{},
|
||||||
|
).get("folderUid"),
|
||||||
|
"panel_count":
|
||||||
|
len(
|
||||||
|
dashboard.get(
|
||||||
|
"panels",
|
||||||
|
[],
|
||||||
|
)
|
||||||
|
),
|
||||||
|
"refresh":
|
||||||
|
dashboard.get("refresh"),
|
||||||
|
"datasource_uids":
|
||||||
|
datasource_uids,
|
||||||
|
"version":
|
||||||
|
dashboard.get("version"),
|
||||||
|
"url":
|
||||||
|
result.get(
|
||||||
|
"meta",
|
||||||
|
{},
|
||||||
|
).get("url"),
|
||||||
|
"regression_panels": {
|
||||||
|
"latest_run": 29 in panels,
|
||||||
|
"issues": 30 in panels,
|
||||||
|
"history": 31 in panels,
|
||||||
|
},
|
||||||
|
"governance_ui_panels": {
|
||||||
|
"pending_review": 23 in panels,
|
||||||
|
"daily_agenda": 26 in panels,
|
||||||
|
"semantic_status_split": all(
|
||||||
|
(
|
||||||
|
'"Incidencia"' in (
|
||||||
|
panels.get(panel_id, {})
|
||||||
|
.get("targets", [{}])[0]
|
||||||
|
.get("rawSql", "")
|
||||||
|
)
|
||||||
|
and '"Revisión"' in (
|
||||||
|
panels.get(panel_id, {})
|
||||||
|
.get("targets", [{}])[0]
|
||||||
|
.get("rawSql", "")
|
||||||
|
)
|
||||||
|
)
|
||||||
|
for panel_id in (23, 26)
|
||||||
|
),
|
||||||
|
},
|
||||||
|
"errors":
|
||||||
|
errors,
|
||||||
|
}
|
||||||
|
|
||||||
|
print(json.dumps(
|
||||||
|
output,
|
||||||
|
ensure_ascii=False,
|
||||||
|
indent=2,
|
||||||
|
))
|
||||||
|
|
||||||
|
if errors:
|
||||||
|
raise SystemExit(1)
|
||||||
|
|
||||||
|
else:
|
||||||
|
raise SystemExit(
|
||||||
|
"Uso: deploy_governance_review_ui_dashboard_v0314.sh "
|
||||||
|
"{deploy|validate}"
|
||||||
|
)
|
||||||
|
PY
|
||||||
323
ucepsa/edge-oee-demo/ops/deploy_governance_review_ui_dashboard_v0315.sh
Executable file
323
ucepsa/edge-oee-demo/ops/deploy_governance_review_ui_dashboard_v0315.sh
Executable file
@ -0,0 +1,323 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
set -euo pipefail
|
||||||
|
|
||||||
|
ACTION="${1:-deploy}"
|
||||||
|
GRAFANA_CONTAINER="${GRAFANA_CONTAINER:-mv_ucepsa_grafana}"
|
||||||
|
REPO_ROOT="${REGRESSION_DASHBOARD_REPO_ROOT:-/srv/mesavault/40-clients/ucepsa/edge-oee-demo}"
|
||||||
|
DASHBOARD_FILE="${REGRESSION_DASHBOARD_FILE:-$REPO_ROOT/grafana/dashboards/ucepsa-shopfloor-context-health.dashboard.json}"
|
||||||
|
FOLDER_UID="${REGRESSION_DASHBOARD_FOLDER_UID:-ucepsa-mesavault}"
|
||||||
|
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 Grafana." >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
printf 'http://%s:3000' "$container_ip"
|
||||||
|
}
|
||||||
|
|
||||||
|
if [[ -z "${GRAFANA_API_TOKEN:-}" ]]; then
|
||||||
|
echo "ERROR: falta GRAFANA_API_TOKEN." >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
export GRAFANA_URL_RESOLVED
|
||||||
|
GRAFANA_URL_RESOLVED="$(resolve_url)"
|
||||||
|
|
||||||
|
python3 - \
|
||||||
|
"$ACTION" \
|
||||||
|
"$DASHBOARD_FILE" \
|
||||||
|
"$FOLDER_UID" \
|
||||||
|
"$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, 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):
|
||||||
|
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(
|
||||||
|
base_url + path,
|
||||||
|
data=body,
|
||||||
|
headers=headers,
|
||||||
|
method=method,
|
||||||
|
)
|
||||||
|
|
||||||
|
try:
|
||||||
|
with urllib.request.urlopen(
|
||||||
|
req,
|
||||||
|
timeout=20,
|
||||||
|
) as response:
|
||||||
|
raw = response.read()
|
||||||
|
return (
|
||||||
|
json.loads(raw.decode("utf-8"))
|
||||||
|
if raw
|
||||||
|
else None
|
||||||
|
)
|
||||||
|
except urllib.error.HTTPError as exc:
|
||||||
|
detail = exc.read().decode(
|
||||||
|
"utf-8",
|
||||||
|
errors="replace",
|
||||||
|
)
|
||||||
|
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}"
|
||||||
|
)
|
||||||
|
|
||||||
|
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 governance review UI "
|
||||||
|
"SHADOW v0.3.15"
|
||||||
|
),
|
||||||
|
},
|
||||||
|
)
|
||||||
|
print(json.dumps(
|
||||||
|
{
|
||||||
|
"status": "deployed",
|
||||||
|
"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 len(dashboard.get("panels", [])) != 34:
|
||||||
|
errors.append(
|
||||||
|
"El dashboard no tiene 34 paneles"
|
||||||
|
)
|
||||||
|
|
||||||
|
if dashboard.get("refresh") != "15s":
|
||||||
|
errors.append(
|
||||||
|
"El refresco no está en 15s"
|
||||||
|
)
|
||||||
|
|
||||||
|
if datasource_uids != [datasource_uid]:
|
||||||
|
errors.append(
|
||||||
|
f"Datasource inesperado: {datasource_uids!r}"
|
||||||
|
)
|
||||||
|
|
||||||
|
expected = {
|
||||||
|
29: "li_context_regression_latest_run_v1",
|
||||||
|
30: "li_context_regression_failures_v1",
|
||||||
|
31: "li_context_regression_run_history_v1",
|
||||||
|
}
|
||||||
|
|
||||||
|
two_stage_expected = {
|
||||||
|
32: "li_context_governance_context_approval_queue_v1",
|
||||||
|
33: "li_context_governance_ui_queue_v2",
|
||||||
|
34: "li_context_governance_ui_activity_v1",
|
||||||
|
}
|
||||||
|
|
||||||
|
for panel_id, fragment in expected.items():
|
||||||
|
sql = (
|
||||||
|
panels.get(panel_id, {})
|
||||||
|
.get("targets", [{}])[0]
|
||||||
|
.get("rawSql", "")
|
||||||
|
)
|
||||||
|
if fragment not in sql:
|
||||||
|
errors.append(
|
||||||
|
f"Panel {panel_id} no consulta {fragment}"
|
||||||
|
)
|
||||||
|
|
||||||
|
governance_expected = {
|
||||||
|
23: "li_context_governance_ui_queue_v2",
|
||||||
|
26: "li_context_governance_ui_queue_v2",
|
||||||
|
}
|
||||||
|
|
||||||
|
for panel_id, fragment in governance_expected.items():
|
||||||
|
sql = (
|
||||||
|
panels.get(panel_id, {})
|
||||||
|
.get("targets", [{}])[0]
|
||||||
|
.get("rawSql", "")
|
||||||
|
)
|
||||||
|
if fragment not in sql:
|
||||||
|
errors.append(
|
||||||
|
f"Panel {panel_id} no consulta {fragment}"
|
||||||
|
)
|
||||||
|
if (
|
||||||
|
'"Incidencia"' not in sql
|
||||||
|
or '"Revisión"' not in sql
|
||||||
|
):
|
||||||
|
errors.append(
|
||||||
|
f"Panel {panel_id} no separa Incidencia y Revisión"
|
||||||
|
)
|
||||||
|
|
||||||
|
for panel_id, fragment in two_stage_expected.items():
|
||||||
|
sql = (
|
||||||
|
panels.get(panel_id, {})
|
||||||
|
.get("targets", [{}])[0]
|
||||||
|
.get("rawSql", "")
|
||||||
|
)
|
||||||
|
if fragment not in sql:
|
||||||
|
errors.append(
|
||||||
|
f"Panel {panel_id} no consulta {fragment}"
|
||||||
|
)
|
||||||
|
|
||||||
|
output = {
|
||||||
|
"status":
|
||||||
|
"ok" if not errors else "error",
|
||||||
|
"dashboard_uid":
|
||||||
|
dashboard.get("uid"),
|
||||||
|
"folder_uid":
|
||||||
|
result.get(
|
||||||
|
"meta",
|
||||||
|
{},
|
||||||
|
).get("folderUid"),
|
||||||
|
"panel_count":
|
||||||
|
len(
|
||||||
|
dashboard.get(
|
||||||
|
"panels",
|
||||||
|
[],
|
||||||
|
)
|
||||||
|
),
|
||||||
|
"refresh":
|
||||||
|
dashboard.get("refresh"),
|
||||||
|
"datasource_uids":
|
||||||
|
datasource_uids,
|
||||||
|
"version":
|
||||||
|
dashboard.get("version"),
|
||||||
|
"url":
|
||||||
|
result.get(
|
||||||
|
"meta",
|
||||||
|
{},
|
||||||
|
).get("url"),
|
||||||
|
"regression_panels": {
|
||||||
|
"latest_run": 29 in panels,
|
||||||
|
"issues": 30 in panels,
|
||||||
|
"history": 31 in panels,
|
||||||
|
},
|
||||||
|
"governance_ui_panels": {
|
||||||
|
"pending_review": 23 in panels,
|
||||||
|
"daily_agenda": 26 in panels,
|
||||||
|
"semantic_status_split": all(
|
||||||
|
(
|
||||||
|
'"Incidencia"' in (
|
||||||
|
panels.get(panel_id, {})
|
||||||
|
.get("targets", [{}])[0]
|
||||||
|
.get("rawSql", "")
|
||||||
|
)
|
||||||
|
and '"Revisión"' in (
|
||||||
|
panels.get(panel_id, {})
|
||||||
|
.get("targets", [{}])[0]
|
||||||
|
.get("rawSql", "")
|
||||||
|
)
|
||||||
|
)
|
||||||
|
for panel_id in (23, 26)
|
||||||
|
),
|
||||||
|
},
|
||||||
|
"two_stage_panels": {
|
||||||
|
"approval_queue": 32 in panels,
|
||||||
|
"consultations": 33 in panels,
|
||||||
|
"activity": 34 in panels,
|
||||||
|
},
|
||||||
|
"errors":
|
||||||
|
errors,
|
||||||
|
}
|
||||||
|
|
||||||
|
print(json.dumps(
|
||||||
|
output,
|
||||||
|
ensure_ascii=False,
|
||||||
|
indent=2,
|
||||||
|
))
|
||||||
|
|
||||||
|
if errors:
|
||||||
|
raise SystemExit(1)
|
||||||
|
|
||||||
|
else:
|
||||||
|
raise SystemExit(
|
||||||
|
"Uso: deploy_governance_review_ui_dashboard_v0315.sh "
|
||||||
|
"{deploy|validate}"
|
||||||
|
)
|
||||||
|
PY
|
||||||
239
ucepsa/edge-oee-demo/ops/deploy_shopfloor_context_health_dashboard_v036.sh
Executable file
239
ucepsa/edge-oee-demo/ops/deploy_shopfloor_context_health_dashboard_v036.sh
Executable 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
|
||||||
335
ucepsa/edge-oee-demo/ops/deploy_shopfloor_context_health_dashboard_v0362.sh
Executable file
335
ucepsa/edge-oee-demo/ops/deploy_shopfloor_context_health_dashboard_v0362.sh
Executable 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
|
||||||
@ -0,0 +1,240 @@
|
|||||||
|
#!/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}"
|
||||||
|
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 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.
|
||||||
|
|
||||||
|
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" \
|
||||||
|
"$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,
|
||||||
|
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):
|
||||||
|
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(
|
||||||
|
base_url + path,
|
||||||
|
data=body,
|
||||||
|
headers=headers,
|
||||||
|
method=method,
|
||||||
|
)
|
||||||
|
try:
|
||||||
|
with urllib.request.urlopen(
|
||||||
|
req,
|
||||||
|
timeout=20,
|
||||||
|
) as response:
|
||||||
|
raw = response.read()
|
||||||
|
return (
|
||||||
|
json.loads(raw.decode("utf-8"))
|
||||||
|
if raw
|
||||||
|
else None
|
||||||
|
)
|
||||||
|
except urllib.error.HTTPError as exc:
|
||||||
|
detail = exc.read().decode(
|
||||||
|
"utf-8",
|
||||||
|
errors="replace",
|
||||||
|
)
|
||||||
|
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}"
|
||||||
|
)
|
||||||
|
|
||||||
|
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 incident "
|
||||||
|
"history v0.3.7"
|
||||||
|
),
|
||||||
|
},
|
||||||
|
)
|
||||||
|
print(json.dumps(
|
||||||
|
{
|
||||||
|
"status": "deployed",
|
||||||
|
"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 len(dashboard.get("panels", [])) != 13:
|
||||||
|
errors.append("El dashboard no tiene 13 paneles")
|
||||||
|
if datasource_uids != [datasource_uid]:
|
||||||
|
errors.append(
|
||||||
|
f"Datasource inesperado: {datasource_uids!r}"
|
||||||
|
)
|
||||||
|
expected = {
|
||||||
|
11: "li_shopfloor_context_incident_backlog_v1",
|
||||||
|
12: "li_shopfloor_context_incident_history_v1",
|
||||||
|
13: "li_shopfloor_context_incident_daily_summary_v1",
|
||||||
|
}
|
||||||
|
for panel_id, fragment in expected.items():
|
||||||
|
sql = (
|
||||||
|
panels.get(panel_id, {})
|
||||||
|
.get("targets", [{}])[0]
|
||||||
|
.get("rawSql", "")
|
||||||
|
)
|
||||||
|
if fragment not in sql:
|
||||||
|
errors.append(
|
||||||
|
f"Panel {panel_id} no consulta {fragment}"
|
||||||
|
)
|
||||||
|
|
||||||
|
output = {
|
||||||
|
"status": "ok" if not errors else "error",
|
||||||
|
"dashboard_uid": dashboard.get("uid"),
|
||||||
|
"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"),
|
||||||
|
"history_panels": {
|
||||||
|
"backlog": 11 in panels,
|
||||||
|
"history_24h": 12 in panels,
|
||||||
|
"daily_summary": 13 in panels,
|
||||||
|
},
|
||||||
|
"errors": errors,
|
||||||
|
}
|
||||||
|
print(json.dumps(
|
||||||
|
output,
|
||||||
|
ensure_ascii=False,
|
||||||
|
indent=2,
|
||||||
|
))
|
||||||
|
if errors:
|
||||||
|
raise SystemExit(1)
|
||||||
|
|
||||||
|
else:
|
||||||
|
raise SystemExit(
|
||||||
|
"Uso: "
|
||||||
|
"deploy_shopfloor_context_incident_history_dashboard_v037.sh "
|
||||||
|
"{deploy|validate}"
|
||||||
|
)
|
||||||
|
PY
|
||||||
36
ucepsa/edge-oee-demo/ops/install_governance_review_ui_v0315.sh
Executable file
36
ucepsa/edge-oee-demo/ops/install_governance_review_ui_v0315.sh
Executable file
@ -0,0 +1,36 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
set -euo pipefail
|
||||||
|
|
||||||
|
TARGET_ROOT="${GOVERNANCE_UI_REPO_ROOT:-/srv/mesavault/40-clients/ucepsa/edge-oee-demo}"
|
||||||
|
RUNTIME_ROOT="${GOVERNANCE_UI_RUNTIME_ROOT:-/srv/mesavault/edge-oee-ucepsa}"
|
||||||
|
SOURCE="$TARGET_ROOT/governance-ui"
|
||||||
|
DEST="$RUNTIME_ROOT/governance-ui"
|
||||||
|
TMP_ENV=""
|
||||||
|
|
||||||
|
if [[ ! -f "$SOURCE/Dockerfile" ]]; then
|
||||||
|
echo "ERROR: no existe $SOURCE/Dockerfile" >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
if [[ -f "$DEST/.env" ]]; then
|
||||||
|
TMP_ENV="$(mktemp)"
|
||||||
|
cp "$DEST/.env" "$TMP_ENV"
|
||||||
|
chmod 600 "$TMP_ENV"
|
||||||
|
fi
|
||||||
|
|
||||||
|
rm -rf "$DEST"
|
||||||
|
mkdir -p "$DEST"
|
||||||
|
cp -a "$SOURCE/." "$DEST/"
|
||||||
|
|
||||||
|
if [[ -n "$TMP_ENV" ]]; then
|
||||||
|
cp "$TMP_ENV" "$DEST/.env"
|
||||||
|
chmod 600 "$DEST/.env"
|
||||||
|
rm -f "$TMP_ENV"
|
||||||
|
echo "Configuración .env preservada."
|
||||||
|
else
|
||||||
|
echo "AVISO: no existía .env previo; configure $DEST/.env antes de arrancar." >&2
|
||||||
|
fi
|
||||||
|
|
||||||
|
find "$DEST" -type d -name '__pycache__' -prune -exec rm -rf {} +
|
||||||
|
|
||||||
|
echo "Governance Review UI v0.3.15 instalada en $DEST"
|
||||||
47
ucepsa/edge-oee-demo/ops/manage_context_daily_governance_v0312.sh
Executable file
47
ucepsa/edge-oee-demo/ops/manage_context_daily_governance_v0312.sh
Executable file
@ -0,0 +1,47 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
set -euo pipefail
|
||||||
|
|
||||||
|
ACTION="${1:-agenda}"
|
||||||
|
ROOT="${DAILY_GOVERNANCE_RUNTIME_ROOT:-/srv/mesavault/edge-oee-ucepsa}"
|
||||||
|
NETWORK="${DAILY_GOVERNANCE_NETWORK:-mv_ucepsa_net}"
|
||||||
|
POSTGRES_CONTAINER="${DAILY_GOVERNANCE_POSTGRES_CONTAINER:-mv_ucepsa_postgres_hot}"
|
||||||
|
TENANT_OVERRIDE="${DAILY_GOVERNANCE_TENANT_OVERRIDE:-ucepsa}"
|
||||||
|
SITE_OVERRIDE="${DAILY_GOVERNANCE_SITE_OVERRIDE:-ucepsa_onpremise}"
|
||||||
|
PGHOST_OVERRIDE="${DAILY_GOVERNANCE_PGHOST_OVERRIDE:-mv_ucepsa_postgres_hot}"
|
||||||
|
|
||||||
|
cd "$ROOT"
|
||||||
|
|
||||||
|
IMAGE="$(
|
||||||
|
docker inspect \
|
||||||
|
mv_ucepsa_odoo_order_state_pg_sink \
|
||||||
|
--format '{{.Config.Image}}'
|
||||||
|
)"
|
||||||
|
|
||||||
|
PGDATABASE_OVERRIDE="${DAILY_GOVERNANCE_PGDATABASE_OVERRIDE:-$(
|
||||||
|
docker inspect "$POSTGRES_CONTAINER" \
|
||||||
|
--format '{{range .Config.Env}}{{println .}}{{end}}' \
|
||||||
|
| sed -n 's/^POSTGRES_DB=//p' \
|
||||||
|
| head -n 1
|
||||||
|
)}"
|
||||||
|
|
||||||
|
if [[ -z "$PGDATABASE_OVERRIDE" ]]; then
|
||||||
|
echo "ERROR: no se pudo determinar POSTGRES_DB." >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
shift || true
|
||||||
|
|
||||||
|
docker run --rm -i \
|
||||||
|
--network "$NETWORK" \
|
||||||
|
--env-file .env \
|
||||||
|
-e "TENANT_OVERRIDE=$TENANT_OVERRIDE" \
|
||||||
|
-e "SITE_OVERRIDE=$SITE_OVERRIDE" \
|
||||||
|
-e "PGHOST=$PGHOST_OVERRIDE" \
|
||||||
|
-e "PGDATABASE=$PGDATABASE_OVERRIDE" \
|
||||||
|
-v "$ROOT:/app:ro" \
|
||||||
|
-w /app \
|
||||||
|
--entrypoint python \
|
||||||
|
"$IMAGE" \
|
||||||
|
tools/context_daily_governance.py \
|
||||||
|
"$ACTION" \
|
||||||
|
"$@"
|
||||||
47
ucepsa/edge-oee-demo/ops/manage_context_decision_groups_v0311.sh
Executable file
47
ucepsa/edge-oee-demo/ops/manage_context_decision_groups_v0311.sh
Executable file
@ -0,0 +1,47 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
set -euo pipefail
|
||||||
|
|
||||||
|
ACTION="${1:-list}"
|
||||||
|
ROOT="${CONTEXT_DECISION_RUNTIME_ROOT:-/srv/mesavault/edge-oee-ucepsa}"
|
||||||
|
NETWORK="${CONTEXT_DECISION_NETWORK:-mv_ucepsa_net}"
|
||||||
|
POSTGRES_CONTAINER="${CONTEXT_DECISION_POSTGRES_CONTAINER:-mv_ucepsa_postgres_hot}"
|
||||||
|
TENANT_OVERRIDE="${CONTEXT_DECISION_TENANT_OVERRIDE:-ucepsa}"
|
||||||
|
SITE_OVERRIDE="${CONTEXT_DECISION_SITE_OVERRIDE:-ucepsa_onpremise}"
|
||||||
|
PGHOST_OVERRIDE="${CONTEXT_DECISION_PGHOST_OVERRIDE:-mv_ucepsa_postgres_hot}"
|
||||||
|
|
||||||
|
cd "$ROOT"
|
||||||
|
|
||||||
|
IMAGE="$(
|
||||||
|
docker inspect \
|
||||||
|
mv_ucepsa_odoo_order_state_pg_sink \
|
||||||
|
--format '{{.Config.Image}}'
|
||||||
|
)"
|
||||||
|
|
||||||
|
PGDATABASE_OVERRIDE="${CONTEXT_DECISION_PGDATABASE_OVERRIDE:-$(
|
||||||
|
docker inspect "$POSTGRES_CONTAINER" \
|
||||||
|
--format '{{range .Config.Env}}{{println .}}{{end}}' \
|
||||||
|
| sed -n 's/^POSTGRES_DB=//p' \
|
||||||
|
| head -n 1
|
||||||
|
)}"
|
||||||
|
|
||||||
|
if [[ -z "$PGDATABASE_OVERRIDE" ]]; then
|
||||||
|
echo "ERROR: no se pudo determinar POSTGRES_DB." >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
shift || true
|
||||||
|
|
||||||
|
docker run --rm -i \
|
||||||
|
--network "$NETWORK" \
|
||||||
|
--env-file .env \
|
||||||
|
-e "TENANT_OVERRIDE=$TENANT_OVERRIDE" \
|
||||||
|
-e "SITE_OVERRIDE=$SITE_OVERRIDE" \
|
||||||
|
-e "PGHOST=$PGHOST_OVERRIDE" \
|
||||||
|
-e "PGDATABASE=$PGDATABASE_OVERRIDE" \
|
||||||
|
-v "$ROOT:/app:ro" \
|
||||||
|
-w /app \
|
||||||
|
--entrypoint python \
|
||||||
|
"$IMAGE" \
|
||||||
|
tools/context_decision_group_admin.py \
|
||||||
|
"$ACTION" \
|
||||||
|
"$@"
|
||||||
50
ucepsa/edge-oee-demo/ops/manage_context_regression_v0313.sh
Executable file
50
ucepsa/edge-oee-demo/ops/manage_context_regression_v0313.sh
Executable file
@ -0,0 +1,50 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
set -euo pipefail
|
||||||
|
|
||||||
|
ACTION="${1:-show}"
|
||||||
|
ROOT="${CONTEXT_REGRESSION_RUNTIME_ROOT:-/srv/mesavault/edge-oee-ucepsa}"
|
||||||
|
REPO="${CONTEXT_REGRESSION_REPO_ROOT:-/srv/mesavault/40-clients}"
|
||||||
|
NETWORK="${CONTEXT_REGRESSION_NETWORK:-mv_ucepsa_net}"
|
||||||
|
POSTGRES_CONTAINER="${CONTEXT_REGRESSION_POSTGRES_CONTAINER:-mv_ucepsa_postgres_hot}"
|
||||||
|
PGHOST_OVERRIDE="${CONTEXT_REGRESSION_PGHOST_OVERRIDE:-mv_ucepsa_postgres_hot}"
|
||||||
|
|
||||||
|
cd "$ROOT"
|
||||||
|
|
||||||
|
IMAGE="$(
|
||||||
|
docker inspect \
|
||||||
|
mv_ucepsa_odoo_order_state_pg_sink \
|
||||||
|
--format '{{.Config.Image}}'
|
||||||
|
)"
|
||||||
|
|
||||||
|
PGDATABASE_OVERRIDE="${CONTEXT_REGRESSION_PGDATABASE_OVERRIDE:-$(
|
||||||
|
docker inspect "$POSTGRES_CONTAINER" \
|
||||||
|
--format '{{range .Config.Env}}{{println .}}{{end}}' \
|
||||||
|
| sed -n 's/^POSTGRES_DB=//p' \
|
||||||
|
| head -n 1
|
||||||
|
)}"
|
||||||
|
|
||||||
|
if [[ -z "$PGDATABASE_OVERRIDE" ]]; then
|
||||||
|
echo "ERROR: no se pudo determinar POSTGRES_DB." >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
SOURCE_GIT_COMMIT="$(
|
||||||
|
git -C "$REPO" rev-parse HEAD 2>/dev/null \
|
||||||
|
|| printf 'unknown'
|
||||||
|
)"
|
||||||
|
|
||||||
|
shift || true
|
||||||
|
|
||||||
|
docker run --rm -i \
|
||||||
|
--network "$NETWORK" \
|
||||||
|
--env-file .env \
|
||||||
|
-e "PGHOST=$PGHOST_OVERRIDE" \
|
||||||
|
-e "PGDATABASE=$PGDATABASE_OVERRIDE" \
|
||||||
|
-e "SOURCE_GIT_COMMIT=$SOURCE_GIT_COMMIT" \
|
||||||
|
-v "$ROOT:/app:ro" \
|
||||||
|
-w /app \
|
||||||
|
--entrypoint python \
|
||||||
|
"$IMAGE" \
|
||||||
|
tools/context_regression_suite.py \
|
||||||
|
"$ACTION" \
|
||||||
|
"$@"
|
||||||
125
ucepsa/edge-oee-demo/ops/manage_context_review_case_builder_v0310.sh
Executable file
125
ucepsa/edge-oee-demo/ops/manage_context_review_case_builder_v0310.sh
Executable file
@ -0,0 +1,125 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
set -euo pipefail
|
||||||
|
|
||||||
|
ACTION="${1:-status}"
|
||||||
|
ROOT="${CONTEXT_CASE_RUNTIME_ROOT:-/srv/mesavault/edge-oee-ucepsa}"
|
||||||
|
CONTAINER="${CONTEXT_CASE_CONTAINER:-mv_ucepsa_context_review_case_builder}"
|
||||||
|
NETWORK="${CONTEXT_CASE_NETWORK:-mv_ucepsa_net}"
|
||||||
|
INTERVAL="${CONTEXT_CASE_INTERVAL_S:-15}"
|
||||||
|
POSTGRES_CONTAINER="${CONTEXT_CASE_POSTGRES_CONTAINER:-mv_ucepsa_postgres_hot}"
|
||||||
|
TENANT_OVERRIDE="${CONTEXT_CASE_TENANT_OVERRIDE:-ucepsa}"
|
||||||
|
SITE_OVERRIDE="${CONTEXT_CASE_SITE_OVERRIDE:-ucepsa_onpremise}"
|
||||||
|
PGHOST_OVERRIDE="${CONTEXT_CASE_PGHOST_OVERRIDE:-mv_ucepsa_postgres_hot}"
|
||||||
|
|
||||||
|
cd "$ROOT"
|
||||||
|
|
||||||
|
IMAGE="$(
|
||||||
|
docker inspect \
|
||||||
|
mv_ucepsa_odoo_order_state_pg_sink \
|
||||||
|
--format '{{.Config.Image}}'
|
||||||
|
)"
|
||||||
|
|
||||||
|
PGDATABASE_OVERRIDE="${CONTEXT_CASE_PGDATABASE_OVERRIDE:-$(
|
||||||
|
docker inspect "$POSTGRES_CONTAINER" \
|
||||||
|
--format '{{range .Config.Env}}{{println .}}{{end}}' \
|
||||||
|
| sed -n 's/^POSTGRES_DB=//p' \
|
||||||
|
| head -n 1
|
||||||
|
)}"
|
||||||
|
|
||||||
|
if [[ -z "$PGDATABASE_OVERRIDE" ]]; then
|
||||||
|
echo "ERROR: no se pudo determinar POSTGRES_DB." >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
common_args=(
|
||||||
|
--network "$NETWORK"
|
||||||
|
--env-file .env
|
||||||
|
-e "TENANT_OVERRIDE=$TENANT_OVERRIDE"
|
||||||
|
-e "SITE_OVERRIDE=$SITE_OVERRIDE"
|
||||||
|
-e "PGHOST=$PGHOST_OVERRIDE"
|
||||||
|
-e "PGDATABASE=$PGDATABASE_OVERRIDE"
|
||||||
|
-v "$ROOT:/app:ro"
|
||||||
|
-w /app
|
||||||
|
--entrypoint python
|
||||||
|
)
|
||||||
|
|
||||||
|
start_builder() {
|
||||||
|
docker rm -f "$CONTAINER" \
|
||||||
|
>/dev/null 2>&1 || true
|
||||||
|
|
||||||
|
docker run -d \
|
||||||
|
--name "$CONTAINER" \
|
||||||
|
--restart unless-stopped \
|
||||||
|
"${common_args[@]}" \
|
||||||
|
"$IMAGE" \
|
||||||
|
tools/context_review_case_builder.py \
|
||||||
|
--watch \
|
||||||
|
--interval "$INTERVAL"
|
||||||
|
|
||||||
|
docker ps \
|
||||||
|
--format 'table {{.Names}}\t{{.Status}}' \
|
||||||
|
| grep -E "$CONTAINER|NAMES"
|
||||||
|
}
|
||||||
|
|
||||||
|
run_builder_once() {
|
||||||
|
docker run --rm -i \
|
||||||
|
"${common_args[@]}" \
|
||||||
|
"$IMAGE" \
|
||||||
|
tools/context_review_case_builder.py \
|
||||||
|
--once
|
||||||
|
}
|
||||||
|
|
||||||
|
run_builder_dry() {
|
||||||
|
docker run --rm -i \
|
||||||
|
"${common_args[@]}" \
|
||||||
|
"$IMAGE" \
|
||||||
|
tools/context_review_case_builder.py \
|
||||||
|
--once \
|
||||||
|
--dry-run
|
||||||
|
}
|
||||||
|
|
||||||
|
run_admin() {
|
||||||
|
shift
|
||||||
|
docker run --rm -i \
|
||||||
|
"${common_args[@]}" \
|
||||||
|
"$IMAGE" \
|
||||||
|
tools/context_review_case_admin.py \
|
||||||
|
"$@"
|
||||||
|
}
|
||||||
|
|
||||||
|
case "$ACTION" in
|
||||||
|
start)
|
||||||
|
start_builder
|
||||||
|
;;
|
||||||
|
stop)
|
||||||
|
docker stop "$CONTAINER"
|
||||||
|
;;
|
||||||
|
restart)
|
||||||
|
docker rm -f "$CONTAINER" \
|
||||||
|
>/dev/null 2>&1 || true
|
||||||
|
start_builder
|
||||||
|
;;
|
||||||
|
status)
|
||||||
|
docker ps -a \
|
||||||
|
--format 'table {{.Names}}\t{{.Status}}' \
|
||||||
|
| grep -E "$CONTAINER|NAMES"
|
||||||
|
;;
|
||||||
|
logs)
|
||||||
|
docker logs --tail 200 -f "$CONTAINER"
|
||||||
|
;;
|
||||||
|
once)
|
||||||
|
run_builder_once
|
||||||
|
;;
|
||||||
|
dry-run)
|
||||||
|
run_builder_dry
|
||||||
|
;;
|
||||||
|
admin)
|
||||||
|
run_admin "$@"
|
||||||
|
;;
|
||||||
|
*)
|
||||||
|
echo \
|
||||||
|
"Uso: $0 {start|stop|restart|status|logs|once|dry-run|admin ...}" \
|
||||||
|
>&2
|
||||||
|
exit 2
|
||||||
|
;;
|
||||||
|
esac
|
||||||
93
ucepsa/edge-oee-demo/ops/manage_governance_review_ui_v0314.sh
Executable file
93
ucepsa/edge-oee-demo/ops/manage_governance_review_ui_v0314.sh
Executable file
@ -0,0 +1,93 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
set -euo pipefail
|
||||||
|
|
||||||
|
ACTION="${1:-status}"
|
||||||
|
RUNTIME_ROOT="${GOVERNANCE_UI_RUNTIME_ROOT:-/srv/mesavault/edge-oee-ucepsa}"
|
||||||
|
APP_ROOT="${GOVERNANCE_UI_APP_ROOT:-$RUNTIME_ROOT/governance-ui}"
|
||||||
|
CONTAINER="${GOVERNANCE_UI_CONTAINER:-mv_ucepsa_governance_ui}"
|
||||||
|
IMAGE="${GOVERNANCE_UI_IMAGE:-mesavault/ucepsa-governance-ui:0.3.14}"
|
||||||
|
NETWORK="${GOVERNANCE_UI_NETWORK:-mv_ucepsa_net}"
|
||||||
|
HOST_IP="${GOVERNANCE_UI_HOST_IP:-0.0.0.0}"
|
||||||
|
HOST_PORT="${GOVERNANCE_UI_HOST_PORT:-8091}"
|
||||||
|
BASE_ENV="${GOVERNANCE_UI_BASE_ENV:-$RUNTIME_ROOT/.env}"
|
||||||
|
UI_ENV="${GOVERNANCE_UI_ENV:-$APP_ROOT/.env}"
|
||||||
|
|
||||||
|
require_build_files() {
|
||||||
|
test -f "$APP_ROOT/Dockerfile"
|
||||||
|
test -f "$APP_ROOT/requirements.txt"
|
||||||
|
}
|
||||||
|
|
||||||
|
require_runtime_files() {
|
||||||
|
require_build_files
|
||||||
|
test -f "$UI_ENV"
|
||||||
|
test -f "$BASE_ENV"
|
||||||
|
}
|
||||||
|
|
||||||
|
build_image() {
|
||||||
|
require_build_files
|
||||||
|
docker build \
|
||||||
|
-t "$IMAGE" \
|
||||||
|
"$APP_ROOT"
|
||||||
|
}
|
||||||
|
|
||||||
|
start_container() {
|
||||||
|
require_runtime_files
|
||||||
|
docker rm -f "$CONTAINER" \
|
||||||
|
>/dev/null 2>&1 || true
|
||||||
|
|
||||||
|
docker run -d \
|
||||||
|
--name "$CONTAINER" \
|
||||||
|
--restart unless-stopped \
|
||||||
|
--network "$NETWORK" \
|
||||||
|
--env-file "$BASE_ENV" \
|
||||||
|
--env-file "$UI_ENV" \
|
||||||
|
-e "PGHOST=mv_ucepsa_postgres_hot" \
|
||||||
|
-e "TENANT_OVERRIDE=ucepsa" \
|
||||||
|
-e "SITE_OVERRIDE=ucepsa_onpremise" \
|
||||||
|
-p "${HOST_IP}:${HOST_PORT}:8080" \
|
||||||
|
"$IMAGE"
|
||||||
|
|
||||||
|
docker ps \
|
||||||
|
--format 'table {{.Names}}\t{{.Status}}\t{{.Ports}}' \
|
||||||
|
| grep -E "$CONTAINER|NAMES"
|
||||||
|
}
|
||||||
|
|
||||||
|
case "$ACTION" in
|
||||||
|
build)
|
||||||
|
build_image
|
||||||
|
;;
|
||||||
|
start)
|
||||||
|
start_container
|
||||||
|
;;
|
||||||
|
deploy)
|
||||||
|
build_image
|
||||||
|
start_container
|
||||||
|
;;
|
||||||
|
restart)
|
||||||
|
docker rm -f "$CONTAINER" \
|
||||||
|
>/dev/null 2>&1 || true
|
||||||
|
start_container
|
||||||
|
;;
|
||||||
|
stop)
|
||||||
|
docker stop "$CONTAINER"
|
||||||
|
;;
|
||||||
|
status)
|
||||||
|
docker ps -a \
|
||||||
|
--format 'table {{.Names}}\t{{.Status}}\t{{.Ports}}' \
|
||||||
|
| grep -E "$CONTAINER|NAMES"
|
||||||
|
;;
|
||||||
|
logs)
|
||||||
|
docker logs --tail 200 -f "$CONTAINER"
|
||||||
|
;;
|
||||||
|
health)
|
||||||
|
curl -fsS \
|
||||||
|
"http://127.0.0.1:${HOST_PORT}/health"
|
||||||
|
echo
|
||||||
|
;;
|
||||||
|
*)
|
||||||
|
echo \
|
||||||
|
"Uso: $0 {build|start|deploy|restart|stop|status|logs|health}" \
|
||||||
|
>&2
|
||||||
|
exit 2
|
||||||
|
;;
|
||||||
|
esac
|
||||||
Some files were not shown because too many files have changed in this diff Show More
Loading…
x
Reference in New Issue
Block a user