feat(ucepsa): add governance review ui shadow

This commit is contained in:
Victor Fraile Garcia 2026-07-21 10:29:31 +02:00
parent 27a653eb42
commit 33706d397a
24 changed files with 3799 additions and 5 deletions

View File

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

View File

@ -0,0 +1,6 @@
.env
.hash-venv
__pycache__
*.pyc
.pytest_cache
.git

View 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"]}]

View File

@ -0,0 +1,25 @@
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
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"]

View File

@ -0,0 +1,162 @@
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
SESSION_COOKIE = "mv_governance_session"
SESSION_SALT = "mv-governance-ui-v0314"
@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:
if "ADMIN" in user.roles:
return True
if review_owner == "PRODUCTION":
return "PRODUCTION" in user.roles
if review_owner == "TECHNICAL":
return "TECHNICAL" in user.roles
if review_owner == "JOINT":
return bool(
user.roles
& {
"PRODUCTION",
"TECHNICAL",
"JOINT",
}
)
return False
def can_review_owner(
user: CurrentUser,
review_owner: str,
) -> bool:
return can_view_owner(
user,
review_owner,
)

View 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_v0314",
)
try:
yield connection
finally:
connection.close()
def dict_cursor(connection: Connection) -> RealDictCursor:
return connection.cursor(
cursor_factory=RealDictCursor
)

View File

@ -0,0 +1,641 @@
from __future__ import annotations
from pathlib import Path
from typing import Any
from urllib.parse import quote
from fastapi import (
FastAPI,
Form,
HTTPException,
Request,
status,
)
from fastapi.responses import (
HTMLResponse,
RedirectResponse,
)
from fastapi.staticfiles import StaticFiles
from fastapi.templating import Jinja2Templates
from psycopg2.extras import RealDictCursor
from .auth import (
SESSION_COOKIE,
authenticate,
build_session_token,
can_review_owner,
can_view_owner,
read_current_user,
require_user,
verify_csrf,
)
from .db import get_connection
from .review_service import (
ReviewError,
ReviewInput,
apply_review,
)
from .settings import get_settings
BASE_DIR = Path(__file__).resolve().parent
app = FastAPI(
title="MESAVAULT Governance Review UI",
version="0.3.14",
)
app.mount(
"/static",
StaticFiles(
directory=BASE_DIR / "static"
),
name="static",
)
templates = Jinja2Templates(
directory=BASE_DIR / "templates"
)
def format_datetime(value: Any) -> str:
if value is None:
return ""
try:
return value.strftime(
"%d/%m/%Y %H:%M:%S"
)
except AttributeError:
return str(value)
templates.env.filters[
"datetime_es"
] = format_datetime
def visible_queue(
user,
) -> list[dict[str, Any]]:
with get_connection() as connection:
with connection.cursor(
cursor_factory=RealDictCursor,
) as cursor:
cursor.execute(
"""
SELECT *
FROM
mv_reports_ucepsa_prod
.li_context_governance_ui_queue_v1
ORDER BY agenda_rank
"""
)
rows = [
dict(row)
for row in cursor.fetchall()
]
return [
row
for row in rows
if can_view_owner(
user,
str(row["review_owner"]),
)
]
def recent_actions(
limit: int = 50,
) -> list[dict[str, Any]]:
with get_connection() as connection:
with connection.cursor(
cursor_factory=RealDictCursor,
) as cursor:
cursor.execute(
"""
SELECT *
FROM
mv_reports_ucepsa_prod
.li_context_governance_ui_recent_actions_v1
ORDER BY
created_at DESC,
ui_action_id DESC
LIMIT %s
""",
(limit,),
)
return [
dict(row)
for row in cursor.fetchall()
]
def load_decision(
decision_group_key: str,
) -> tuple[
dict[str, Any],
list[dict[str, Any]],
]:
with get_connection() as connection:
with connection.cursor(
cursor_factory=RealDictCursor,
) as cursor:
cursor.execute(
"""
SELECT *
FROM
mv_reports_ucepsa_prod
.li_context_governance_decision_evidence_pack_v1
WHERE decision_group_key = %s
""",
(decision_group_key,),
)
decision = cursor.fetchone()
if not decision:
raise HTTPException(
status_code=404,
detail="Decisión no encontrada.",
)
cursor.execute(
"""
SELECT *
FROM
mv_reports_ucepsa_prod
.li_context_governance_ui_case_timeline_v1
WHERE decision_group_key = %s
ORDER BY
first_seen_at,
case_id
""",
(decision_group_key,),
)
cases = [
dict(row)
for row in cursor.fetchall()
]
return dict(decision), cases
def answer_options(
decision_family: str,
) -> list[tuple[str, str]]:
if decision_family == "UNCONFIRMED_ACTIVITY":
return [
(
"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",
),
(
"OTHER",
"Otro",
),
]
if decision_family == "ODOO_ORDER_START_CONFLICT":
return [
(
"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",
),
]
return [
(
"DATA_ISSUE",
"Incidencia de datos",
),
(
"OTHER",
"Otro",
),
]
@app.get(
"/health",
response_class=HTMLResponse,
)
def health() -> str:
with get_connection() as connection:
with connection.cursor() as cursor:
cursor.execute("SELECT 1")
cursor.fetchone()
return "ok"
@app.get(
"/login",
response_class=HTMLResponse,
)
def login_page(
request: Request,
):
if read_current_user(request):
return RedirectResponse(
"/",
status_code=status.HTTP_303_SEE_OTHER,
)
return templates.TemplateResponse(
request=request,
name="login.html",
context={
"settings": get_settings(),
"error": None,
},
)
@app.post(
"/login",
response_class=HTMLResponse,
)
def login_submit(
request: Request,
username: str = Form(...),
password: str = Form(...),
):
user = authenticate(
username.strip(),
password,
)
if user is None:
return templates.TemplateResponse(
request=request,
name="login.html",
status_code=401,
context={
"settings": get_settings(),
"error":
"Usuario o contraseña incorrectos.",
},
)
response = RedirectResponse(
"/",
status_code=status.HTTP_303_SEE_OTHER,
)
response.set_cookie(
SESSION_COOKIE,
build_session_token(user),
max_age=get_settings().session_max_age_s,
httponly=True,
secure=get_settings().cookie_secure,
samesite="lax",
path="/",
)
return response
@app.post("/logout")
def logout(
request: Request,
csrf_token: str = Form(...),
):
user = require_user(request)
verify_csrf(
user,
csrf_token,
)
response = RedirectResponse(
"/login",
status_code=status.HTTP_303_SEE_OTHER,
)
response.delete_cookie(
SESSION_COOKIE,
path="/",
)
return response
@app.get(
"/",
response_class=HTMLResponse,
)
def queue_page(
request: Request,
message: str | None = None,
):
user = read_current_user(request)
if user is None:
return RedirectResponse(
"/login",
status_code=status.HTTP_303_SEE_OTHER,
)
return templates.TemplateResponse(
request=request,
name="queue.html",
context={
"settings": get_settings(),
"user": user,
"rows": visible_queue(user),
"message": message,
},
)
@app.get(
"/decisions/{decision_group_key:path}",
response_class=HTMLResponse,
)
def decision_page(
request: Request,
decision_group_key: str,
error: str | None = None,
):
user = read_current_user(request)
if user is None:
return RedirectResponse(
"/login",
status_code=status.HTTP_303_SEE_OTHER,
)
decision, cases = load_decision(
decision_group_key
)
if not can_view_owner(
user,
str(decision["review_owner"]),
):
raise HTTPException(
status_code=403,
detail="No tiene acceso a esta decisión.",
)
pending_cases = [
case
for case in cases
if case["case_review_status"] ==
"PENDING"
]
order_pairs = sorted({
(
str(item["production_order"]),
int(item["odoo_workorder_id"]),
)
for item in (
decision.get(
"episode_evidence"
)
or []
)
if item.get(
"production_order"
)
and item.get(
"odoo_workorder_id"
) is not None
})
return templates.TemplateResponse(
request=request,
name="decision.html",
context={
"settings": get_settings(),
"user": user,
"decision": decision,
"cases": cases,
"pending_cases": pending_cases,
"answer_options": answer_options(
str(
decision[
"decision_family"
]
)
),
"order_pairs": order_pairs,
"can_review": (
get_settings().write_enabled
and can_review_owner(
user,
str(decision["review_owner"]),
)
),
"write_enabled":
get_settings().write_enabled,
"error": error,
},
)
@app.post(
"/decisions/{decision_group_key:path}/review"
)
def review_decision(
request: Request,
decision_group_key: str,
csrf_token: str = Form(...),
case_ids: list[int] = Form(default=[]),
answer_code: str = Form(...),
notes: str = Form(""),
selected_order: str = Form(""),
corrected_in_odoo: bool = Form(False),
create_contexts: bool = Form(False),
confirmation: bool = Form(False),
):
user = require_user(request)
verify_csrf(
user,
csrf_token,
)
if not get_settings().write_enabled:
raise HTTPException(
status_code=503,
detail="La interfaz está en modo solo lectura.",
)
decision, _ = load_decision(
decision_group_key
)
if not can_review_owner(
user,
str(decision["review_owner"]),
):
raise HTTPException(
status_code=403,
detail="No puede resolver esta decisión.",
)
selected_order_ref = None
selected_workorder_id = None
if selected_order:
try:
selected_order_ref, raw_id = (
selected_order.split(
"|",
1,
)
)
selected_workorder_id = int(
raw_id
)
except (
ValueError,
TypeError,
):
target = (
"/decisions/"
+ quote(
decision_group_key,
safe="",
)
+ "?error="
+ quote(
"Orden seleccionada no válida."
)
)
return RedirectResponse(
target,
status_code=303,
)
review = ReviewInput(
decision_group_key=
decision_group_key,
selected_case_ids=tuple(
sorted(
set(
int(case_id)
for case_id in case_ids
)
)
),
answer_code=answer_code,
notes=notes.strip(),
selected_order_ref=
selected_order_ref,
selected_workorder_id=
selected_workorder_id,
corrected_in_odoo=
corrected_in_odoo,
create_contexts=
create_contexts,
confirmation=
confirmation,
)
try:
result = apply_review(
review=review,
user=user,
source_ip=(
request.client.host
if request.client
else None
),
user_agent=request.headers.get(
"user-agent"
),
)
except ReviewError as exc:
target = (
"/decisions/"
+ quote(
decision_group_key,
safe="",
)
+ "?error="
+ quote(str(exc))
)
return RedirectResponse(
target,
status_code=303,
)
message = (
"Revisión registrada. "
f"Acción UI {result['ui_action_id']}."
)
if result[
"created_context_session_ids"
]:
message += (
" Contextos creados: "
+ ", ".join(
str(value)
for value in result[
"created_context_session_ids"
]
)
+ "."
)
return RedirectResponse(
"/?message="
+ quote(message),
status_code=303,
)
@app.get(
"/history",
response_class=HTMLResponse,
)
def history_page(
request: Request,
):
user = read_current_user(request)
if user is None:
return RedirectResponse(
"/login",
status_code=303,
)
return templates.TemplateResponse(
request=request,
name="history.html",
context={
"settings": get_settings(),
"user": user,
"actions": recent_actions(),
},
)

File diff suppressed because it is too large Load Diff

View 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)),
)

View File

@ -0,0 +1,339 @@
: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;
}
}

View File

@ -0,0 +1,33 @@
<!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>
<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>

View File

@ -0,0 +1,158 @@
{% 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 }}</span>
<h1>{{ decision.decision_title }}</h1>
</div>
<div class="status-stack">
<span class="status">
Incidencia:
{% if decision.decision_status == "OPEN" %}Activa{% else %}Terminada{% endif %}
</span>
<span class="status">Revisión: {{ decision.review_status }}</span>
</div>
</div>
<div class="question-box">
<h2>Pregunta</h2>
<p>{{ decision.question_text }}</p>
</div>
<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:
{% if case.case_status == "OPEN" %}activa{% else %}terminada{% endif %}
· Revisión: {{ case.case_review_status }}
</p>
{% if case.case_resolution_classification %}
<p><strong>Resolución previa:</strong> {{ case.case_resolution_classification }}</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 %}
<form class="review-form" method="post" action="/decisions/{{ decision.decision_group_key|urlencode }}/review">
<input type="hidden" name="csrf_token" value="{{ user.csrf_token }}">
<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, label in answer_options %}
<label class="radio-row">
<input type="radio" name="answer_code" value="{{ code }}" required>
<span>{{ label }}</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 %}
{% if decision.decision_family == "UNCONFIRMED_ACTIVITY" %}
<fieldset>
<legend>3. Contexto retrospectivo</legend>
<label class="check-row">
<input type="checkbox" name="create_contexts" value="true">
<span>Crear un contexto exacto por cada intervalo seleccionado cuando la respuesta lo permita</span>
</label>
<p class="help">
Solo se admite para sistema antiguo, prueba/ajuste o mantenimiento.
No cubre los huecos entre intervalos y no se permite sobre casos todavía abiertos.
</p>
</fieldset>
{% endif %}
<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>
{% else %}
<div class="alert error">Su rol puede consultar esta decisión, pero no resolverla.</div>
{% endif %}
</section>
{% endblock %}

View File

@ -0,0 +1,38 @@
{% extends "base.html" %}
{% block content %}
<div class="page-heading">
<div>
<h1>Historial de acciones</h1>
<p>Acciones realizadas desde la interfaz durante los últimos 30 días.</p>
</div>
</div>
<div class="table-wrap">
<table>
<thead>
<tr>
<th>Fecha</th>
<th>Usuario</th>
<th>Decisión</th>
<th>Respuesta</th>
<th>Casos</th>
<th>Contextos</th>
<th>Estado</th>
</tr>
</thead>
<tbody>
{% for action in actions %}
<tr>
<td>{{ action.created_at|datetime_es }}</td>
<td>{{ action.actor_display_name }}</td>
<td>{{ action.decision_group_key }}</td>
<td>{{ action.answer_code }}</td>
<td>{{ action.selected_case_ids|join(", ") }}</td>
<td>{{ action.created_context_session_ids|join(", ") if action.created_context_session_ids else "—" }}</td>
<td>{{ action.status }}</td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
{% endblock %}

View 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 %}

View File

@ -0,0 +1,61 @@
{% extends "base.html" %}
{% block content %}
<div class="page-heading">
<div>
<h1>Decisiones pendientes</h1>
<p>Una tarjeta equivale a una pregunta humana. Los datos técnicos se conservan como evidencia.</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 requiera intervenció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 }}</span>
</div>
<div class="status-pair">
<span class="status">
Incidencia:
{% if row.incident_status == "ACTIVE" %}Activa{% else %}Terminada{% endif %}
</span>
<span class="status review-pending">Revisión: Pendiente</span>
</div>
</div>
<h2>{{ row.decision_title }}</h2>
<p class="question">{{ row.question_text }}</p>
<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 }}</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 }}</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 %}

View File

@ -0,0 +1,7 @@
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

View File

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

View File

@ -15,7 +15,7 @@
}
]
},
"description": "Supervisión SHADOW de sesiones, gobernanza diaria y regresión funcional. La suite protege contratos históricos, arbitraje, agrupación, agenda y guardas oficiales sin modificar datos productivos.",
"description": "Supervisión SHADOW y acceso a la cola de revisión. Los paneles distinguen incidencia activa/terminada de revisión pendiente/resuelta. La escritura se realiza únicamente mediante la Governance Review UI v0.3.14.",
"editable": true,
"fiscalYearStartMonth": 0,
"graphTooltip": 1,
@ -2743,11 +2743,11 @@
"editorMode": "code",
"format": "table",
"rawQuery": true,
"rawSql": "SELECT\n backlog_rank AS \"Prioridad\",\n decision_group_key AS \"Decisión\",\n CASE decision_status\n WHEN 'OPEN' THEN 'Abierta'\n ELSE 'Cerrada'\n END AS \"Estado\",\n max_severity AS \"Severidad\",\n machine_id AS \"Máquina\",\n decision_title AS \"Pregunta operativa\",\n array_to_string(\n root_case_ids,\n ', '\n ) AS \"Casos raíz\",\n array_to_string(\n production_orders,\n ', '\n ) AS \"Órdenes\",\n array_to_string(\n odoo_workorder_ids,\n ', '\n ) AS \"Workorders\",\n pending_root_case_count\n AS \"Casos pendientes\",\n reviewed_root_case_count\n AS \"Casos revisados\",\n array_to_string(\n known_resolution_classifications,\n ', '\n ) AS \"Resolución conocida\",\n CASE review_owner\n WHEN 'PRODUCTION' THEN 'Producción'\n WHEN 'TECHNICAL' THEN 'Técnico'\n ELSE 'Conjunto'\n END AS \"Responsable\",\n first_seen_at AS \"Inicio\",\n decision_duration_min AS \"Duración min\",\n question_text AS \"Pregunta\",\n recommended_action AS \"Acción\"\nFROM\n mv_reports_ucepsa_prod\n .li_context_decision_group_backlog_v1\nORDER BY backlog_rank\nLIMIT 50",
"rawSql": "SELECT\n agenda_rank AS \"Prioridad\",\n machine_id AS \"Máquina\",\n CASE incident_status\n WHEN 'ACTIVE' THEN 'Activa'\n ELSE 'Terminada'\n END AS \"Incidencia\",\n CASE review_status\n WHEN 'PENDING' THEN 'Pendiente'\n WHEN 'REVIEWED' THEN 'Resuelta'\n ELSE review_status\n END AS \"Revisión\",\n max_severity AS \"Severidad\",\n decision_title AS \"Pregunta operativa\",\n pending_root_case_count AS \"Casos pendientes\",\n linked_episode_count AS \"Evidencias\",\n array_to_string(\n production_orders,\n ', '\n ) AS \"Órdenes\",\n array_to_string(\n odoo_workorder_ids,\n ', '\n ) AS \"Workorders\",\n CASE review_owner\n WHEN 'PRODUCTION' THEN 'Producción'\n WHEN 'TECHNICAL' THEN 'Técnico'\n ELSE 'Conjunto'\n END AS \"Responsable\",\n first_seen_at AS \"Inicio\",\n ended_at AS \"Fin\",\n question_text AS \"Pregunta\",\n recommended_action AS \"Acción\"\nFROM\n mv_reports_ucepsa_prod\n .li_context_governance_ui_queue_v1\nORDER BY agenda_rank\nLIMIT 50",
"refId": "A"
}
],
"title": "Decisiones humanas pendientes",
"title": "Decisiones pendientes de revisión",
"type": "table"
},
{
@ -3150,7 +3150,7 @@
"editorMode": "code",
"format": "table",
"rawQuery": true,
"rawSql": "SELECT\n agenda_rank AS \"Prioridad\",\n max_severity AS \"Severidad\",\n attention_status AS \"Atención\",\n machine_id AS \"Máquina\",\n decision_title AS \"Decisión\",\n CASE review_owner\n WHEN 'PRODUCTION' THEN 'Producción'\n WHEN 'TECHNICAL' THEN 'Técnico'\n ELSE 'Conjunto'\n END AS \"Responsable\",\n pending_age_hours AS \"Antigüedad h\",\n review_due_at AS \"Límite\",\n pending_root_case_count AS \"Casos pendientes\",\n linked_episode_count AS \"Evidencias\",\n array_to_string(\n production_orders,\n ', '\n ) AS \"Órdenes\",\n array_to_string(\n odoo_workorder_ids,\n ', '\n ) AS \"Workorders\",\n question_text AS \"Pregunta\",\n recommended_action AS \"Acción\"\nFROM\n mv_reports_ucepsa_prod\n .li_context_governance_daily_agenda_v1\nORDER BY agenda_rank\nLIMIT 50",
"rawSql": "SELECT\n agenda_rank AS \"Prioridad\",\n max_severity AS \"Severidad\",\n attention_status AS \"Atención\",\n machine_id AS \"Máquina\",\n CASE incident_status\n WHEN 'ACTIVE' THEN 'Activa'\n ELSE 'Terminada'\n END AS \"Incidencia\",\n 'Pendiente' AS \"Revisión\",\n decision_title AS \"Decisión\",\n CASE review_owner\n WHEN 'PRODUCTION' THEN 'Producción'\n WHEN 'TECHNICAL' THEN 'Técnico'\n ELSE 'Conjunto'\n END AS \"Responsable\",\n pending_age_hours AS \"Antigüedad h\",\n review_due_at AS \"Límite\",\n pending_root_case_count AS \"Casos pendientes\",\n linked_episode_count AS \"Evidencias\",\n question_text AS \"Pregunta\",\n recommended_action AS \"Acción\"\nFROM\n mv_reports_ucepsa_prod\n .li_context_governance_ui_queue_v1\nORDER BY agenda_rank\nLIMIT 50",
"refId": "A"
}
],
@ -3672,7 +3672,11 @@
"regression",
"hardening",
"contracts",
"v0313"
"v0313",
"v03131",
"governance-ui",
"human-review",
"v0314"
],
"templating": {
"list": []

View 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

View 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

View File

@ -0,0 +1,27 @@
#!/usr/bin/env bash
set -euo pipefail
PORT="${GOVERNANCE_UI_HOST_PORT:-8091}"
BASE_URL="${GOVERNANCE_UI_BASE_URL:-http://127.0.0.1:${PORT}}"
echo "=== health ==="
curl -fsS "${BASE_URL}/health"
echo
echo "=== login page ==="
curl -fsS "${BASE_URL}/login" \
| grep -F "Revisión de gobernanza" \
>/dev/null
echo "=== protected queue redirects to login ==="
STATUS="$(
curl -sS -o /dev/null -w '%{http_code}' \
"${BASE_URL}/"
)"
if [[ "$STATUS" != "307" && "$STATUS" != "303" ]]; then
echo "ERROR: la cola sin sesión devolvió HTTP $STATUS" >&2
exit 1
fi
echo "Smoke test OK"

View File

@ -0,0 +1,121 @@
\pset pager off
\echo '=== 1. Objetos v0.3.14 ==='
SELECT
to_regclass(
'mv_loss_intelligence.context_governance_ui_actions'
) AS actions_table,
to_regclass(
'mv_reports_ucepsa_prod.li_context_governance_ui_queue_v1'
) AS queue_view,
to_regclass(
'mv_reports_ucepsa_prod.li_context_governance_ui_case_timeline_v1'
) AS timeline_view,
to_regclass(
'mv_reports_ucepsa_prod.li_context_governance_ui_recent_actions_v1'
) AS actions_view;
\echo '=== 2. Cola UI igual a la agenda diaria ==='
SELECT
(
SELECT COUNT(*)
FROM
mv_reports_ucepsa_prod
.li_context_governance_ui_queue_v1
) AS ui_queue_rows,
(
SELECT COUNT(*)
FROM
mv_reports_ucepsa_prod
.li_context_governance_daily_agenda_v1
) AS agenda_rows;
\echo '=== 3. Separación semántica de estados ==='
SELECT COUNT(*) AS invalid_ui_status_rows
FROM
mv_reports_ucepsa_prod
.li_context_governance_ui_queue_v1
WHERE incident_status NOT IN (
'ACTIVE',
'ENDED'
)
OR review_status <> 'PENDING';
\echo '=== 4. Una decisión una sola vez ==='
SELECT COUNT(*) AS duplicate_ui_decisions
FROM (
SELECT
decision_group_key,
COUNT(*) AS n
FROM
mv_reports_ucepsa_prod
.li_context_governance_ui_queue_v1
GROUP BY decision_group_key
HAVING COUNT(*) > 1
) duplicated;
\echo '=== 5. Timeline contiene todos los casos pendientes ==='
SELECT COUNT(*) AS pending_cases_without_timeline
FROM
mv_reports_ucepsa_prod
.li_context_decision_group_cases_v1 c
WHERE c.case_review_status =
'PENDING'
AND NOT EXISTS (
SELECT 1
FROM
mv_reports_ucepsa_prod
.li_context_governance_ui_case_timeline_v1 t
WHERE t.decision_group_key =
c.decision_group_key
AND t.case_id =
c.case_id
);
\echo '=== 6. Auditoría UI no oficial ==='
SELECT
(
SELECT COUNT(*)
FROM
mv_loss_intelligence
.context_governance_ui_actions
WHERE official_eligible
) AS official_ui_action_rows,
(
SELECT COUNT(*)
FROM
mv_reports_ucepsa_prod
.li_context_governance_ui_queue_v1
WHERE official_eligible
) AS official_ui_queue_rows,
(
SELECT COUNT(*)
FROM
mv_reports_ucepsa_prod
.li_operator_stop_queue_v2
) AS official_operator_queue_rows;
\echo '=== 7. Acciones registradas ==='
SELECT
ui_action_id,
decision_group_key,
selected_case_ids,
answer_code,
mapped_classification,
actor_display_name,
create_contexts,
created_context_session_ids,
status,
created_at
FROM
mv_reports_ucepsa_prod
.li_context_governance_ui_recent_actions_v1
ORDER BY created_at DESC
LIMIT 20;

View File

@ -0,0 +1,220 @@
BEGIN;
CREATE TABLE IF NOT EXISTS
mv_loss_intelligence.context_governance_ui_actions (
ui_action_id bigserial PRIMARY KEY,
tenant text NOT NULL,
site text NOT NULL,
request_id uuid NOT NULL,
decision_group_key text NOT NULL,
selected_case_ids bigint[] NOT NULL
CHECK (cardinality(selected_case_ids) > 0),
action_scope text NOT NULL
CHECK (
action_scope IN (
'ALL_PENDING_CASES',
'SELECTED_CASES'
)
),
answer_code text NOT NULL,
mapped_classification text NOT NULL,
selected_order_ref text,
selected_workorder_id bigint,
corrected_in_odoo boolean NOT NULL DEFAULT false,
create_contexts boolean NOT NULL DEFAULT false,
created_context_session_ids bigint[] NOT NULL
DEFAULT ARRAY[]::bigint[],
actor_username text NOT NULL,
actor_display_name text NOT NULL,
actor_roles text[] NOT NULL
DEFAULT ARRAY[]::text[],
notes text,
previous_state jsonb NOT NULL,
resulting_state jsonb NOT NULL,
status text NOT NULL
CHECK (
status IN (
'APPLIED',
'REJECTED',
'ERROR'
)
),
error_detail text,
source_ip inet,
user_agent text,
created_at timestamptz NOT NULL DEFAULT now(),
official_eligible boolean NOT NULL DEFAULT false
CHECK (official_eligible = false),
CONSTRAINT ux_context_governance_ui_request
UNIQUE (tenant, site, request_id)
);
CREATE INDEX IF NOT EXISTS
idx_context_governance_ui_actions_decision
ON
mv_loss_intelligence.context_governance_ui_actions (
tenant,
site,
decision_group_key,
created_at DESC
);
CREATE INDEX IF NOT EXISTS
idx_context_governance_ui_actions_actor
ON
mv_loss_intelligence.context_governance_ui_actions (
tenant,
site,
actor_username,
created_at DESC
);
COMMENT ON TABLE
mv_loss_intelligence.context_governance_ui_actions IS
'Auditoría SHADOW de cada acción realizada desde la interfaz de revisión. No modifica Odoo ni habilita el Ledger oficial.';
CREATE OR REPLACE VIEW
mv_reports_ucepsa_prod.li_context_governance_ui_queue_v1
AS
SELECT
a.agenda_rank,
a.decision_group_key,
a.machine_id,
CASE a.decision_status
WHEN 'OPEN' THEN 'ACTIVE'
ELSE 'ENDED'
END AS incident_status,
a.review_status,
a.max_severity,
a.decision_family,
a.decision_title,
a.question_text,
a.recommended_action,
a.review_owner,
a.first_seen_at,
a.last_seen_at,
a.ended_at,
a.pending_age_hours,
a.attention_status,
a.review_due_at,
a.pending_root_case_count,
a.reviewed_root_case_count,
a.linked_root_case_count,
a.linked_episode_count,
a.root_case_ids,
a.episode_ids,
a.production_orders,
a.odoo_workorder_ids,
a.operator_names,
a.known_resolution_classifications,
a.official_eligible
FROM
mv_reports_ucepsa_prod
.li_context_governance_daily_agenda_v1 a;
COMMENT ON VIEW
mv_reports_ucepsa_prod.li_context_governance_ui_queue_v1 IS
'Cola de la interfaz: separa estado de incidencia y estado de revisión para evitar confundir terminada con resuelta.';
CREATE OR REPLACE VIEW
mv_reports_ucepsa_prod.li_context_governance_ui_case_timeline_v1
AS
SELECT
d.decision_group_key,
d.decision_family,
d.machine_id,
d.decision_status,
d.decision_review_status,
d.decision_case_role,
d.case_id,
d.case_family,
d.case_title,
d.case_status,
d.case_severity,
d.first_seen_at,
d.last_seen_at,
d.ended_at,
d.case_duration_min,
d.linked_evidence_count,
d.episode_ids,
d.production_orders,
d.odoo_workorder_ids,
d.case_review_owner,
d.case_review_status,
d.case_resolution_classification,
d.case_selected_order_ref,
d.case_selected_workorder_id,
d.case_reviewed_by,
d.case_reviewed_at,
d.case_review_notes,
d.official_eligible
FROM
mv_reports_ucepsa_prod
.li_context_decision_group_cases_v1 d;
COMMENT ON VIEW
mv_reports_ucepsa_prod.li_context_governance_ui_case_timeline_v1 IS
'Línea temporal de casos raíz mostrada en la interfaz de revisión.';
CREATE OR REPLACE VIEW
mv_reports_ucepsa_prod.li_context_governance_ui_recent_actions_v1
AS
SELECT
a.ui_action_id,
a.request_id,
a.decision_group_key,
a.selected_case_ids,
a.action_scope,
a.answer_code,
a.mapped_classification,
a.selected_order_ref,
a.selected_workorder_id,
a.corrected_in_odoo,
a.create_contexts,
a.created_context_session_ids,
a.actor_username,
a.actor_display_name,
a.actor_roles,
a.notes,
a.status,
a.error_detail,
a.source_ip,
a.created_at,
a.official_eligible
FROM
mv_loss_intelligence.context_governance_ui_actions a
WHERE a.created_at >= now() - interval '30 days'
ORDER BY
a.created_at DESC,
a.ui_action_id DESC;
COMMENT ON VIEW
mv_reports_ucepsa_prod.li_context_governance_ui_recent_actions_v1 IS
'Historial de acciones de la interfaz durante los últimos 30 días.';
DO $$
BEGIN
IF EXISTS (
SELECT 1
FROM pg_roles
WHERE rolname = 'grafana_ucepsa_ro'
) THEN
GRANT SELECT
ON mv_reports_ucepsa_prod
.li_context_governance_ui_queue_v1
TO grafana_ucepsa_ro;
GRANT SELECT
ON mv_reports_ucepsa_prod
.li_context_governance_ui_case_timeline_v1
TO grafana_ucepsa_ro;
GRANT SELECT
ON mv_reports_ucepsa_prod
.li_context_governance_ui_recent_actions_v1
TO grafana_ucepsa_ro;
END IF;
END
$$;
COMMIT;