Compare commits
55 Commits
recovery/d
...
2f2b8f3538
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
2f2b8f3538 | ||
|
|
3bdd7b7594 | ||
|
|
58448b5496 | ||
| dc0d9b99a8 | |||
|
|
ccb25a91a3 | ||
|
|
83c3713bea | ||
|
|
67840dac0e | ||
|
|
3d6bcf66b2 | ||
|
|
c1a7ab0458 | ||
|
|
c1b5d8a72e | ||
|
|
1adeb7448e | ||
|
|
42e7f20d2d | ||
|
|
c7648ff0e6 | ||
|
|
d22b4191ae | ||
|
|
8131bb3093 | ||
|
|
86df19afe9 | ||
|
|
b18e0af4c3 | ||
|
|
546b82e560 | ||
|
|
e227eccb57 | ||
|
|
76113ab9d2 | ||
|
|
4ab80e24bc | ||
|
|
42ffe50202 | ||
|
|
036e99edbe | ||
|
|
9a15f77095 | ||
|
|
fa33e268a3 | ||
|
|
e83accea80 | ||
|
|
923511d5a9 | ||
|
|
42cf6b9e9c | ||
|
|
2f229b6cfb | ||
|
|
790b1213c3 | ||
|
|
4daba89ba3 | ||
|
|
77f1bf07cb | ||
|
|
39f1f811e6 | ||
|
|
a2152e4bce | ||
|
|
f051555569 | ||
|
|
34e9e0a2ba | ||
| 5f2c5a8e11 | |||
| 2688509d78 | |||
|
|
039557e8e2 | ||
|
|
7cc7460220 | ||
|
|
bc09dc1f80 | ||
|
|
b2e3da2728 | ||
|
|
f94c75cdd0 | ||
|
|
7b9d3998b1 | ||
|
|
c62c9991d6 | ||
|
|
8f239c6ff4 | ||
|
|
399c203b10 | ||
|
|
eba624d58b | ||
|
|
582348ef45 | ||
|
|
f6e1a2e616 | ||
|
|
66dfedaf20 | ||
|
|
f776f61430 | ||
|
|
c9d3afe4c0 | ||
| 9393886836 | |||
| 5cbadb17e1 |
10
.cargo/config.toml
Normal file
10
.cargo/config.toml
Normal file
@@ -0,0 +1,10 @@
|
|||||||
|
# ==============================================================================
|
||||||
|
# Cargo Configuration — Workspace-level settings
|
||||||
|
# ==============================================================================
|
||||||
|
# Profile settings (dev/release) are defined in Cargo.toml [profile.*] sections.
|
||||||
|
# This file is for non-profile settings only.
|
||||||
|
|
||||||
|
[profile.dev]
|
||||||
|
# Split debug info into separate files so the linker doesn't embed them.
|
||||||
|
# On Windows this is already the default (.pdb), but being explicit helps.
|
||||||
|
split-debuginfo = "packed"
|
||||||
46
.env.example
46
.env.example
@@ -1,46 +0,0 @@
|
|||||||
# =============================================================================
|
|
||||||
# OMNIOIL SCADA - LISTA CONSOLIDADA
|
|
||||||
# =============================================================================
|
|
||||||
|
|
||||||
# --- CONTROL DE ARCHIVOS COMPOSE ---
|
|
||||||
# Local (desarrollo): COMPOSE_FILE=docker-compose.yml:docker-compose.dev.yml
|
|
||||||
# Producción (Dokploy): COMPOSE_FILE=docker-compose.yml
|
|
||||||
COMPOSE_PATH_SEPARATOR=:
|
|
||||||
COMPOSE_FILE=docker-compose.yml:docker-compose.dev.yml
|
|
||||||
|
|
||||||
# --- INFRAESTRUCTURA CORE ---
|
|
||||||
DB_USER=adminomnioil
|
|
||||||
DB_PASSWORD=admin123
|
|
||||||
DB_NAME=omnioil
|
|
||||||
|
|
||||||
# --- REDIS & STORAGE ---
|
|
||||||
REDIS_URL=redis://redis:6379
|
|
||||||
MINIO_USER=admin_s3
|
|
||||||
MINIO_PASSWORD=otra_clave_segura_minio
|
|
||||||
|
|
||||||
# --- SEGURIDAD (JWT & Tunnels) ---
|
|
||||||
JWT_SECRET=llave_maestra_para_tokens_api
|
|
||||||
TUNNEL_SECRET=llave_para_tuneles_seguros_edge
|
|
||||||
ALLOWED_ORIGINS=http://localhost:3000,http://localhost:5173,https://mobile.omnioil.app,https://app.omnioil.app
|
|
||||||
|
|
||||||
# --- EDGE & ORQUESTACIÓN ---
|
|
||||||
DOCKER_REGISTRY_URL=docker-registry:5000
|
|
||||||
MQTT_HOST=mosquitto
|
|
||||||
MQTT_PORT=1883
|
|
||||||
API_URL=
|
|
||||||
API_KEY=
|
|
||||||
MQTT_USER=omnioil_admin
|
|
||||||
MQTT_PASSWORD=admin123
|
|
||||||
# Hash para 'admin123' (generado para Bcrypt)
|
|
||||||
MQTT_ADMIN_HASH='$2a$10$WlHbLOyB/A6WKwIXwh24Y.Njgi0w26eAq9nwpsRQIoqlEU7.n3H5O'
|
|
||||||
REDIS_PASSWORD=redisoilsecret456
|
|
||||||
|
|
||||||
# --- VOLUMENES (Desarrollo: Bind Mounts | Producción: Named Volumes) ---
|
|
||||||
TIMESCALEDB_DATA=./.docker/timescaledb_data
|
|
||||||
REDIS_DATA=./.docker/redis_data
|
|
||||||
MINIO_DATA=./.docker/minio_data
|
|
||||||
MOSQUITTO_DATA=./.docker/mosquitto_data
|
|
||||||
MOSQUITTO_LOG=./.docker/mosquitto_log
|
|
||||||
REGISTRY_DATA=./.docker/registry_data
|
|
||||||
INSTALLER_INCOMING=./.docker/installer_incoming
|
|
||||||
INSTALLER_OUTPUT=./.docker/installer_output
|
|
||||||
73
.env.example.docker
Normal file
73
.env.example.docker
Normal file
@@ -0,0 +1,73 @@
|
|||||||
|
# =============================================================================
|
||||||
|
# OMNIOIL SCADA - CONFIGURACIÓN PARA AMBIENTE DOCKER (NGINX AWARE + DOCUMENTADA)
|
||||||
|
# =============================================================================
|
||||||
|
|
||||||
|
# --- ORQUESTACIÓN DE COMPOSE ---
|
||||||
|
COMPOSE_PATH_SEPARATOR=; # Separador de archivos compose para Windows
|
||||||
|
COMPOSE_FILE=docker-compose.yml;docker-compose.dev.yml # Archivos compose activos por defecto
|
||||||
|
|
||||||
|
# --- BASE DE DATOS (Postgres/Timescale) ---
|
||||||
|
DB_USER=adminomnioil # Usuario administrador de la DB
|
||||||
|
DB_PASSWORD=admin123 # Contraseña de la DB
|
||||||
|
DB_NAME=omnioil # Nombre de la base de datos principal
|
||||||
|
DB_HOST=timescaledb # Host (Nombre del servicio en Docker)
|
||||||
|
DB_PORT=5432 # Puerto estándar de Postgres
|
||||||
|
DATABASE_URL=postgres://${DB_USER}:${DB_PASSWORD}@${DB_HOST}:${DB_PORT}/${DB_NAME}
|
||||||
|
|
||||||
|
# --- CACHE (Redis) ---
|
||||||
|
REDIS_PASSWORD=redisoilsecret456 # Contraseña de seguridad de Redis
|
||||||
|
REDIS_URL=redis://:${REDIS_PASSWORD}@redis:6379 # Conexión interna de Docker a Redis
|
||||||
|
|
||||||
|
# --- ALMACENAMIENTO (MinIO / S3) ---
|
||||||
|
MINIO_USER=admin_s3
|
||||||
|
MINIO_PASSWORD=otra_clave_segura_minio
|
||||||
|
MINIO_ENDPOINT_URL=http://minio:9000
|
||||||
|
MINIO_ENDPOINT=http://minio:9000
|
||||||
|
MINIO_BUCKET=anh-reports # Bucket para reportes generados
|
||||||
|
S3_INSTALLERS_BUCKET=installers # Bucket exclusivo para instaladores MSI
|
||||||
|
MINIO_PUBLIC_URL=https://app.omnioil.app:9000
|
||||||
|
AWS_ACCESS_KEY_ID=${MINIO_USER}
|
||||||
|
AWS_SECRET_ACCESS_KEY=${MINIO_PASSWORD}
|
||||||
|
AWS_REGION=us-east-1
|
||||||
|
|
||||||
|
# --- FRONTEND (Rutas vía Nginx Gateway) ---
|
||||||
|
VITE_API_URL=http://api.omnioil.app # URL del API unificada por Nginx
|
||||||
|
VITE_API_BASE=/api # Prefijo de ruta para la API
|
||||||
|
VITE_ADMIN_APP_ORIGIN=http://app.omnioil.app # Subdominio de Administración en Nginx
|
||||||
|
VITE_MOBILE_APP_ORIGIN=http://mobile.omnioil.app # Subdominio de la PWA en Nginx
|
||||||
|
|
||||||
|
# --- MENSAJERÍA (Mosquitto / MQTT) ---
|
||||||
|
MQTT_HOST=mosquitto # Host del broker interno (servicio Docker)
|
||||||
|
MQTT_PORT=1883 # Puerto estándar (TCP) interno
|
||||||
|
MQTT_USER=omnioil_admin # Usuario para el broker MQTT
|
||||||
|
MQTT_PASSWORD=admin123 # Contraseña para el broker MQTT
|
||||||
|
MOSQUITTO_GO_AUTH=true # Habilita autenticación vía base de datos en Mosquitto
|
||||||
|
|
||||||
|
# --- SEGURIDAD Y TÚNELES ---
|
||||||
|
JWT_SECRET=llave_maestra_para_tokens_api # Secreto para firmar tokens de sesión
|
||||||
|
OMNIOIL_MASTER_SECRET=redisoilsecret456 # Secreto maestro para encriptación de datos sensibles
|
||||||
|
ALLOWED_ORIGINS=http://app.omnioil.app,http://mobile.omnioil.app # Orígenes permitidos (Subdominios Nginx)
|
||||||
|
TUNNEL_SECRET_TOKEN=default_tunnel_secret_123 # Token de validación para Nginx Gatekeeper
|
||||||
|
|
||||||
|
# --- ORQUESTACIÓN Y REGISTRY ---
|
||||||
|
DOCKER_REGISTRY_URL=docker-registry:5000 # Registro accesible entre contenedores
|
||||||
|
EXTERNAL_REGISTRY_URL=localhost:5000 # Registro que verá el agente desde fuera
|
||||||
|
BACKEND_API_URL=http://backend-api:8000 # Comunicación interna directa entre contenedores
|
||||||
|
|
||||||
|
# --- CONFIGURACIÓN INYECTADA A AGENTES EDGE ---
|
||||||
|
MQTT_PUBLIC_HOST=omnioil.app # IP o Dominio del servidor que verá el agente remoto
|
||||||
|
MQTT_PUBLIC_PORT=9883 # Puerto MQTT (WebSockets) que verá el agente
|
||||||
|
MQTT_USE_WS=true # Indica al agente que debe usar WebSockets
|
||||||
|
|
||||||
|
# --- CONTROL DE PROCESOS ---
|
||||||
|
RUN_ON_STARTUP=false # Si es true, el generador de reportes corre al iniciar
|
||||||
|
|
||||||
|
# --- VOLUMENES Y PERSISTENCIA ---
|
||||||
|
TIMESCALEDB_DATA=timescaledb_data # Volumen para datos de Postgres
|
||||||
|
REDIS_DATA=redis_data # Volumen para persistencia de Redis
|
||||||
|
MINIO_DATA=minio_data # Volumen para archivos en MinIO
|
||||||
|
MOSQUITTO_DATA=mosquitto_data # Volumen para persistencia de MQTT
|
||||||
|
MOSQUITTO_LOG=mosquitto_log # Volumen para logs de MQTT
|
||||||
|
REGISTRY_DATA=registry_data # Volumen para las imágenes del Docker Registry
|
||||||
|
INSTALLER_INCOMING=./.docker/installer_incoming # Carpeta host donde se inyectan los .exe
|
||||||
|
INSTALLER_OUTPUT=./.docker/installer_output # Carpeta host donde se generan los .msi
|
||||||
73
.env.example.local
Normal file
73
.env.example.local
Normal file
@@ -0,0 +1,73 @@
|
|||||||
|
# =============================================================================
|
||||||
|
# OMNIOIL SCADA - CONFIGURACIÓN PARA AMBIENTE LOCAL (DOCUMENTADA)
|
||||||
|
# =============================================================================
|
||||||
|
|
||||||
|
# --- ORQUESTACIÓN DE COMPOSE ---
|
||||||
|
COMPOSE_PATH_SEPARATOR=; # Separador de archivos compose para Windows
|
||||||
|
COMPOSE_FILE=docker-compose.yml;docker-compose.dev.yml # Archivos compose activos por defecto
|
||||||
|
|
||||||
|
# --- BASE DE DATOS (Postgres/Timescale) ---
|
||||||
|
DB_USER=adminomnioil # Usuario administrador de la DB
|
||||||
|
DB_PASSWORD=admin123 # Contraseña de la DB
|
||||||
|
DB_NAME=omnioil # Nombre de la base de datos principal
|
||||||
|
DB_HOST=localhost # Host (localhost para desarrollo nativo)
|
||||||
|
DB_PORT=5432 # Puerto estándar de Postgres
|
||||||
|
DATABASE_URL=postgres://${DB_USER}:${DB_PASSWORD}@${DB_HOST}:${DB_PORT}/${DB_NAME}
|
||||||
|
|
||||||
|
# --- CACHE (Redis) ---
|
||||||
|
REDIS_PASSWORD=redisoilsecret456 # Contraseña de seguridad de Redis
|
||||||
|
REDIS_URL=redis://:${REDIS_PASSWORD}@localhost:6379 # Conexión local a Redis
|
||||||
|
|
||||||
|
# --- ALMACENAMIENTO (MinIO / S3) ---
|
||||||
|
MINIO_USER=admin_s3 # Access Key de MinIO
|
||||||
|
MINIO_PASSWORD=otra_clave_segura_minio # Secret Key de MinIO
|
||||||
|
MINIO_ENDPOINT_URL=http://localhost:9000 # Endpoint para el SDK de AWS (Nativo)
|
||||||
|
MINIO_ENDPOINT=http://localhost:9000 # Endpoint interno para servicios
|
||||||
|
MINIO_BUCKET=anh-reports # Bucket para reportes generados
|
||||||
|
S3_INSTALLERS_BUCKET=installers # Bucket exclusivo para instaladores MSI
|
||||||
|
MINIO_PUBLIC_URL=http://localhost:9000 # URL pública para descargar instaladores
|
||||||
|
AWS_ACCESS_KEY_ID=${MINIO_USER} # Duplicado para compatibilidad con AWS SDK
|
||||||
|
AWS_SECRET_ACCESS_KEY=${MINIO_PASSWORD} # Duplicado para compatibilidad con AWS SDK
|
||||||
|
AWS_REGION=us-east-1 # Región ficticia para MinIO
|
||||||
|
|
||||||
|
# --- FRONTEND ---
|
||||||
|
VITE_API_URL=http://localhost:8000 # URL base del Backend para el Frontend
|
||||||
|
VITE_API_BASE=/api # Prefijo de ruta para la API
|
||||||
|
VITE_ADMIN_APP_ORIGIN=http://localhost:3000 # URL de la aplicación de Administración
|
||||||
|
VITE_MOBILE_APP_ORIGIN=http://localhost:3001 # URL de la aplicación PWA (Mobile)
|
||||||
|
|
||||||
|
# --- MENSAJERÍA (Mosquitto / MQTT) ---
|
||||||
|
MQTT_HOST=localhost # Host del broker para servicios internos
|
||||||
|
MQTT_PORT=1883 # Puerto estándar (TCP) para servicios internos
|
||||||
|
MQTT_USER=omnioil_admin # Usuario para el broker MQTT
|
||||||
|
MQTT_PASSWORD=admin123 # Contraseña para el broker MQTT
|
||||||
|
MOSQUITTO_GO_AUTH=true # Habilita autenticación vía base de datos en Mosquitto
|
||||||
|
|
||||||
|
# --- SEGURIDAD Y TÚNELES ---
|
||||||
|
JWT_SECRET=llave_maestra_para_tokens_api # Secreto para firmar tokens de sesión
|
||||||
|
OMNIOIL_MASTER_SECRET=redisoilsecret456 # Secreto maestro para encriptación de datos sensibles
|
||||||
|
ALLOWED_ORIGINS=http://localhost:3000,http://localhost:3001 # Orígenes permitidos para CORS
|
||||||
|
TUNNEL_SECRET_TOKEN=default_tunnel_secret_123 # Token de validación para Nginx Gatekeeper
|
||||||
|
|
||||||
|
# --- ORQUESTACIÓN Y REGISTRY ---
|
||||||
|
DOCKER_REGISTRY_URL=localhost:5000 # Registro local accesible desde el host
|
||||||
|
EXTERNAL_REGISTRY_URL=localhost:5000 # Registro accesible para agentes en la misma PC
|
||||||
|
BACKEND_API_URL=http://localhost:8000 # URL del API reportada a los agentes
|
||||||
|
|
||||||
|
# --- CONFIGURACIÓN INYECTADA A AGENTES EDGE ---
|
||||||
|
MQTT_PUBLIC_HOST=localhost # Host MQTT que verá el agente (localhost o IP)
|
||||||
|
MQTT_PUBLIC_PORT=9883 # Puerto MQTT (WebSockets) que verá el agente
|
||||||
|
MQTT_USE_WS=true # Indica al agente que debe usar WebSockets
|
||||||
|
|
||||||
|
# --- CONTROL DE PROCESOS ---
|
||||||
|
RUN_ON_STARTUP=false # Si es true, el generador de reportes corre al iniciar
|
||||||
|
|
||||||
|
# --- VOLUMENES Y PERSISTENCIA ---
|
||||||
|
TIMESCALEDB_DATA=timescaledb_data # Volumen para datos de Postgres
|
||||||
|
REDIS_DATA=redis_data # Volumen para persistencia de Redis
|
||||||
|
MINIO_DATA=minio_data # Volumen para archivos en MinIO
|
||||||
|
MOSQUITTO_DATA=mosquitto_data # Volumen para persistencia de MQTT
|
||||||
|
MOSQUITTO_LOG=mosquitto_log # Volumen para logs de MQTT
|
||||||
|
REGISTRY_DATA=registry_data # Volumen para las imágenes del Docker Registry
|
||||||
|
INSTALLER_INCOMING=./.docker/installer_incoming # Carpeta host donde se inyectan los .exe
|
||||||
|
INSTALLER_OUTPUT=./.docker/installer_output # Carpeta host donde se generan los .msi
|
||||||
4
.gitignore
vendored
4
.gitignore
vendored
@@ -20,8 +20,6 @@ yarn-error.log*
|
|||||||
|
|
||||||
# Environment Variables & Secrets
|
# Environment Variables & Secrets
|
||||||
.env
|
.env
|
||||||
.env.*
|
|
||||||
!.env.example
|
|
||||||
|
|
||||||
# OS Generated Files
|
# OS Generated Files
|
||||||
.DS_Store
|
.DS_Store
|
||||||
@@ -45,3 +43,5 @@ CLAUDE.md
|
|||||||
.atl/
|
.atl/
|
||||||
.npm
|
.npm
|
||||||
__pycache__
|
__pycache__
|
||||||
|
build_test/
|
||||||
|
venv
|
||||||
591
Cargo.lock
generated
591
Cargo.lock
generated
File diff suppressed because it is too large
Load Diff
51
Cargo.toml
51
Cargo.toml
@@ -12,3 +12,54 @@ members = [
|
|||||||
]
|
]
|
||||||
|
|
||||||
resolver = "2"
|
resolver = "2"
|
||||||
|
|
||||||
|
# ── Shared dependencies ───────────────────────────────────────────────────────
|
||||||
|
# Centralized versions prevent conflicts and reduce Cargo.lock churn.
|
||||||
|
|
||||||
|
[workspace.dependencies]
|
||||||
|
tokio = { version = "1.50.0", features = ["full"] }
|
||||||
|
serde = { version = "1.0.228", features = ["derive"] }
|
||||||
|
serde_json = "1.0.149"
|
||||||
|
chrono = { version = "0.4.44", features = ["serde"] }
|
||||||
|
uuid = { version = "1.23.0", features = ["v4", "serde"] }
|
||||||
|
anyhow = "1.0.102"
|
||||||
|
tracing = "0.1.44"
|
||||||
|
tracing-subscriber = { version = "0.3.23", features = ["env-filter"] }
|
||||||
|
sqlx = { version = "0.8.6", features = ["runtime-tokio-rustls", "postgres", "uuid", "chrono", "json", "bigdecimal"] }
|
||||||
|
rumqttc = { version = "0.25.1", features = ["use-rustls"] }
|
||||||
|
zstd = "0.13"
|
||||||
|
dotenvy = "0.15.7"
|
||||||
|
axum = { version = "0.8.8", features = ["macros"] }
|
||||||
|
tower = { version = "0.5.3", features = ["util"] }
|
||||||
|
tower-http = { version = "0.6.8", features = ["cors", "trace"] }
|
||||||
|
bigdecimal = { version = "0.4.7", features = ["serde"] }
|
||||||
|
aes-gcm = "0.10.3"
|
||||||
|
pbkdf2 = "0.12.2"
|
||||||
|
sha2 = "0.10.8"
|
||||||
|
rand = "0.8.5"
|
||||||
|
reqwest = { version = "0.13.2", default-features = false, features = ["json", "rustls"] }
|
||||||
|
tokio-modbus = { version = "0.17.0", default-features = false, features = ["tcp"] }
|
||||||
|
s7 = "0.1.2"
|
||||||
|
shared-lib = { path = "services/shared-lib" }
|
||||||
|
|
||||||
|
# ── Disk-space optimized profiles ──────────────────────────────────────────────
|
||||||
|
# These reduce the target/ directory from ~37 GB to ~5-10 GB in normal use.
|
||||||
|
|
||||||
|
[profile.dev]
|
||||||
|
# "limited" keeps backtraces but skips detailed type info → PDBs ~6x smaller.
|
||||||
|
debug = "limited"
|
||||||
|
|
||||||
|
[profile.dev.package."*"]
|
||||||
|
# Compile third-party crates with basic optimizations even in debug.
|
||||||
|
# Makes dependency .rlib files 20-40% smaller and tests run faster.
|
||||||
|
opt-level = 1
|
||||||
|
|
||||||
|
[profile.release]
|
||||||
|
# Strip all debug symbols from release binaries.
|
||||||
|
strip = true
|
||||||
|
# Thin LTO produces smaller binaries with manageable link time.
|
||||||
|
lto = "thin"
|
||||||
|
# Single codegen unit for maximum size reduction.
|
||||||
|
codegen-units = 1
|
||||||
|
# Abort on panic — removes unwinding tables (~5-10% smaller binary).
|
||||||
|
panic = "abort"
|
||||||
|
|||||||
@@ -19,24 +19,81 @@ COPY Cargo.toml Cargo.lock ./
|
|||||||
COPY services/ services/
|
COPY services/ services/
|
||||||
RUN cargo chef prepare --recipe-path recipe.json
|
RUN cargo chef prepare --recipe-path recipe.json
|
||||||
|
|
||||||
# 3. CACHER: Compila TODAS las dependencias del workspace UNA vez (con cache mounts)
|
# 3. CACHER: Compila TODAS las dependencias del workspace UNA vez
|
||||||
FROM base AS cacher
|
FROM base AS cacher
|
||||||
COPY --from=planner /app/recipe.json recipe.json
|
COPY --from=planner /app/recipe.json recipe.json
|
||||||
|
RUN --mount=type=cache,target=/usr/local/cargo/registry \
|
||||||
|
--mount=type=cache,target=/usr/local/cargo/git \
|
||||||
|
--mount=type=cache,target=/app/target \
|
||||||
|
cargo chef cook --release --recipe-path recipe.json
|
||||||
|
|
||||||
|
# 4. BUILDER-LINUX: Base para servicios Linux (rápida)
|
||||||
|
FROM base AS builder-linux
|
||||||
|
COPY Cargo.toml Cargo.lock ./
|
||||||
|
# Crear estructura de carpetas y archivos dummy
|
||||||
|
RUN mkdir -p services/backend-api/src && echo "fn main() {}" > services/backend-api/src/main.rs && \
|
||||||
|
mkdir -p services/data-collector/src && echo "fn main() {}" > services/data-collector/src/main.rs && \
|
||||||
|
mkdir -p services/json-generator/src && echo "fn main() {}" > services/json-generator/src/main.rs && \
|
||||||
|
mkdir -p services/shared-lib/src && touch services/shared-lib/src/lib.rs && \
|
||||||
|
mkdir -p services/flow-generator/src && touch services/flow-generator/src/lib.rs && \
|
||||||
|
mkdir -p services/build-orchestrator/src && echo "fn main() {}" > services/build-orchestrator/src/main.rs && \
|
||||||
|
mkdir -p services/edge-agent/src && echo "fn main() {}" > services/edge-agent/src/main.rs && \
|
||||||
|
mkdir -p services/events-relay/src && echo "fn main() {}" > services/events-relay/src/main.rs && \
|
||||||
|
mkdir -p services/telemetry-ingestor/src && echo "fn main() {}" > services/telemetry-ingestor/src/main.rs
|
||||||
|
|
||||||
|
COPY services/backend-api/Cargo.toml services/backend-api/Cargo.toml
|
||||||
|
COPY services/data-collector/Cargo.toml services/data-collector/Cargo.toml
|
||||||
|
COPY services/json-generator/Cargo.toml services/json-generator/Cargo.toml
|
||||||
|
COPY services/shared-lib/Cargo.toml services/shared-lib/Cargo.toml
|
||||||
|
COPY services/flow-generator/Cargo.toml services/flow-generator/Cargo.toml
|
||||||
|
COPY services/build-orchestrator/Cargo.toml services/build-orchestrator/Cargo.toml
|
||||||
|
COPY services/edge-agent/Cargo.toml services/edge-agent/Cargo.toml
|
||||||
|
COPY services/events-relay/Cargo.toml services/events-relay/Cargo.toml
|
||||||
|
COPY services/telemetry-ingestor/Cargo.toml services/telemetry-ingestor/Cargo.toml
|
||||||
|
|
||||||
|
# Pre-descargar todas las dependencias de forma secuencial para evitar colisiones
|
||||||
|
RUN --mount=type=cache,sharing=locked,target=/usr/local/cargo/registry \
|
||||||
|
--mount=type=cache,sharing=locked,target=/usr/local/cargo/git \
|
||||||
|
cargo fetch
|
||||||
|
|
||||||
|
# Compilar shared-lib primero
|
||||||
|
COPY services/shared-lib services/shared-lib
|
||||||
RUN --mount=type=cache,sharing=locked,target=/usr/local/cargo/registry \
|
RUN --mount=type=cache,sharing=locked,target=/usr/local/cargo/registry \
|
||||||
--mount=type=cache,sharing=locked,target=/usr/local/cargo/git \
|
--mount=type=cache,sharing=locked,target=/usr/local/cargo/git \
|
||||||
--mount=type=cache,sharing=locked,target=/app/target \
|
--mount=type=cache,sharing=locked,target=/app/target \
|
||||||
cargo chef cook --release --recipe-path recipe.json
|
cargo build --release -p shared-lib
|
||||||
|
|
||||||
# 4. BUILDER-BASE: Código + dependencias pre-compiladas + cache mounts
|
# 5. BUILDER-WINDOWS: Especializada en el agente
|
||||||
FROM base AS builder
|
FROM base AS builder-windows
|
||||||
COPY Cargo.toml Cargo.lock ./
|
|
||||||
COPY services/ services/
|
|
||||||
|
|
||||||
# COMPILACIÓN CRUZADA PARA WINDOWS (Template del agente de borde)
|
|
||||||
RUN apt-get update && apt-get install -y gcc-mingw-w64-x86-64 && rm -rf /var/lib/apt/lists/*
|
RUN apt-get update && apt-get install -y gcc-mingw-w64-x86-64 && rm -rf /var/lib/apt/lists/*
|
||||||
RUN rustup target add x86_64-pc-windows-gnu
|
RUN rustup target add x86_64-pc-windows-gnu
|
||||||
|
COPY Cargo.toml Cargo.lock ./
|
||||||
|
RUN mkdir -p services/backend-api/src && echo "fn main() {}" > services/backend-api/src/main.rs && \
|
||||||
|
mkdir -p services/data-collector/src && echo "fn main() {}" > services/data-collector/src/main.rs && \
|
||||||
|
mkdir -p services/json-generator/src && echo "fn main() {}" > services/json-generator/src/main.rs && \
|
||||||
|
mkdir -p services/shared-lib/src && touch services/shared-lib/src/lib.rs && \
|
||||||
|
mkdir -p services/flow-generator/src && touch services/flow-generator/src/lib.rs && \
|
||||||
|
mkdir -p services/build-orchestrator/src && echo "fn main() {}" > services/build-orchestrator/src/main.rs && \
|
||||||
|
mkdir -p services/edge-agent/src && echo "fn main() {}" > services/edge-agent/src/main.rs && \
|
||||||
|
mkdir -p services/events-relay/src && echo "fn main() {}" > services/events-relay/src/main.rs && \
|
||||||
|
mkdir -p services/telemetry-ingestor/src && echo "fn main() {}" > services/telemetry-ingestor/src/main.rs
|
||||||
|
|
||||||
|
COPY services/backend-api/Cargo.toml services/backend-api/Cargo.toml
|
||||||
|
COPY services/data-collector/Cargo.toml services/data-collector/Cargo.toml
|
||||||
|
COPY services/json-generator/Cargo.toml services/json-generator/Cargo.toml
|
||||||
|
COPY services/shared-lib/Cargo.toml services/shared-lib/Cargo.toml
|
||||||
|
COPY services/flow-generator/Cargo.toml services/flow-generator/Cargo.toml
|
||||||
|
COPY services/build-orchestrator/Cargo.toml services/build-orchestrator/Cargo.toml
|
||||||
|
COPY services/edge-agent/Cargo.toml services/edge-agent/Cargo.toml
|
||||||
|
COPY services/events-relay/Cargo.toml services/events-relay/Cargo.toml
|
||||||
|
COPY services/telemetry-ingestor/Cargo.toml services/telemetry-ingestor/Cargo.toml
|
||||||
|
|
||||||
|
RUN --mount=type=cache,sharing=locked,target=/usr/local/cargo/registry \
|
||||||
|
--mount=type=cache,sharing=locked,target=/usr/local/cargo/git \
|
||||||
|
cargo fetch
|
||||||
|
|
||||||
|
COPY services/shared-lib services/shared-lib
|
||||||
|
COPY services/edge-agent services/edge-agent
|
||||||
RUN --mount=type=cache,sharing=locked,target=/usr/local/cargo/registry \
|
RUN --mount=type=cache,sharing=locked,target=/usr/local/cargo/registry \
|
||||||
--mount=type=cache,sharing=locked,target=/usr/local/cargo/git \
|
--mount=type=cache,sharing=locked,target=/usr/local/cargo/git \
|
||||||
--mount=type=cache,sharing=locked,target=/app/target \
|
--mount=type=cache,sharing=locked,target=/app/target \
|
||||||
@@ -44,11 +101,12 @@ RUN --mount=type=cache,sharing=locked,target=/usr/local/cargo/registry \
|
|||||||
mkdir -p /app/bin/windows && cp target/x86_64-pc-windows-gnu/release/edge-agent.exe /app/bin/windows/
|
mkdir -p /app/bin/windows && cp target/x86_64-pc-windows-gnu/release/edge-agent.exe /app/bin/windows/
|
||||||
|
|
||||||
# =============================================================================
|
# =============================================================================
|
||||||
# INDIVIDUAL SERVICE TARGETS (con cache mounts para incremental)
|
# INDIVIDUAL SERVICE TARGETS
|
||||||
# =============================================================================
|
# =============================================================================
|
||||||
|
|
||||||
# --- BACKEND-API ---
|
# --- BACKEND-API ---
|
||||||
FROM builder AS builder-backend-api
|
FROM builder-linux AS builder-backend-api
|
||||||
|
COPY services/backend-api services/backend-api
|
||||||
RUN --mount=type=cache,sharing=locked,target=/usr/local/cargo/registry \
|
RUN --mount=type=cache,sharing=locked,target=/usr/local/cargo/registry \
|
||||||
--mount=type=cache,sharing=locked,target=/usr/local/cargo/git \
|
--mount=type=cache,sharing=locked,target=/usr/local/cargo/git \
|
||||||
--mount=type=cache,sharing=locked,target=/app/target \
|
--mount=type=cache,sharing=locked,target=/app/target \
|
||||||
@@ -64,7 +122,8 @@ EXPOSE 8000
|
|||||||
CMD ["./backend-api"]
|
CMD ["./backend-api"]
|
||||||
|
|
||||||
# --- DATA-COLLECTOR ---
|
# --- DATA-COLLECTOR ---
|
||||||
FROM builder AS builder-data-collector
|
FROM builder-linux AS builder-data-collector
|
||||||
|
COPY services/data-collector services/data-collector
|
||||||
RUN --mount=type=cache,sharing=locked,target=/usr/local/cargo/registry \
|
RUN --mount=type=cache,sharing=locked,target=/usr/local/cargo/registry \
|
||||||
--mount=type=cache,sharing=locked,target=/usr/local/cargo/git \
|
--mount=type=cache,sharing=locked,target=/usr/local/cargo/git \
|
||||||
--mount=type=cache,sharing=locked,target=/app/target \
|
--mount=type=cache,sharing=locked,target=/app/target \
|
||||||
@@ -79,7 +138,8 @@ ENV RUST_LOG=info
|
|||||||
CMD ["./data-collector"]
|
CMD ["./data-collector"]
|
||||||
|
|
||||||
# --- JSON-GENERATOR ---
|
# --- JSON-GENERATOR ---
|
||||||
FROM builder AS builder-json-generator
|
FROM builder-linux AS builder-json-generator
|
||||||
|
COPY services/json-generator services/json-generator
|
||||||
RUN --mount=type=cache,sharing=locked,target=/usr/local/cargo/registry \
|
RUN --mount=type=cache,sharing=locked,target=/usr/local/cargo/registry \
|
||||||
--mount=type=cache,sharing=locked,target=/usr/local/cargo/git \
|
--mount=type=cache,sharing=locked,target=/usr/local/cargo/git \
|
||||||
--mount=type=cache,sharing=locked,target=/app/target \
|
--mount=type=cache,sharing=locked,target=/app/target \
|
||||||
@@ -94,7 +154,8 @@ ENV RUST_LOG=info
|
|||||||
CMD ["./json-generator"]
|
CMD ["./json-generator"]
|
||||||
|
|
||||||
# --- TELEMETRY-INGESTOR ---
|
# --- TELEMETRY-INGESTOR ---
|
||||||
FROM builder AS builder-telemetry-ingestor
|
FROM builder-linux AS builder-telemetry-ingestor
|
||||||
|
COPY services/telemetry-ingestor services/telemetry-ingestor
|
||||||
RUN --mount=type=cache,sharing=locked,target=/usr/local/cargo/registry \
|
RUN --mount=type=cache,sharing=locked,target=/usr/local/cargo/registry \
|
||||||
--mount=type=cache,sharing=locked,target=/usr/local/cargo/git \
|
--mount=type=cache,sharing=locked,target=/usr/local/cargo/git \
|
||||||
--mount=type=cache,sharing=locked,target=/app/target \
|
--mount=type=cache,sharing=locked,target=/app/target \
|
||||||
@@ -109,7 +170,8 @@ ENV RUST_LOG=info
|
|||||||
CMD ["./telemetry-ingestor"]
|
CMD ["./telemetry-ingestor"]
|
||||||
|
|
||||||
# --- EVENTS-RELAY ---
|
# --- EVENTS-RELAY ---
|
||||||
FROM builder AS builder-events-relay
|
FROM builder-linux AS builder-events-relay
|
||||||
|
COPY services/events-relay services/events-relay
|
||||||
RUN --mount=type=cache,sharing=locked,target=/usr/local/cargo/registry \
|
RUN --mount=type=cache,sharing=locked,target=/usr/local/cargo/registry \
|
||||||
--mount=type=cache,sharing=locked,target=/usr/local/cargo/git \
|
--mount=type=cache,sharing=locked,target=/usr/local/cargo/git \
|
||||||
--mount=type=cache,sharing=locked,target=/app/target \
|
--mount=type=cache,sharing=locked,target=/app/target \
|
||||||
@@ -124,7 +186,10 @@ ENV RUST_LOG=info
|
|||||||
CMD ["./events-relay"]
|
CMD ["./events-relay"]
|
||||||
|
|
||||||
# --- BUILD-ORCHESTRATOR ---
|
# --- BUILD-ORCHESTRATOR ---
|
||||||
FROM builder AS builder-build-orchestrator
|
FROM builder-linux AS builder-build-orchestrator
|
||||||
|
# El orquestador necesita las plantillas de flow-generator también
|
||||||
|
COPY services/flow-generator services/flow-generator
|
||||||
|
COPY services/build-orchestrator services/build-orchestrator
|
||||||
RUN --mount=type=cache,sharing=locked,target=/usr/local/cargo/registry \
|
RUN --mount=type=cache,sharing=locked,target=/usr/local/cargo/registry \
|
||||||
--mount=type=cache,sharing=locked,target=/usr/local/cargo/git \
|
--mount=type=cache,sharing=locked,target=/usr/local/cargo/git \
|
||||||
--mount=type=cache,sharing=locked,target=/app/target \
|
--mount=type=cache,sharing=locked,target=/app/target \
|
||||||
@@ -138,11 +203,11 @@ RUN apt-get update && apt-get install -y libssl3 ca-certificates mosquitto-clien
|
|||||||
# Binario del orquestador
|
# Binario del orquestador
|
||||||
COPY --from=builder-build-orchestrator /app/build-orchestrator .
|
COPY --from=builder-build-orchestrator /app/build-orchestrator .
|
||||||
|
|
||||||
# Template del agente (NECESARIO PARA EL OVERLAY)
|
# Template del agente (Desde la etapa builder-windows)
|
||||||
RUN mkdir -p target/x86_64-pc-windows-gnu/release
|
RUN mkdir -p target/x86_64-pc-windows-gnu/release
|
||||||
COPY --from=builder /app/bin/windows/edge-agent.exe target/x86_64-pc-windows-gnu/release/
|
COPY --from=builder-windows /app/bin/windows/edge-agent.exe target/x86_64-pc-windows-gnu/release/
|
||||||
|
|
||||||
# Cargo.toml del agente (PARA EXTRACTAR VERSIÓN EN RUNTIME)
|
# Cargo.toml del agente
|
||||||
RUN mkdir -p services/edge-agent
|
RUN mkdir -p services/edge-agent
|
||||||
COPY services/edge-agent/Cargo.toml services/edge-agent/Cargo.toml
|
COPY services/edge-agent/Cargo.toml services/edge-agent/Cargo.toml
|
||||||
|
|
||||||
|
|||||||
@@ -22,11 +22,6 @@ services:
|
|||||||
ports:
|
ports:
|
||||||
- "8000:8000"
|
- "8000:8000"
|
||||||
|
|
||||||
# Exponer el Registry para inspección opcional
|
|
||||||
docker-registry:
|
|
||||||
ports:
|
|
||||||
- "5000:5000"
|
|
||||||
|
|
||||||
# Exponer frontend para ver la UI localmente
|
# Exponer frontend para ver la UI localmente
|
||||||
frontend-admin:
|
frontend-admin:
|
||||||
build:
|
build:
|
||||||
@@ -47,3 +42,15 @@ services:
|
|||||||
mosquitto:
|
mosquitto:
|
||||||
ports:
|
ports:
|
||||||
- "1883:1883"
|
- "1883:1883"
|
||||||
|
- "9883:9883"
|
||||||
|
|
||||||
|
# Desarrollo: Sincronizar versión y scripts del instalador
|
||||||
|
build-orchestrator:
|
||||||
|
volumes:
|
||||||
|
- ./services/edge-agent/Cargo.toml:/app/services/edge-agent/Cargo.toml
|
||||||
|
|
||||||
|
edge-agent-msi-builder:
|
||||||
|
volumes:
|
||||||
|
- ./services/edge-agent/Cargo.toml:/app/services/edge-agent/Cargo.toml
|
||||||
|
- ./services/edge-agent/wix:/app/services/edge-agent/wix
|
||||||
|
- ./services/edge-agent/msi_worker.sh:/app/services/edge-agent/msi_worker.sh
|
||||||
|
|||||||
@@ -28,7 +28,7 @@ services:
|
|||||||
image: redis:8.6.2-alpine
|
image: redis:8.6.2-alpine
|
||||||
container_name: anh_redis
|
container_name: anh_redis
|
||||||
restart: always
|
restart: always
|
||||||
command: redis-server --requirepass ${REDIS_PASSWORD} --appendonly yes
|
command: [ "redis-server", "--requirepass", "${REDIS_PASSWORD}", "--appendonly", "yes" ]
|
||||||
volumes:
|
volumes:
|
||||||
- ${REDIS_DATA:-redis_data}:/data
|
- ${REDIS_DATA:-redis_data}:/data
|
||||||
networks:
|
networks:
|
||||||
@@ -65,13 +65,19 @@ services:
|
|||||||
- MQTT_USER=${MQTT_USER}
|
- MQTT_USER=${MQTT_USER}
|
||||||
- MQTT_PASSWORD=${MQTT_PASSWORD}
|
- MQTT_PASSWORD=${MQTT_PASSWORD}
|
||||||
- REDIS_URL=redis://:${REDIS_PASSWORD}@redis:6379
|
- REDIS_URL=redis://:${REDIS_PASSWORD}@redis:6379
|
||||||
ports:
|
- MINIO_ENDPOINT_URL=${MINIO_ENDPOINT_URL}
|
||||||
- "8000:8000"
|
- AWS_ACCESS_KEY_ID=${MINIO_USER}
|
||||||
|
- AWS_SECRET_ACCESS_KEY=${MINIO_PASSWORD}
|
||||||
|
- ALLOWED_ORIGINS=${ALLOWED_ORIGINS}
|
||||||
|
expose:
|
||||||
|
- "8000"
|
||||||
depends_on:
|
depends_on:
|
||||||
timescaledb:
|
timescaledb:
|
||||||
condition: service_healthy
|
condition: service_healthy
|
||||||
redis:
|
redis:
|
||||||
condition: service_started
|
condition: service_started
|
||||||
|
mosquitto:
|
||||||
|
condition: service_started
|
||||||
networks:
|
networks:
|
||||||
- anh_network
|
- anh_network
|
||||||
healthcheck:
|
healthcheck:
|
||||||
@@ -129,7 +135,9 @@ services:
|
|||||||
target: json-generator
|
target: json-generator
|
||||||
environment:
|
environment:
|
||||||
- DATABASE_URL=postgres://${DB_USER}:${DB_PASSWORD}@timescaledb:5432/${DB_NAME}
|
- DATABASE_URL=postgres://${DB_USER}:${DB_PASSWORD}@timescaledb:5432/${DB_NAME}
|
||||||
- MINIO_ENDPOINT=http://minio:9000
|
- MINIO_ENDPOINT=${MINIO_ENDPOINT}
|
||||||
|
- AWS_ACCESS_KEY_ID=${MINIO_USER}
|
||||||
|
- AWS_SECRET_ACCESS_KEY=${MINIO_PASSWORD}
|
||||||
depends_on:
|
depends_on:
|
||||||
backend-api:
|
backend-api:
|
||||||
condition: service_healthy
|
condition: service_healthy
|
||||||
@@ -154,6 +162,9 @@ services:
|
|||||||
dockerfile: services/frontend-pwa/Dockerfile
|
dockerfile: services/frontend-pwa/Dockerfile
|
||||||
args:
|
args:
|
||||||
- VITE_API_BASE=/api
|
- VITE_API_BASE=/api
|
||||||
|
- VITE_ADMIN_APP_ORIGIN=${VITE_ADMIN_APP_ORIGIN}
|
||||||
|
- VITE_MOBILE_APP_ORIGIN=${VITE_MOBILE_APP_ORIGIN}
|
||||||
|
- VITE_API_URL=${VITE_API_URL}
|
||||||
depends_on:
|
depends_on:
|
||||||
- backend-api
|
- backend-api
|
||||||
networks:
|
networks:
|
||||||
@@ -167,6 +178,9 @@ services:
|
|||||||
dockerfile: Dockerfile
|
dockerfile: Dockerfile
|
||||||
args:
|
args:
|
||||||
- VITE_API_BASE=/api
|
- VITE_API_BASE=/api
|
||||||
|
- VITE_ADMIN_APP_ORIGIN=${VITE_ADMIN_APP_ORIGIN}
|
||||||
|
- VITE_MOBILE_APP_ORIGIN=${VITE_MOBILE_APP_ORIGIN}
|
||||||
|
- VITE_API_URL=${VITE_API_URL}
|
||||||
depends_on:
|
depends_on:
|
||||||
- backend-api
|
- backend-api
|
||||||
networks:
|
networks:
|
||||||
@@ -179,7 +193,7 @@ services:
|
|||||||
context: ./infrastructure/mosquitto
|
context: ./infrastructure/mosquitto
|
||||||
dockerfile: Dockerfile
|
dockerfile: Dockerfile
|
||||||
environment:
|
environment:
|
||||||
- MOSQUITTO_GO_AUTH=true
|
- MOSQUITTO_GO_AUTH=${MOSQUITTO_GO_AUTH:-true}
|
||||||
- POSTGRES_HOST=timescaledb
|
- POSTGRES_HOST=timescaledb
|
||||||
- POSTGRES_USER=${DB_USER}
|
- POSTGRES_USER=${DB_USER}
|
||||||
- POSTGRES_PASSWORD=${DB_PASSWORD}
|
- POSTGRES_PASSWORD=${DB_PASSWORD}
|
||||||
@@ -190,23 +204,9 @@ services:
|
|||||||
networks:
|
networks:
|
||||||
- anh_network
|
- anh_network
|
||||||
depends_on:
|
depends_on:
|
||||||
backend-api:
|
timescaledb:
|
||||||
condition: service_healthy
|
condition: service_healthy
|
||||||
|
|
||||||
docker-registry:
|
|
||||||
image: registry:3.0.0
|
|
||||||
container_name: anh_docker_registry
|
|
||||||
restart: always
|
|
||||||
expose:
|
|
||||||
- "5000"
|
|
||||||
environment:
|
|
||||||
REGISTRY_STORAGE_DELETE_ENABLED: "true"
|
|
||||||
OTEL_TRACES_EXPORTER: "none"
|
|
||||||
networks:
|
|
||||||
- anh_network
|
|
||||||
volumes:
|
|
||||||
- ${REGISTRY_DATA:-registry_data}:/var/lib/registry
|
|
||||||
|
|
||||||
build-orchestrator:
|
build-orchestrator:
|
||||||
container_name: anh_build_orchestrator
|
container_name: anh_build_orchestrator
|
||||||
build:
|
build:
|
||||||
@@ -219,16 +219,18 @@ services:
|
|||||||
- MQTT_PORT=1883
|
- MQTT_PORT=1883
|
||||||
- MQTT_USER=${MQTT_USER}
|
- MQTT_USER=${MQTT_USER}
|
||||||
- MQTT_PASSWORD=${MQTT_PASSWORD}
|
- MQTT_PASSWORD=${MQTT_PASSWORD}
|
||||||
- DOCKER_REGISTRY_URL=docker-registry:5000
|
- MINIO_ENDPOINT_URL=${MINIO_ENDPOINT_URL}
|
||||||
- MINIO_ENDPOINT_URL=http://minio:9000
|
|
||||||
- AWS_ACCESS_KEY_ID=${MINIO_USER}
|
- AWS_ACCESS_KEY_ID=${MINIO_USER}
|
||||||
- AWS_SECRET_ACCESS_KEY=${MINIO_PASSWORD}
|
- AWS_SECRET_ACCESS_KEY=${MINIO_PASSWORD}
|
||||||
- AWS_REGION=us-east-1
|
- AWS_REGION=${AWS_REGION}
|
||||||
- AWS_EC2_METADATA_DISABLED=true
|
- AWS_EC2_METADATA_DISABLED=${AWS_EC2_METADATA_DISABLED:-true}
|
||||||
- BACKEND_API_URL=${BACKEND_API_URL}
|
- BACKEND_API_URL=${BACKEND_API_URL}
|
||||||
- MQTT_PUBLIC_HOST=${MQTT_PUBLIC_HOST}
|
- MQTT_PUBLIC_HOST=${MQTT_PUBLIC_HOST}
|
||||||
|
- MQTT_PUBLIC_PORT=${MQTT_PUBLIC_PORT:-9883}
|
||||||
|
- MQTT_USE_WS=${MQTT_USE_WS:-true}
|
||||||
|
- OMNIOIL_MASTER_SECRET=${OMNIOIL_MASTER_SECRET}
|
||||||
|
- S3_INSTALLERS_BUCKET=${S3_INSTALLERS_BUCKET:-installers}
|
||||||
volumes:
|
volumes:
|
||||||
- /var/run/docker.sock:/var/run/docker.sock
|
|
||||||
- ./infrastructure/mosquitto:/app/infrastructure/mosquitto
|
- ./infrastructure/mosquitto:/app/infrastructure/mosquitto
|
||||||
- ${INSTALLER_INCOMING:-installer_incoming}:/app/services/edge-agent/incoming
|
- ${INSTALLER_INCOMING:-installer_incoming}:/app/services/edge-agent/incoming
|
||||||
- ${INSTALLER_OUTPUT:-installer_output}:/app/services/edge-agent/output
|
- ${INSTALLER_OUTPUT:-installer_output}:/app/services/edge-agent/output
|
||||||
@@ -262,6 +264,5 @@ volumes:
|
|||||||
minio_data:
|
minio_data:
|
||||||
mosquitto_data:
|
mosquitto_data:
|
||||||
mosquitto_log:
|
mosquitto_log:
|
||||||
registry_data:
|
|
||||||
installer_incoming:
|
installer_incoming:
|
||||||
installer_output:
|
installer_output:
|
||||||
|
|||||||
32
docs/ANH_COMPLIANCE_GUIDE.md
Normal file
32
docs/ANH_COMPLIANCE_GUIDE.md
Normal file
@@ -0,0 +1,32 @@
|
|||||||
|
# Manual de Cumplimiento Normativo ANH (Res. 0651)
|
||||||
|
|
||||||
|
Este documento resume las capacidades de OmniOil para cumplir con las exigencias de la Agencia Nacional de Hidrocarburos.
|
||||||
|
|
||||||
|
## 1. Integridad de Datos (Inalterabilidad)
|
||||||
|
* **Hash SHA-256**: Cada reporte diario JSON generado incluye un hash de seguridad en su encabezado. Cualquier cambio en los datos después de la generación invalidará el hash, permitiendo detectar alteraciones.
|
||||||
|
* **Store & Forward**: El Edge Agent asegura que los datos no se pierdan durante caídas de internet, almacenándolos localmente y sincronizándolos con su marca de tiempo original (TS).
|
||||||
|
|
||||||
|
## 2. Gestión de Excepciones y Alarmas
|
||||||
|
* **Detección Automática**: El sistema monitorea variables críticas en tiempo real.
|
||||||
|
* **Registro de Alarmas**: Cualquier anomalía se guarda en la tabla `telemetry_alarms` y se incluye en la sección `exceptions` del reporte diario JSON.
|
||||||
|
|
||||||
|
## 3. Notificaciones Proactivas (Watchdog)
|
||||||
|
* **Pérdida de Telemetría**: El sistema notifica vía Email/Telegram si un proyecto activo deja de enviar datos por más de 10 minutos.
|
||||||
|
* **Estado de Agentes**: Se notifica si un Agente Edge pierde comunicación (Heartbeat).
|
||||||
|
|
||||||
|
## 4. Trazabilidad de Datos Manuales (Audit Trail)
|
||||||
|
* Todo dato ingresado manualmente (Movimientos, Pruebas de Pozo, Laboratorio) queda registrado en el `audit_log`.
|
||||||
|
* Se almacena: Usuario, Fecha, IP, Valor Anterior y Valor Nuevo.
|
||||||
|
|
||||||
|
## 5. Metrología y Certificación
|
||||||
|
* **Trazabilidad de Activos**: Cada activo (pozo, medidor, tanque) cuenta con campos para:
|
||||||
|
* Última fecha de calibración.
|
||||||
|
* Próxima fecha de calibración.
|
||||||
|
* Link al certificado digital.
|
||||||
|
* **Ubicación Geográfica**: Los reportes incluyen coordenadas (Lat/Long), Departamento y Municipio para una georreferenciación exacta.
|
||||||
|
|
||||||
|
## 6. Seguridad de Acceso
|
||||||
|
* **2FA (Segundo Factor)**: Soporte nativo para Google Authenticator, asegurando que solo personal autorizado pueda realizar cambios críticos en la configuración o datos operativos.
|
||||||
|
|
||||||
|
---
|
||||||
|
*Documento actualizado al: 2026-04-18*
|
||||||
@@ -88,6 +88,20 @@ MQTT_PUBLIC_HOST=mqtt.omnioil.app
|
|||||||
# --- FRONTEND ---
|
# --- FRONTEND ---
|
||||||
VITE_API_BASE=https://api.omnioil.app
|
VITE_API_BASE=https://api.omnioil.app
|
||||||
|
|
||||||
|
# --- SISTEMA DE NOTIFICACIONES & ALERTAS (ANH) ---
|
||||||
|
SMTP_HOST=smtp.gmail.com
|
||||||
|
SMTP_PORT=465
|
||||||
|
SMTP_USER=tu_correo@gmail.com
|
||||||
|
SMTP_PASS=tu_clave_de_aplicacion
|
||||||
|
SMTP_FROM=alertas@omnioil.app
|
||||||
|
|
||||||
|
TELEGRAM_BOT_TOKEN=7890123456:ABC-defGHijKLmnoPQRstUVwxyZ
|
||||||
|
TELEGRAM_CHAT_ID=-100123456789
|
||||||
|
|
||||||
|
# --- MONITOREO & WATCHDOG ---
|
||||||
|
WATCHDOG_CHECK_INTERVAL_SEC=300
|
||||||
|
WATCHDOG_SILENCE_THRESHOLD_MIN=15
|
||||||
|
|
||||||
# --- VOLUMENES (Producción: Named Volumes) ---
|
# --- VOLUMENES (Producción: Named Volumes) ---
|
||||||
# Dejar estas variables vacías o no definidas para usar volúmenes nombrados de alto rendimiento
|
# Dejar estas variables vacías o no definidas para usar volúmenes nombrados de alto rendimiento
|
||||||
TIMESCALEDB_DATA=
|
TIMESCALEDB_DATA=
|
||||||
@@ -123,6 +137,7 @@ OmniOil uses a unified multi-stage build pipeline with **Cargo Chef** to drastic
|
|||||||
- [ ] Active **Let's Encrypt** SSL in Dokploy for all public domains.
|
- [ ] Active **Let's Encrypt** SSL in Dokploy for all public domains.
|
||||||
- [ ] Ensure `timescaledb` and `redis` ports are NOT mapped publicly in the UI.
|
- [ ] Ensure `timescaledb` and `redis` ports are NOT mapped publicly in the UI.
|
||||||
- [ ] Verify `VITE_API_BASE` matches the actual API subdomain.
|
- [ ] Verify `VITE_API_BASE` matches the actual API subdomain.
|
||||||
|
- [ ] Configured `SMTP_*` and `TELEGRAM_*` credentials for ANH automated alerting.
|
||||||
- [ ] Switched to `native-tls` and `aws_lc_rs` for maximum stability.
|
- [ ] Switched to `native-tls` and `aws_lc_rs` for maximum stability.
|
||||||
|
|
||||||
## 7. Canonical Frontend Domain Map
|
## 7. Canonical Frontend Domain Map
|
||||||
|
|||||||
51
docs/DisposicionFinal.md
Normal file
51
docs/DisposicionFinal.md
Normal file
@@ -0,0 +1,51 @@
|
|||||||
|
# Arquitectura de Archivado y Data Tiering
|
||||||
|
|
||||||
|
Para cumplir con la retención indefinida de la ANH sin saturar la base de datos operativa, implementamos una estrategia de **Data Tiering**.
|
||||||
|
|
||||||
|
## 1. Niveles de Datos (Tiers)
|
||||||
|
|
||||||
|
| Nivel | Almacenamiento | Retención | Objetivo |
|
||||||
|
| :--- | :--- | :--- | :--- |
|
||||||
|
| **Hot** | TimescaleDB | 6 Meses | Telemetría en tiempo real y reportes diarios activos. |
|
||||||
|
| **Warm** | MinIO (S3 Local) | 2 Años | Datos históricos comprimidos en formato Parquet o Gzipped JSON. |
|
||||||
|
| **Cold** | Google Drive | Indefinido | Backup final y archivado de largo plazo (Cumplimiento ANH). |
|
||||||
|
|
||||||
|
## 2. Automatización con Rclone (Docker)
|
||||||
|
|
||||||
|
Para sincronizar MinIO con Google Drive de forma sencilla, utilizaremos el contenedor oficial de `rclone`.
|
||||||
|
|
||||||
|
### Configuración sugerida (`docker-compose.backup.yml`)
|
||||||
|
|
||||||
|
```yaml
|
||||||
|
services:
|
||||||
|
rclone-backup:
|
||||||
|
image: rclone/rclone:latest
|
||||||
|
container_name: omnioil-backup-cloud
|
||||||
|
restart: unless-stopped
|
||||||
|
volumes:
|
||||||
|
- ./infra/rclone/config:/config/rclone
|
||||||
|
- ./infra/minio/data:/data:ro
|
||||||
|
environment:
|
||||||
|
- RCLONE_CONFIG_GDRIVE_TYPE=drive
|
||||||
|
- RCLONE_CONFIG_GDRIVE_SCOPE=drive.file
|
||||||
|
# El token se genera una vez mediante 'rclone config'
|
||||||
|
command: >
|
||||||
|
sync /data gdrive:OmniOil_Backups
|
||||||
|
--verbose
|
||||||
|
--checkers 4
|
||||||
|
--transfers 4
|
||||||
|
--cron "0 2 * * *"
|
||||||
|
```
|
||||||
|
|
||||||
|
## 3. Proceso de Limpieza (Purge) en Rust
|
||||||
|
|
||||||
|
Añadiremos una tarea en `backend-api` que:
|
||||||
|
1. Cada semana, busque datos de `telemetry_raw` con más de 180 días.
|
||||||
|
2. Los exporte a un archivo `.json.gz`.
|
||||||
|
3. Suba el archivo a MinIO (Bucket `history`).
|
||||||
|
4. Elimine los registros de PostgreSQL para liberar espacio.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
> [!TIP]
|
||||||
|
> **Ventaja de Google Drive**: Al usar tu cuenta de pago con espacio ilimitado/amplio, el costo de cumplimiento de la ANH se reduce a casi cero.
|
||||||
@@ -17,6 +17,8 @@ erDiagram
|
|||||||
MQTT_USERS ||--o{ MQTT_ACLS : "permisos"
|
MQTT_USERS ||--o{ MQTT_ACLS : "permisos"
|
||||||
EDGE_PROJECTS ||--o{ AGENT_LOGS : "genera"
|
EDGE_PROJECTS ||--o{ AGENT_LOGS : "genera"
|
||||||
EDGE_PROJECTS ||--o{ TELEMETRY_RAW : "registra historial"
|
EDGE_PROJECTS ||--o{ TELEMETRY_RAW : "registra historial"
|
||||||
|
EDGE_PROJECTS ||--o{ TELEMETRY_ALARMS : "genera alertas"
|
||||||
|
EDGE_PROJECTS ||--o{ AUDIT_LOGS : "traza cambios"
|
||||||
|
|
||||||
EDGE_PROJECTS {
|
EDGE_PROJECTS {
|
||||||
uuid id PK
|
uuid id PK
|
||||||
@@ -44,7 +46,7 @@ erDiagram
|
|||||||
Base URL: `http://localhost:3000/api`
|
Base URL: `http://localhost:3000/api`
|
||||||
|
|
||||||
### Auth & Usuarios
|
### Auth & Usuarios
|
||||||
- `POST /auth/login`: Autenticación JWT.
|
- `POST /auth/login`: Autenticación JWT. Soporta **2FA (TOTP)** si está habilitado mediante el campo `two_factor_code`.
|
||||||
- `GET /auth/me`: Verificación de sesión.
|
- `GET /auth/me`: Verificación de sesión.
|
||||||
|
|
||||||
### Gestión de Agentes (Edge)
|
### Gestión de Agentes (Edge)
|
||||||
@@ -61,6 +63,7 @@ Base URL: `http://localhost:3000/api`
|
|||||||
### Telemetría & Logs
|
### Telemetría & Logs
|
||||||
- `GET /telemetry/history/:project_id`: Consulta de datos históricos (Cold Path).
|
- `GET /telemetry/history/:project_id`: Consulta de datos históricos (Cold Path).
|
||||||
- `GET /edge/projects/:id/logs`: Consulta de logs de salud y eventos MQTT.
|
- `GET /edge/projects/:id/logs`: Consulta de logs de salud y eventos MQTT.
|
||||||
|
- `GET /audit/logs?project_id=...`: Trazabilidad de cambios manuales (Cumplimiento ANH).
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -95,5 +98,6 @@ Para reportes legales o gráficas de largo plazo (días/meses):
|
|||||||
|
|
||||||
## 5. Recomendaciones de Implementación
|
## 5. Recomendaciones de Implementación
|
||||||
1. **Cache de Salud**: El frontend debe considerar a un agente como "Offline" si no recibe un mensaje en `omnioil/health` durante más de 60 segundos.
|
1. **Cache de Salud**: El frontend debe considerar a un agente como "Offline" si no recibe un mensaje en `omnioil/health` durante más de 60 segundos.
|
||||||
2. **Seguridad**: Nunca exponer el token de la base de datos directo; siempre pasar por el proxy de la API REST.
|
2. **Seguridad**: Nunca exponer el token de la base de datos directo; siempre pasar por el proxy de la API REST. Requiere 2FA para roles con privilegios de escritura.
|
||||||
3. **Gráficas**: Usar librerías que soporten "Stream Data" (como Chart.js con streaming plugin o Recharts) para procesar los mensajes MQTT sin refrescar la página.
|
3. **Auditoría**: Cualquier cambio en activos o movimientos operativos es trazado en `audit_logs` según Resolución ANH 0651.
|
||||||
|
4. **Gráficas**: Usar librerías que soporten "Stream Data" (como Chart.js con streaming plugin o Recharts) para procesar los mensajes MQTT sin refrescar la página.
|
||||||
|
|||||||
52
docs/REPORTE_CORRECCION_AUTH_MOBILE.md
Normal file
52
docs/REPORTE_CORRECCION_AUTH_MOBILE.md
Normal file
@@ -0,0 +1,52 @@
|
|||||||
|
# Reporte de correccion auth mobile y recuperacion asociada
|
||||||
|
|
||||||
|
## 1. Contexto del problema
|
||||||
|
Durante la recuperacion del proyecto se detecto que la superficie mobile (PWA de campo) estaba mezclando responsabilidades de autenticacion y navegacion con la superficie administrativa. Esto generaba inconsistencias en el acceso, confusion para usuarios de campo y una exposicion indebida del flujo de registro publico desde mobile.
|
||||||
|
|
||||||
|
## 2. Que estaba ocurriendo
|
||||||
|
- La PWA mobile mostraba y exponia un flujo de registro publico (`/register`) que no correspondia al modelo operativo real.
|
||||||
|
- El backend todavia aceptaba el endpoint de registro publico, en lugar de bloquearlo de forma explicita.
|
||||||
|
- La experiencia de login en mobile todavia arrastraba señales visuales y de navegacion propias del entorno admin.
|
||||||
|
- La separacion entre `app.omnioil.app` (admin) y `mobile.omnioil.app` (campo) no estaba completamente resuelta, incluyendo redirecciones y recuperacion de sesion.
|
||||||
|
|
||||||
|
## 3. Que se diagnostico
|
||||||
|
- El registro publico no debia existir en mobile: el alta de usuarios de campo debe quedar bajo provisionamiento administrado.
|
||||||
|
- Habia que reforzar la separacion de superficies entre admin y mobile tanto a nivel UI como a nivel routing/autorizacion.
|
||||||
|
- La recuperacion de sesion necesitaba cubrir mejor el arranque de la PWA para evitar estados en blanco o redirecciones inconsistentes.
|
||||||
|
- El bloqueo del registro debia hacerse en dos capas: quitando la entrada desde frontend y devolviendo una respuesta formal desde backend.
|
||||||
|
|
||||||
|
## 4. Que se corrigio
|
||||||
|
- Se deshabilito el registro publico en mobile.
|
||||||
|
- Se elimino la ruta `/register` de la PWA y se retiro la pantalla/formulario asociado.
|
||||||
|
- El login mobile dejo de invitar al auto-registro y ahora indica que el alta debe solicitarse a un administrador.
|
||||||
|
- El backend ahora responde `403 Forbidden` cuando se intenta usar el registro publico, con un mensaje explicito indicando que el alta publica esta deshabilitada.
|
||||||
|
- Se consolido la separacion de superficies entre admin y mobile:
|
||||||
|
- mobile queda orientado a operadores/cuadrillas y tareas de campo;
|
||||||
|
- admin queda como superficie administrativa independiente.
|
||||||
|
- Se fortalecio la resolucion de rutas segun rol y dominio para redirigir al entorno correcto.
|
||||||
|
- Se mejoro la recuperacion de sesion/bootstrapping para que la app no quede en blanco mientras valida el acceso.
|
||||||
|
|
||||||
|
## 5. Validaciones realizadas
|
||||||
|
- Validacion local de los flujos de autenticacion y navegacion por superficie.
|
||||||
|
- Validacion en runtime de:
|
||||||
|
- redireccion por dominio/rol;
|
||||||
|
- recuperacion de sesion al iniciar la PWA;
|
||||||
|
- manejo de sesiones expiradas;
|
||||||
|
- bloqueo del registro publico.
|
||||||
|
- Cobertura automatizada agregada/ajustada para:
|
||||||
|
- respuesta `403` del backend al registro deshabilitado;
|
||||||
|
- identidad visual y mensajes exclusivos de mobile;
|
||||||
|
- redirecciones entre superficie admin y superficie mobile;
|
||||||
|
- bootstrap de sesion y manejo de refresh.
|
||||||
|
|
||||||
|
## 6. Estado final
|
||||||
|
- El registro publico en mobile quedo deshabilitado.
|
||||||
|
- La PWA mobile ya no expone una via de alta publica.
|
||||||
|
- El backend protege el endpoint de registro devolviendo `403`.
|
||||||
|
- La separacion entre superficie administrativa y superficie mobile quedo implementada de forma mas consistente.
|
||||||
|
- El flujo de login/recuperacion de sesion en mobile quedo estabilizado dentro del trabajo de recuperacion del proyecto.
|
||||||
|
|
||||||
|
## 7. Notas y riesgos pendientes
|
||||||
|
- Si existieran clientes antiguos cacheados apuntando a `/register`, recibiran el nuevo comportamiento bloqueado y deberian actualizarse.
|
||||||
|
- El alta de usuarios depende ahora completamente del flujo administrado, por lo que conviene mantener documentado ese procedimiento operativo.
|
||||||
|
- Como siguiente control recomendable, conviene revisar periodicamente que no reaparezcan accesos cruzados entre superficies en futuros cambios de auth o routing.
|
||||||
64
docs/SECURITY_AUDIT_REPORT.md
Normal file
64
docs/SECURITY_AUDIT_REPORT.md
Normal file
@@ -0,0 +1,64 @@
|
|||||||
|
# 🛡️ Reporte de Auditoría de Seguridad: Ecosistema OmniOil
|
||||||
|
|
||||||
|
Este reporte detalla los hallazgos de seguridad encontrados tras analizar la infraestructura de contenedores, la configuración de red y la arquitectura de servicios de OmniOil.
|
||||||
|
|
||||||
|
## 1. Hallazgos Críticos 🚨
|
||||||
|
|
||||||
|
### A. Exposición del Docker Socket (`High Risk`)
|
||||||
|
- **Hallazgo**: El servicio `build-orchestrator` monta el volumen `/var/run/docker.sock:/var/run/docker.sock`.
|
||||||
|
- **Impacto**: Cualquier vulnerabilidad en el orquestador permitiría a un atacante ejecutar contenedores con privilegios de root en el host, acceder a otros contenedores y comprometer toda la máquina.
|
||||||
|
- **Remediación**: Implementar una arquitectura "Docker-out-of-Docker" con permisos restringidos o usar herramientas como `Kaniko` para construir imágenes sin necesidad del socket de Docker.
|
||||||
|
|
||||||
|
### B. Exposición de Bases de Datos en Desarrollo (`Medium Risk`)
|
||||||
|
- **Hallazgo**: El archivo `docker-compose.dev.yml` expone TimescaleDB (5432) y Redis (6379) directamente a la red del host.
|
||||||
|
- **Impacto**: Si este archivo se utiliza por error en un entorno que no esté detrás de un firewall estricto, la base de datos será atacable por fuerza bruta desde internet.
|
||||||
|
- **Remediación**: Asegurar que las bases de datos solo escuchen en `127.0.0.1` en el host o eliminarlas del archivo dev si se usa una red bridge.
|
||||||
|
|
||||||
|
### C. Protocolo MQTT en Texto Plano (`Medium Risk`)
|
||||||
|
- **Hallazgo**: Mosquitto está configurado en el puerto `1883` (estándar sin cifrar).
|
||||||
|
- **Impacto**: Los datos de telemetría industrial (presión, flujo, temperatura) viajan en texto plano. Un atacante en la red podría interceptar (sniffing) o inyectar datos falsos en el sistema.
|
||||||
|
- **Remediación**: Implementar **MQTTS** (puerto 8883) con certificados SSL/TLS para toda la comunicación entre los Edge Agents y el servidor central.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 2. Análisis de Superficie de Exposición 🌐
|
||||||
|
|
||||||
|
| Servicio | Puerto | Uso | Estado de Seguridad |
|
||||||
|
|----------|--------|-----|---------------------|
|
||||||
|
| **Backend API** | 8000 | API Rest / WS | **ASEGURADO**: Usa JWT y filtrado multi-tenant. |
|
||||||
|
| **Nginx Gateway** | 80 | Proxy Inverso | **RIESGO BAJO**: Falta forzar HTTPS (443) con certificados válidos. |
|
||||||
|
| **Mosquitto** | 1883 | Telemetría | **RIESGO MEDIO**: Falta cifrado TLS. |
|
||||||
|
| **Docker Registry** | 5000 | Imágenes | **RIESGO BAJO**: Debe estar restringido a la red interna. |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 3. Recomendaciones de Fortalecimiento (Hardening) 🛠️
|
||||||
|
|
||||||
|
### 1. Implementación de HTTPS Global
|
||||||
|
Actualmente, `gateway.conf` solo escucha en el puerto 80.
|
||||||
|
- **Acción**: Configurar Certbot (Let's Encrypt) para todos los subdominios (`api.*`, `app.*`, `mobile.*`).
|
||||||
|
- **Beneficio**: Protege las credenciales de los usuarios y el token JWT durante la transmisión.
|
||||||
|
|
||||||
|
### 2. Aislamiento de Redes Docker
|
||||||
|
- **Acción**: Separar la red `anh_network` en dos:
|
||||||
|
- `frontend_net`: Para comunicación Nginx <-> Frontends.
|
||||||
|
- `backend_net`: Para comunicación Backend <-> DB <-> Redis <-> MQTT.
|
||||||
|
- **Beneficio**: Si un frontend es comprometido, el atacante no tendría visibilidad directa de la base de datos o el broker MQTT.
|
||||||
|
|
||||||
|
### 3. Gestión de Secretos
|
||||||
|
- **Acción**: Verificar que el archivo `.env` esté incluido en `.dockerignore` y `.gitignore`.
|
||||||
|
- **Beneficio**: Evita que las contraseñas de la base de datos se filtren en el historial de Git o se empaqueten dentro de las imágenes Docker.
|
||||||
|
|
||||||
|
### 4. Límites de Recursos
|
||||||
|
- **Acción**: Añadir `deploy: resources: limits:` a los servicios críticos en `docker-compose.yml`.
|
||||||
|
- **Beneficio**: Evita ataques de Denegación de Servicio (DoS) por agotamiento de memoria o CPU de un solo contenedor.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 4. Conclusión Técnica
|
||||||
|
|
||||||
|
La arquitectura es robusta en su lógica de aplicación (JWT y multi-tenancy están bien implementados), pero presenta brechas en la **configuración de la infraestructura**. El riesgo más inmediato es el montaje del socket de Docker en el orquestador y la falta de cifrado en el protocolo MQTT.
|
||||||
|
|
||||||
|
> [!IMPORTANT]
|
||||||
|
> **Prioridad 1**: Cifrar el tráfico MQTT (TLS) para proteger la integridad de los datos industriales.
|
||||||
|
> **Prioridad 2**: Asegurar que las bases de datos NUNCA estén expuestas a puertos públicos en producción.
|
||||||
179
docs/frontend-ui-ux-backlog.md
Normal file
179
docs/frontend-ui-ux-backlog.md
Normal file
@@ -0,0 +1,179 @@
|
|||||||
|
# Frontend UI/UX Backlog
|
||||||
|
|
||||||
|
Este backlog prioriza mejoras concretas para `frontend-pwa`, `frontend-admin` y `landing`, con foco en reducir error operativo, cerrar flujos incompletos y mejorar consistencia visual y de interacción.
|
||||||
|
|
||||||
|
## Prioridad Recomendada
|
||||||
|
|
||||||
|
1. `frontend-pwa`
|
||||||
|
2. `frontend-admin`
|
||||||
|
3. `landing`
|
||||||
|
|
||||||
|
## Tickets
|
||||||
|
|
||||||
|
### 1. PWA: eliminar auto-seleccion silenciosa de activo
|
||||||
|
|
||||||
|
- Objetivo: evitar carga sobre el activo equivocado.
|
||||||
|
- Cambio:
|
||||||
|
- no seleccionar el primer activo automaticamente
|
||||||
|
- mostrar placeholder explicito: `Selecciona un activo`
|
||||||
|
- si hace falta acelerar, usar el ultimo activo usado solo como sugerencia visible
|
||||||
|
- Archivos:
|
||||||
|
- `services/frontend-pwa/src/features/field/measurements/components/AssetSelector.tsx`
|
||||||
|
- Impacto: muy alto
|
||||||
|
- Esfuerzo: bajo
|
||||||
|
|
||||||
|
### 2. PWA: persistir contexto despues de guardar
|
||||||
|
|
||||||
|
- Objetivo: reducir friccion en cargas repetitivas.
|
||||||
|
- Cambio:
|
||||||
|
- no hacer `setSelectedAsset(null)` al guardar mediciones o movimientos
|
||||||
|
- mantener activo y proyecto actual mientras el usuario siga en el flujo
|
||||||
|
- limpiar solo si el usuario lo decide o cambia de contexto
|
||||||
|
- Archivos:
|
||||||
|
- `services/frontend-pwa/src/features/field/measurements/pages/MeasurementPage.tsx`
|
||||||
|
- `services/frontend-pwa/src/features/field/movements/pages/MovementPage.tsx`
|
||||||
|
- Impacto: muy alto
|
||||||
|
- Esfuerzo: bajo
|
||||||
|
|
||||||
|
### 3. PWA: crear centro real de sincronizacion
|
||||||
|
|
||||||
|
- Objetivo: dar confianza operativa en una app offline-first.
|
||||||
|
- Cambio:
|
||||||
|
- pantalla de `Sync` con:
|
||||||
|
- cantidad pendiente
|
||||||
|
- ultima sync exitosa
|
||||||
|
- errores
|
||||||
|
- boton de reintento
|
||||||
|
- estado por tipo de registro
|
||||||
|
- Archivos:
|
||||||
|
- `services/frontend-pwa/src/app/router/index.tsx`
|
||||||
|
- `services/frontend-pwa/src/app/layouts/FieldLayout.tsx`
|
||||||
|
- `services/frontend-pwa/src/lib/sync/*`
|
||||||
|
- `services/frontend-pwa/src/lib/db/*`
|
||||||
|
- Impacto: muy alto
|
||||||
|
- Esfuerzo: medio
|
||||||
|
|
||||||
|
### 4. PWA: mejorar indicador online/offline
|
||||||
|
|
||||||
|
- Objetivo: que el estado no dependa solo de color o tooltip.
|
||||||
|
- Cambio:
|
||||||
|
- reemplazar puntito por texto claro y badge semantico
|
||||||
|
- mostrar `Online`, `Sin conexion`, `Pendientes: N`, `Error de sync`
|
||||||
|
- Archivos:
|
||||||
|
- `services/frontend-pwa/src/app/layouts/FieldLayout.tsx`
|
||||||
|
- `services/frontend-pwa/src/features/field/measurements/components/SyncStatusBadge.tsx`
|
||||||
|
- Impacto: alto
|
||||||
|
- Esfuerzo: bajo
|
||||||
|
|
||||||
|
### 5. Admin y PWA: cerrar dead ends de navegacion
|
||||||
|
|
||||||
|
- Objetivo: que ningun item lleve a una pantalla hueca sin contexto.
|
||||||
|
- Cambio:
|
||||||
|
- ocultar rutas no listas o marcarlas como `Proximamente` antes de entrar
|
||||||
|
- si quedan visibles, agregar contexto real y una expectativa funcional minima
|
||||||
|
- Archivos:
|
||||||
|
- `services/frontend-admin/src/app/router/index.tsx`
|
||||||
|
- `services/frontend-admin/src/app/layouts/AdminLayout.tsx`
|
||||||
|
- `services/frontend-pwa/src/app/router/index.tsx`
|
||||||
|
- `services/frontend-pwa/src/app/layouts/FieldLayout.tsx`
|
||||||
|
- Impacto: alto
|
||||||
|
- Esfuerzo: medio
|
||||||
|
|
||||||
|
### 6. Admin: unificar sistema de feedback
|
||||||
|
|
||||||
|
- Objetivo: lograr una experiencia consistente y menos friccion.
|
||||||
|
- Cambio:
|
||||||
|
- eliminar `alert()` y `confirm()` donde sea posible
|
||||||
|
- usar un patron unico con toast y modal de confirmacion
|
||||||
|
- definir mensajes estandar de exito, error y progreso
|
||||||
|
- Archivos:
|
||||||
|
- `services/frontend-admin/src/features/admin/projects/pages/ProjectListPage.tsx`
|
||||||
|
- `services/frontend-admin/src/features/admin/users/pages/UsersPage.tsx`
|
||||||
|
- `services/frontend-admin/src/features/admin/projects/pages/ProjectDetailsPage.tsx`
|
||||||
|
- `services/frontend-admin/src/features/admin/projects/components/DeviceConfigModal.tsx`
|
||||||
|
- Impacto: alto
|
||||||
|
- Esfuerzo: medio
|
||||||
|
|
||||||
|
### 7. Admin: mejorar legibilidad del dashboard
|
||||||
|
|
||||||
|
- Objetivo: priorizar lectura operativa sobre efecto visual.
|
||||||
|
- Cambio:
|
||||||
|
- subir tamanos minimos de texto
|
||||||
|
- bajar blur y ornamento
|
||||||
|
- reforzar jerarquia de alarmas, KPIs y estados
|
||||||
|
- exponer errores de carga al usuario
|
||||||
|
- Archivos:
|
||||||
|
- `services/frontend-admin/src/features/admin/dashboard/pages/DashboardPage.tsx`
|
||||||
|
- `services/frontend-admin/src/app/layouts/AdminLayout.tsx`
|
||||||
|
- Impacto: alto
|
||||||
|
- Esfuerzo: medio
|
||||||
|
|
||||||
|
### 8. Landing: resolver navegacion mobile
|
||||||
|
|
||||||
|
- Objetivo: no perder acceso a secciones clave en celular.
|
||||||
|
- Cambio:
|
||||||
|
- agregar menu mobile real
|
||||||
|
- no ocultar `.nav-links` sin reemplazo
|
||||||
|
- Archivos:
|
||||||
|
- `services/landing/src/App.tsx`
|
||||||
|
- `services/landing/src/App.css`
|
||||||
|
- Impacto: alto
|
||||||
|
- Esfuerzo: bajo
|
||||||
|
|
||||||
|
### 9. Landing: hacer funcional el CTA principal
|
||||||
|
|
||||||
|
- Objetivo: convertir interes en accion real.
|
||||||
|
- Cambio:
|
||||||
|
- envolver email y boton en `<form>`
|
||||||
|
- agregar validacion
|
||||||
|
- agregar feedback de exito o error
|
||||||
|
- definir destino real del lead
|
||||||
|
- Archivos:
|
||||||
|
- `services/landing/src/App.tsx`
|
||||||
|
- Impacto: alto
|
||||||
|
- Esfuerzo: bajo
|
||||||
|
|
||||||
|
### 10. Accesibilidad base transversal
|
||||||
|
|
||||||
|
- Objetivo: corregir deuda estructural que afecta calidad general.
|
||||||
|
- Cambio:
|
||||||
|
- modales con semantica correcta
|
||||||
|
- `aria-label` en icon buttons
|
||||||
|
- toggle de password accesible por teclado
|
||||||
|
- labels asociadas en formularios
|
||||||
|
- reemplazar `div` clickeables por botones reales
|
||||||
|
- Archivos:
|
||||||
|
- `services/frontend-admin/src/features/admin/projects/components/DeviceConfigModal.tsx`
|
||||||
|
- `services/frontend-admin/src/features/admin/users/components/UserFormModal.tsx`
|
||||||
|
- `services/frontend-admin/src/features/auth/pages/LoginPage.tsx`
|
||||||
|
- `services/frontend-pwa/src/features/auth/pages/LoginPage.tsx`
|
||||||
|
- `services/landing/src/App.tsx`
|
||||||
|
- Impacto: medio-alto
|
||||||
|
- Esfuerzo: medio
|
||||||
|
|
||||||
|
## Orden Recomendado
|
||||||
|
|
||||||
|
1. Ticket 1
|
||||||
|
2. Ticket 2
|
||||||
|
3. Ticket 3
|
||||||
|
4. Ticket 5
|
||||||
|
5. Ticket 6
|
||||||
|
6. Ticket 7
|
||||||
|
7. Ticket 8
|
||||||
|
8. Ticket 9
|
||||||
|
9. Ticket 4
|
||||||
|
10. Ticket 10
|
||||||
|
|
||||||
|
## Sprint 1 Sugerido
|
||||||
|
|
||||||
|
- Ticket 1
|
||||||
|
- Ticket 2
|
||||||
|
- Ticket 4
|
||||||
|
- Ticket 5
|
||||||
|
|
||||||
|
## Resultado Esperado del Sprint 1
|
||||||
|
|
||||||
|
- menor riesgo de error operativo en campo
|
||||||
|
- menos friccion en cargas repetitivas
|
||||||
|
- mejor visibilidad de estado en la PWA
|
||||||
|
- menos pantallas vacias desde navegacion
|
||||||
@@ -1,13 +0,0 @@
|
|||||||
import hashlib
|
|
||||||
import base64
|
|
||||||
import os
|
|
||||||
|
|
||||||
password = b'admin123'
|
|
||||||
salt = os.urandom(16)
|
|
||||||
iterations = 100000
|
|
||||||
hash_bytes = hashlib.pbkdf2_hmac('sha512', password, salt, iterations, dklen=64)
|
|
||||||
|
|
||||||
salt_b64 = base64.b64encode(salt).decode()
|
|
||||||
hash_b64 = base64.b64encode(hash_bytes).decode()
|
|
||||||
|
|
||||||
print(f'PBKDF2$sha512${iterations}${salt_b64}${hash_b64}')
|
|
||||||
@@ -5,8 +5,9 @@ FROM iegomez/mosquitto-go-auth:3.0.0-mosquitto_2.0.18
|
|||||||
COPY mosquitto.conf.template /etc/mosquitto/mosquitto.conf.template
|
COPY mosquitto.conf.template /etc/mosquitto/mosquitto.conf.template
|
||||||
COPY entrypoint.sh /etc/mosquitto/entrypoint.sh
|
COPY entrypoint.sh /etc/mosquitto/entrypoint.sh
|
||||||
|
|
||||||
# Aseguramos permisos de ejecución
|
# Aseguramos permisos de ejecución y limpiamos finales de línea de Windows
|
||||||
RUN chmod +x /etc/mosquitto/entrypoint.sh
|
RUN sed -i 's/\r$//' /etc/mosquitto/entrypoint.sh && \
|
||||||
|
chmod +x /etc/mosquitto/entrypoint.sh
|
||||||
|
|
||||||
# Exponemos los puertos
|
# Exponemos los puertos
|
||||||
EXPOSE 1883 9883
|
EXPOSE 1883 9883
|
||||||
|
|||||||
@@ -1,6 +1,12 @@
|
|||||||
#!/bin/sh
|
#!/bin/sh
|
||||||
set -e
|
set -e
|
||||||
|
|
||||||
|
# 🛡️ [OmniOil] Ensure data and log directories have correct permissions
|
||||||
|
# Mosquitto runs as UID 1883, we need to make sure it can write to the volumes
|
||||||
|
echo "🔐 [OmniOil] Fixing permissions for /mosquitto/data and /mosquitto/log..."
|
||||||
|
mkdir -p /mosquitto/data /mosquitto/log
|
||||||
|
chown -R mosquitto:mosquitto /mosquitto/data /mosquitto/log
|
||||||
|
|
||||||
# Generar el archivo final a partir de la plantilla
|
# Generar el archivo final a partir de la plantilla
|
||||||
TEMPLATE="/etc/mosquitto/mosquitto.conf.template"
|
TEMPLATE="/etc/mosquitto/mosquitto.conf.template"
|
||||||
CONFIG="/etc/mosquitto/mosquitto.conf"
|
CONFIG="/etc/mosquitto/mosquitto.conf"
|
||||||
|
|||||||
@@ -41,4 +41,4 @@ auth_opt_log_level info
|
|||||||
|
|
||||||
# Persistence
|
# Persistence
|
||||||
persistence true
|
persistence true
|
||||||
persistence_location /mosquitto/data/
|
persistence_location /mosquitto/data
|
||||||
|
|||||||
@@ -14,6 +14,18 @@ server {
|
|||||||
listen 80;
|
listen 80;
|
||||||
server_name app.omnioil.app;
|
server_name app.omnioil.app;
|
||||||
|
|
||||||
|
location /api {
|
||||||
|
proxy_pass http://backend-api:8000;
|
||||||
|
proxy_set_header Host $host;
|
||||||
|
proxy_set_header X-Real-IP $remote_addr;
|
||||||
|
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||||
|
|
||||||
|
# Soporte para WebSockets
|
||||||
|
proxy_http_version 1.1;
|
||||||
|
proxy_set_header Upgrade $http_upgrade;
|
||||||
|
proxy_set_header Connection "upgrade";
|
||||||
|
}
|
||||||
|
|
||||||
location / {
|
location / {
|
||||||
proxy_pass http://frontend-admin:80;
|
proxy_pass http://frontend-admin:80;
|
||||||
proxy_set_header Host $host;
|
proxy_set_header Host $host;
|
||||||
@@ -34,6 +46,18 @@ server {
|
|||||||
listen 80;
|
listen 80;
|
||||||
server_name mobile.omnioil.app;
|
server_name mobile.omnioil.app;
|
||||||
|
|
||||||
|
location /api {
|
||||||
|
proxy_pass http://backend-api:8000;
|
||||||
|
proxy_set_header Host $host;
|
||||||
|
proxy_set_header X-Real-IP $remote_addr;
|
||||||
|
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||||
|
|
||||||
|
# Soporte para WebSockets
|
||||||
|
proxy_http_version 1.1;
|
||||||
|
proxy_set_header Upgrade $http_upgrade;
|
||||||
|
proxy_set_header Connection "upgrade";
|
||||||
|
}
|
||||||
|
|
||||||
location / {
|
location / {
|
||||||
proxy_pass http://frontend-pwa:80;
|
proxy_pass http://frontend-pwa:80;
|
||||||
proxy_set_header Host $host;
|
proxy_set_header Host $host;
|
||||||
@@ -56,7 +80,9 @@ server {
|
|||||||
server_name api.omnioil.app;
|
server_name api.omnioil.app;
|
||||||
|
|
||||||
location / {
|
location / {
|
||||||
proxy_pass http://backend-api:8000;
|
resolver 127.0.0.11 valid=30s;
|
||||||
|
set $backend_upstream backend-api;
|
||||||
|
proxy_pass http://$backend_upstream:8000;
|
||||||
proxy_set_header Host $host;
|
proxy_set_header Host $host;
|
||||||
proxy_set_header X-Real-IP $remote_addr;
|
proxy_set_header X-Real-IP $remote_addr;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -48,6 +48,24 @@ async function main() {
|
|||||||
console.log(' ✓ admin@omnioil.com (Admin123!)')
|
console.log(' ✓ admin@omnioil.com (Admin123!)')
|
||||||
console.log(' ✓ operador@omnioil.com (Campo123!)')
|
console.log(' ✓ operador@omnioil.com (Campo123!)')
|
||||||
|
|
||||||
|
// ─── 1b. Proyecto por Defecto ───────────────────────────
|
||||||
|
console.log('\n📦 Creando proyecto por defecto...')
|
||||||
|
const projectRes = await client.query(`
|
||||||
|
INSERT INTO edge_projects (name, client, operator_name, contract_number, description)
|
||||||
|
VALUES ('Proyecto Piloto Norte', 'OmniOil Corp', 'OmniOil Operator', 'CONT-2026-001', 'Campo de pruebas principal')
|
||||||
|
ON CONFLICT (name) DO UPDATE SET description = EXCLUDED.description
|
||||||
|
RETURNING id
|
||||||
|
`)
|
||||||
|
const projectId = projectRes.rows[0].id
|
||||||
|
|
||||||
|
// Vincular admin al proyecto
|
||||||
|
const adminUser = await client.query("SELECT id FROM users WHERE email = 'admin@omnioil.com'")
|
||||||
|
await client.query(`
|
||||||
|
INSERT INTO user_project_access (user_id, project_id, project_role)
|
||||||
|
VALUES ($1, $2, 'owner')
|
||||||
|
ON CONFLICT DO NOTHING
|
||||||
|
`, [adminUser.rows[0].id, projectId])
|
||||||
|
|
||||||
// ─── 2. Assets ─────────────────────────────────────────
|
// ─── 2. Assets ─────────────────────────────────────────
|
||||||
console.log('\n📦 Insertando assets...')
|
console.log('\n📦 Insertando assets...')
|
||||||
|
|
||||||
@@ -62,7 +80,8 @@ async function main() {
|
|||||||
const assetIds = {}
|
const assetIds = {}
|
||||||
for (const asset of assets) {
|
for (const asset of assets) {
|
||||||
const result = await client.query(`
|
const result = await client.query(`
|
||||||
INSERT INTO assets (code, name, asset_type, metadata) VALUES ($1, $2, $3, $4)
|
INSERT INTO edge_assets (code, name, asset_type, metadata, project_id)
|
||||||
|
SELECT $1, $2, $3, $4, id FROM edge_projects LIMIT 1
|
||||||
ON CONFLICT (code) DO UPDATE SET name = $2
|
ON CONFLICT (code) DO UPDATE SET name = $2
|
||||||
RETURNING id
|
RETURNING id
|
||||||
`, [asset.code, asset.name, asset.type, JSON.stringify(asset.meta)])
|
`, [asset.code, asset.name, asset.type, JSON.stringify(asset.meta)])
|
||||||
@@ -109,9 +128,9 @@ async function main() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
await client.query(
|
await client.query(
|
||||||
`INSERT INTO telemetry_raw (time, asset_id, data) VALUES ($1::timestamptz, $2::uuid, $3::jsonb)
|
`INSERT INTO telemetry_raw (time, project_id, data)
|
||||||
ON CONFLICT (time, asset_id) DO NOTHING`,
|
SELECT $1::timestamptz, id, $2::jsonb FROM edge_projects LIMIT 1`,
|
||||||
[time.toISOString(), assetId, JSON.stringify(data)]
|
[time.toISOString(), JSON.stringify(data)]
|
||||||
)
|
)
|
||||||
insertCount++
|
insertCount++
|
||||||
}
|
}
|
||||||
@@ -154,7 +173,7 @@ async function main() {
|
|||||||
for (const mov of movements) {
|
for (const mov of movements) {
|
||||||
const assetId = assetIds[mov.assetCode]
|
const assetId = assetIds[mov.assetCode]
|
||||||
await client.query(`
|
await client.query(`
|
||||||
INSERT INTO operations_movements (asset_id, movement_type, start_time, end_time, volumen_neto, details)
|
INSERT INTO operation_movements (asset_id, movement_type, start_time, end_time, volumen_neto, details)
|
||||||
VALUES ($1, $2, $3, $4, $5, $6)
|
VALUES ($1, $2, $3, $4, $5, $6)
|
||||||
`, [assetId, mov.type, mov.start.toISOString(), mov.end.toISOString(), mov.volumen, JSON.stringify(mov.details)])
|
`, [assetId, mov.type, mov.start.toISOString(), mov.end.toISOString(), mov.volumen, JSON.stringify(mov.details)])
|
||||||
console.log(` ✓ ${mov.type} — ${mov.assetCode}: ${mov.volumen} BBL`)
|
console.log(` ✓ ${mov.type} — ${mov.assetCode}: ${mov.volumen} BBL`)
|
||||||
|
|||||||
23
package.json
Normal file
23
package.json
Normal file
@@ -0,0 +1,23 @@
|
|||||||
|
{
|
||||||
|
"name": "omnioil-root",
|
||||||
|
"version": "1.0.0",
|
||||||
|
"private": true,
|
||||||
|
"scripts": {
|
||||||
|
"infra": "docker compose -f docker-compose.yml -f docker-compose.dev.yml up -d --no-deps timescaledb redis minio mosquitto edge-agent-msi-builder",
|
||||||
|
"infra:stop": "docker compose down",
|
||||||
|
"wait:infra": "powershell -Command \"Write-Host '⏳ Esperando que la infraestructura esté lista...'; Start-Sleep -s 10\"",
|
||||||
|
"build:all": "cargo build",
|
||||||
|
"srv:api": "cargo watch -w services/backend-api -w services/shared-lib -x \"run -p backend-api\"",
|
||||||
|
"srv:telemetry": "cargo watch -w services/telemetry-ingestor -w services/shared-lib -x \"run -p telemetry-ingestor\"",
|
||||||
|
"srv:orchestrator": "cargo watch -w services/build-orchestrator -w services/shared-lib -x \"run -p build-orchestrator\"",
|
||||||
|
"srv:relay": "cargo watch -w services/events-relay -w services/shared-lib -x \"run -p events-relay\"",
|
||||||
|
"srv:json": "cargo watch -w services/json-generator -w services/shared-lib -x \"run -p json-generator\"",
|
||||||
|
"srv:flow": "cargo watch -w services/flow-generator -w services/shared-lib -x \"run -p flow-generator\"",
|
||||||
|
"srv:collector": "cargo watch -w services/data-collector -w services/shared-lib -x \"run -p data-collector\"",
|
||||||
|
"ui:admin": "cd services/frontend-admin && pnpm run dev",
|
||||||
|
"dev": "pnpm run infra && pnpm run wait:infra && pnpm run build:all && npx concurrently --kill-others -n \"API,TELE,ORCH,RELAY,JSON,FLOW,COLL,UI\" -c \"cyan,magenta,green,yellow,blue,red,white,cyan.bold\" \"pnpm run srv:api\" \"pnpm run srv:telemetry\" \"pnpm run srv:orchestrator\" \"pnpm run srv:relay\" \"pnpm run srv:json\" \"pnpm run srv:flow\" \"pnpm run srv:collector\" \"pnpm run ui:admin\""
|
||||||
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
"concurrently": "^8.2.2"
|
||||||
|
}
|
||||||
|
}
|
||||||
69
scratch/seed_anh_reports.sql
Normal file
69
scratch/seed_anh_reports.sql
Normal file
@@ -0,0 +1,69 @@
|
|||||||
|
-- Seed de datos para pruebas de Reportes ANH
|
||||||
|
|
||||||
|
-- 1. Insertar un reporte de prueba con datos simulados
|
||||||
|
INSERT INTO anh_reports (
|
||||||
|
filename,
|
||||||
|
status,
|
||||||
|
hash_sha384,
|
||||||
|
content,
|
||||||
|
size_bytes,
|
||||||
|
period_start,
|
||||||
|
period_end
|
||||||
|
) VALUES (
|
||||||
|
'ANH_TEST_REPORT_20260501.json',
|
||||||
|
'GENERATED',
|
||||||
|
'c2966580e2f54a8e298e72769416550702d76f8e71887e5b61e2a530f5b61e2a530f5b61e2a530f5b61e2a530f5b61e2a530f5b61e2a530f',
|
||||||
|
'{
|
||||||
|
"cabecera": {
|
||||||
|
"version": "1.0",
|
||||||
|
"operador": "OmniOil Test",
|
||||||
|
"fecha_reporte": "2026-05-01"
|
||||||
|
},
|
||||||
|
"datos": [
|
||||||
|
{
|
||||||
|
"pozo": "WELL-ALPHA-01",
|
||||||
|
"presion_cabeza": 150.5,
|
||||||
|
"temperatura": 85.0,
|
||||||
|
"volumen_bruto": 1200.0,
|
||||||
|
"manual_fields": {
|
||||||
|
"bsw_manual": 0.5,
|
||||||
|
"cloro_ppm": 120
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"pozo": "WELL-BETA-02",
|
||||||
|
"presion_cabeza": 142.3,
|
||||||
|
"temperatura": 82.5,
|
||||||
|
"volumen_bruto": 980.0,
|
||||||
|
"manual_fields": {
|
||||||
|
"bsw_manual": 1.2,
|
||||||
|
"cloro_ppm": 115
|
||||||
|
}
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}',
|
||||||
|
1024,
|
||||||
|
'2026-05-01 00:00:00+00',
|
||||||
|
'2026-05-01 23:59:59+00'
|
||||||
|
);
|
||||||
|
|
||||||
|
-- 2. Insertar un reporte ya "enviado"
|
||||||
|
INSERT INTO anh_reports (
|
||||||
|
filename,
|
||||||
|
status,
|
||||||
|
hash_sha384,
|
||||||
|
content,
|
||||||
|
size_bytes,
|
||||||
|
period_start,
|
||||||
|
period_end,
|
||||||
|
sent_at
|
||||||
|
) VALUES (
|
||||||
|
'ANH_HISTORY_REPORT_20260430.json',
|
||||||
|
'SENT',
|
||||||
|
'a5b61e2a530f5b61e2a530f5b61e2a530f5b61e2a530f5b61e2a530f5b61e2a530f5b61e2a530f5b61e2a530f5b61e2a530f5b61e2a530f',
|
||||||
|
'{"history": "test data"}',
|
||||||
|
512,
|
||||||
|
'2026-04-30 00:00:00+00',
|
||||||
|
'2026-04-30 23:59:59+00',
|
||||||
|
'2026-04-30 23:59:59+00'
|
||||||
|
);
|
||||||
@@ -4,29 +4,31 @@ version = "0.1.0"
|
|||||||
edition = "2024"
|
edition = "2024"
|
||||||
|
|
||||||
[dependencies]
|
[dependencies]
|
||||||
shared-lib = { path = "../shared-lib" }
|
shared-lib = { workspace = true }
|
||||||
axum = { version = "0.8.8", features = ["macros"] }
|
axum = { workspace = true, features = ["macros", "ws"] }
|
||||||
tokio = { version = "1.50.0", features = ["full"] }
|
tokio = { workspace = true }
|
||||||
sqlx = { version = "0.8.6", features = ["runtime-tokio-native-tls", "postgres", "uuid", "chrono", "json", "bigdecimal"] }
|
sqlx = { workspace = true }
|
||||||
tower = { version = "0.5.3", features = ["util"] }
|
tower = { workspace = true }
|
||||||
tower-http = { version = "0.6.8", features = ["cors", "trace"] }
|
tower-http = { workspace = true }
|
||||||
serde = { version = "1.0.228", features = ["derive"] }
|
serde = { workspace = true }
|
||||||
serde_json = "1.0.149"
|
serde_json = { workspace = true }
|
||||||
tracing = "0.1.44"
|
tracing = { workspace = true }
|
||||||
tracing-subscriber = { version = "0.3.23", features = ["env-filter"] }
|
tracing-subscriber = { workspace = true }
|
||||||
dotenv = "0.15.0"
|
dotenvy = { workspace = true }
|
||||||
jsonwebtoken = { version = "10.3.0", features = ["aws_lc_rs"] }
|
jsonwebtoken = { version = "10.3.0", features = ["aws_lc_rs"] }
|
||||||
# rustls = { version = "0.23.12", features = ["ring", "std"] }
|
|
||||||
argon2 = { version = "0.5.3", features = ["password-hash", "rand"] }
|
argon2 = { version = "0.5.3", features = ["password-hash", "rand"] }
|
||||||
chrono = { version = "0.4.44", features = ["serde"] }
|
chrono = { workspace = true }
|
||||||
axum-extra = { version = "0.12.5", features = ["typed-header"] }
|
axum-extra = { version = "0.12.5", features = ["typed-header"] }
|
||||||
uuid = { version = "1.23.0", features = ["v4", "serde"] }
|
uuid = { workspace = true }
|
||||||
anyhow = "1.0.102"
|
anyhow = { workspace = true }
|
||||||
rumqttc = "0.25.1"
|
rumqttc = { workspace = true }
|
||||||
bigdecimal = { version = "0.4.7", features = ["serde"] }
|
zstd = { workspace = true }
|
||||||
|
bigdecimal = { workspace = true }
|
||||||
aws-config = "1.5"
|
aws-config = "1.5"
|
||||||
aws-sdk-s3 = "1.50"
|
aws-sdk-s3 = "1.50"
|
||||||
tokio-util = { version = "0.7.18", features = ["io"] }
|
tokio-util = { version = "0.7.18", features = ["io"] }
|
||||||
tower_governor = "0.8.0"
|
tower_governor = "0.8.0"
|
||||||
governor = "0.10.4"
|
governor = "0.10.4"
|
||||||
sha2 = "0.10.8"
|
sha2 = { workspace = true }
|
||||||
|
async-trait = "0.1.87"
|
||||||
|
totp-rs = { version = "5.7.1", features = ["qr"] }
|
||||||
|
|||||||
@@ -1,8 +1,9 @@
|
|||||||
-- =============================================================================
|
-- =============================================================================
|
||||||
-- OMNIOIL — CONSOLIDATED INITIAL SCHEMA
|
-- OMNIOIL — CONSOLIDATED MASTER SCHEMA (v2026-04-19)
|
||||||
-- =============================================================================
|
-- =============================================================================
|
||||||
|
|
||||||
CREATE EXTENSION IF NOT EXISTS "uuid-ossp";
|
CREATE EXTENSION IF NOT EXISTS "uuid-ossp";
|
||||||
|
CREATE EXTENSION IF NOT EXISTS "pgcrypto";
|
||||||
|
|
||||||
-- MÓDULO 1: SEGURIDAD Y ACCESO
|
-- MÓDULO 1: SEGURIDAD Y ACCESO
|
||||||
CREATE TABLE IF NOT EXISTS roles (
|
CREATE TABLE IF NOT EXISTS roles (
|
||||||
@@ -25,13 +26,14 @@ CREATE TABLE IF NOT EXISTS users (
|
|||||||
full_name VARCHAR(200),
|
full_name VARCHAR(200),
|
||||||
password_hash VARCHAR(255) NOT NULL,
|
password_hash VARCHAR(255) NOT NULL,
|
||||||
role_id INT NOT NULL REFERENCES roles(id),
|
role_id INT NOT NULL REFERENCES roles(id),
|
||||||
|
two_factor_secret TEXT,
|
||||||
|
two_factor_enabled BOOLEAN DEFAULT false,
|
||||||
is_active BOOLEAN DEFAULT true,
|
is_active BOOLEAN DEFAULT true,
|
||||||
last_login TIMESTAMPTZ,
|
last_login TIMESTAMPTZ,
|
||||||
created_at TIMESTAMPTZ DEFAULT NOW(),
|
created_at TIMESTAMPTZ DEFAULT NOW(),
|
||||||
updated_at TIMESTAMPTZ DEFAULT NOW()
|
updated_at TIMESTAMPTZ DEFAULT NOW()
|
||||||
);
|
);
|
||||||
|
|
||||||
-- MÓDULO: REFRESH TOKENS (Soporte para sesiones seguras)
|
|
||||||
CREATE TABLE IF NOT EXISTS refresh_tokens (
|
CREATE TABLE IF NOT EXISTS refresh_tokens (
|
||||||
id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
|
id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
|
||||||
user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
||||||
@@ -41,27 +43,30 @@ CREATE TABLE IF NOT EXISTS refresh_tokens (
|
|||||||
revoked_at TIMESTAMPTZ
|
revoked_at TIMESTAMPTZ
|
||||||
);
|
);
|
||||||
|
|
||||||
CREATE INDEX IF NOT EXISTS idx_refresh_tokens_user_id ON refresh_tokens(user_id);
|
-- MÓDULO 2: JERARQUÍA OPERATIVA Y CUMPLIMIENTO ANH
|
||||||
CREATE INDEX IF NOT EXISTS idx_refresh_tokens_token_hash ON refresh_tokens(token_hash);
|
|
||||||
|
|
||||||
-- MÓDULO 2: JERARQUÍA OPERATIVA
|
|
||||||
CREATE TABLE IF NOT EXISTS edge_projects (
|
CREATE TABLE IF NOT EXISTS edge_projects (
|
||||||
id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
|
id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
|
||||||
name VARCHAR(100) NOT NULL UNIQUE,
|
name VARCHAR(100) NOT NULL UNIQUE,
|
||||||
client VARCHAR(150) NOT NULL,
|
client VARCHAR(150) NOT NULL,
|
||||||
|
operator_name VARCHAR(150) NOT NULL,
|
||||||
|
contract_number VARCHAR(100) NOT NULL,
|
||||||
description TEXT,
|
description TEXT,
|
||||||
|
municipality VARCHAR(100),
|
||||||
|
department VARCHAR(100),
|
||||||
|
latitude DOUBLE PRECISION,
|
||||||
|
longitude DOUBLE PRECISION,
|
||||||
metadata JSONB DEFAULT '{}'::jsonb,
|
metadata JSONB DEFAULT '{}'::jsonb,
|
||||||
is_active BOOLEAN DEFAULT true,
|
is_active BOOLEAN DEFAULT true,
|
||||||
installer_status VARCHAR(50) DEFAULT 'NOT_STARTED',
|
installer_status VARCHAR(50) DEFAULT 'NOT_STARTED',
|
||||||
installer_url TEXT,
|
installer_url TEXT,
|
||||||
|
mqtt_password TEXT,
|
||||||
|
agent_master_secret TEXT,
|
||||||
last_health_report JSONB DEFAULT '{}'::jsonb,
|
last_health_report JSONB DEFAULT '{}'::jsonb,
|
||||||
created_at TIMESTAMPTZ DEFAULT NOW(),
|
created_at TIMESTAMPTZ DEFAULT NOW(),
|
||||||
updated_at TIMESTAMPTZ DEFAULT NOW(),
|
updated_at TIMESTAMPTZ DEFAULT NOW(),
|
||||||
CONSTRAINT check_installer_status CHECK (installer_status IN ('NOT_STARTED', 'QUEUED', 'BUILDING', 'READY', 'COMPLETED', 'FAILED'))
|
CONSTRAINT check_installer_status CHECK (installer_status IN ('NOT_STARTED', 'QUEUED', 'BUILDING', 'READY', 'COMPLETED', 'FAILED'))
|
||||||
);
|
);
|
||||||
|
|
||||||
CREATE INDEX IF NOT EXISTS idx_edge_projects_client ON edge_projects(client);
|
|
||||||
|
|
||||||
CREATE TABLE IF NOT EXISTS user_project_access (
|
CREATE TABLE IF NOT EXISTS user_project_access (
|
||||||
user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
||||||
project_id UUID NOT NULL REFERENCES edge_projects(id) ON DELETE CASCADE,
|
project_id UUID NOT NULL REFERENCES edge_projects(id) ON DELETE CASCADE,
|
||||||
@@ -77,22 +82,21 @@ CREATE TABLE IF NOT EXISTS edge_assets (
|
|||||||
project_id UUID NOT NULL REFERENCES edge_projects(id) ON DELETE CASCADE,
|
project_id UUID NOT NULL REFERENCES edge_projects(id) ON DELETE CASCADE,
|
||||||
code VARCHAR(50) UNIQUE NOT NULL,
|
code VARCHAR(50) UNIQUE NOT NULL,
|
||||||
name VARCHAR(100) NOT NULL,
|
name VARCHAR(100) NOT NULL,
|
||||||
asset_type VARCHAR(20) NOT NULL
|
asset_type VARCHAR(20) NOT NULL CHECK (asset_type IN ('POZO', 'TANQUE', 'MEDIDOR_GAS', 'AGUA', 'OTRO')),
|
||||||
CHECK (asset_type IN ('POZO', 'TANQUE', 'MEDIDOR_GAS', 'AGUA', 'OTRO')),
|
last_calibration_date DATE,
|
||||||
|
next_calibration_date DATE,
|
||||||
|
calibration_certificate_url TEXT,
|
||||||
is_active BOOLEAN DEFAULT true,
|
is_active BOOLEAN DEFAULT true,
|
||||||
metadata JSONB DEFAULT '{}'::jsonb,
|
metadata JSONB DEFAULT '{}'::jsonb,
|
||||||
created_at TIMESTAMPTZ DEFAULT NOW(),
|
created_at TIMESTAMPTZ DEFAULT NOW(),
|
||||||
updated_at TIMESTAMPTZ DEFAULT NOW()
|
updated_at TIMESTAMPTZ DEFAULT NOW()
|
||||||
);
|
);
|
||||||
|
|
||||||
CREATE INDEX IF NOT EXISTS idx_edge_assets_project ON edge_assets(project_id);
|
|
||||||
|
|
||||||
CREATE TABLE IF NOT EXISTS edge_devices (
|
CREATE TABLE IF NOT EXISTS edge_devices (
|
||||||
id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
|
id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
|
||||||
asset_id UUID NOT NULL REFERENCES edge_assets(id) ON DELETE CASCADE,
|
asset_id UUID NOT NULL REFERENCES edge_assets(id) ON DELETE CASCADE,
|
||||||
name VARCHAR(100) NOT NULL,
|
name VARCHAR(100) NOT NULL,
|
||||||
protocol VARCHAR(30) NOT NULL
|
protocol VARCHAR(30) NOT NULL CHECK (protocol IN ('MODBUS_TCP', 'MODBUS_RTU', 'OPC_UA', 'MQTT', 'ETHERNET_IP', 'PROFINET')),
|
||||||
CHECK (protocol IN ('MODBUS_TCP', 'MODBUS_RTU', 'OPC_UA', 'MQTT', 'ETHERNET_IP', 'PROFINET')),
|
|
||||||
host VARCHAR(255) NOT NULL,
|
host VARCHAR(255) NOT NULL,
|
||||||
port INTEGER NOT NULL,
|
port INTEGER NOT NULL,
|
||||||
protocol_config JSONB DEFAULT '{}'::jsonb,
|
protocol_config JSONB DEFAULT '{}'::jsonb,
|
||||||
@@ -105,13 +109,15 @@ CREATE TABLE IF NOT EXISTS edge_variables (
|
|||||||
id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
|
id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
|
||||||
device_id UUID NOT NULL REFERENCES edge_devices(id) ON DELETE CASCADE,
|
device_id UUID NOT NULL REFERENCES edge_devices(id) ON DELETE CASCADE,
|
||||||
address VARCHAR(255) NOT NULL,
|
address VARCHAR(255) NOT NULL,
|
||||||
data_type VARCHAR(30) NOT NULL
|
data_type VARCHAR(30) NOT NULL CHECK (data_type IN ('bool', 'int16', 'uint16', 'int32', 'uint32', 'float32', 'float64', 'string')),
|
||||||
CHECK (data_type IN ('bool', 'int16', 'uint16', 'int32', 'uint32', 'float32', 'float64', 'string')),
|
|
||||||
alias VARCHAR(100) NOT NULL,
|
alias VARCHAR(100) NOT NULL,
|
||||||
unit VARCHAR(30),
|
unit VARCHAR(30),
|
||||||
polling_interval_ms INTEGER DEFAULT 5000,
|
polling_interval_ms INTEGER DEFAULT 5000,
|
||||||
byte_order VARCHAR(10) DEFAULT 'BE'
|
byte_order VARCHAR(10) DEFAULT 'BE' CHECK (byte_order IN ('BE', 'LE', 'BE_SWAP', 'LE_SWAP')),
|
||||||
CHECK (byte_order IN ('BE', 'LE', 'BE_SWAP', 'LE_SWAP')),
|
alarm_min DOUBLE PRECISION,
|
||||||
|
alarm_max DOUBLE PRECISION,
|
||||||
|
alarm_enabled BOOLEAN DEFAULT false,
|
||||||
|
metadata JSONB DEFAULT '{}'::jsonb,
|
||||||
is_enabled BOOLEAN DEFAULT true,
|
is_enabled BOOLEAN DEFAULT true,
|
||||||
created_at TIMESTAMPTZ DEFAULT NOW(),
|
created_at TIMESTAMPTZ DEFAULT NOW(),
|
||||||
updated_at TIMESTAMPTZ DEFAULT NOW()
|
updated_at TIMESTAMPTZ DEFAULT NOW()
|
||||||
@@ -121,8 +127,10 @@ CREATE TABLE IF NOT EXISTS edge_variables (
|
|||||||
CREATE TABLE IF NOT EXISTS telemetry_raw (
|
CREATE TABLE IF NOT EXISTS telemetry_raw (
|
||||||
time TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
time TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||||
project_id UUID NOT NULL REFERENCES edge_projects(id) ON DELETE CASCADE,
|
project_id UUID NOT NULL REFERENCES edge_projects(id) ON DELETE CASCADE,
|
||||||
|
asset_id UUID REFERENCES edge_assets(id) ON DELETE CASCADE,
|
||||||
data JSONB NOT NULL
|
data JSONB NOT NULL
|
||||||
);
|
);
|
||||||
|
SELECT create_hypertable('telemetry_raw', 'time', if_not_exists => TRUE);
|
||||||
|
|
||||||
CREATE TABLE IF NOT EXISTS agent_logs (
|
CREATE TABLE IF NOT EXISTS agent_logs (
|
||||||
id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
|
id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
|
||||||
@@ -131,12 +139,10 @@ CREATE TABLE IF NOT EXISTS agent_logs (
|
|||||||
level VARCHAR(20),
|
level VARCHAR(20),
|
||||||
category VARCHAR(50),
|
category VARCHAR(50),
|
||||||
message TEXT,
|
message TEXT,
|
||||||
context JSONB,
|
context JSONB NOT NULL DEFAULT '{}'::jsonb,
|
||||||
timestamp TIMESTAMPTZ DEFAULT NOW()
|
timestamp TIMESTAMPTZ DEFAULT NOW()
|
||||||
);
|
);
|
||||||
|
|
||||||
SELECT create_hypertable('telemetry_raw', 'time', if_not_exists => TRUE);
|
|
||||||
|
|
||||||
-- MÓDULO 4: EDGE AGENT DEPLOYMENTS
|
-- MÓDULO 4: EDGE AGENT DEPLOYMENTS
|
||||||
CREATE TABLE IF NOT EXISTS edge_deployments (
|
CREATE TABLE IF NOT EXISTS edge_deployments (
|
||||||
id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
|
id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
|
||||||
@@ -153,16 +159,7 @@ CREATE TABLE IF NOT EXISTS edge_deployments (
|
|||||||
created_at TIMESTAMPTZ DEFAULT NOW()
|
created_at TIMESTAMPTZ DEFAULT NOW()
|
||||||
);
|
);
|
||||||
|
|
||||||
-- MÓDULO 5: OUTBOX (Patrón de Mensajería)
|
-- MÓDULO 5: MQTT AUTH & ACL
|
||||||
CREATE TABLE IF NOT EXISTS domain_outbox (
|
|
||||||
id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
|
|
||||||
event_type VARCHAR(100) NOT NULL,
|
|
||||||
payload JSONB NOT NULL,
|
|
||||||
status VARCHAR(20) DEFAULT 'PENDING',
|
|
||||||
created_at TIMESTAMPTZ DEFAULT NOW()
|
|
||||||
);
|
|
||||||
|
|
||||||
-- MÓDULO 6: MQTT AUTH & ACL
|
|
||||||
CREATE TABLE IF NOT EXISTS mqtt_users (
|
CREATE TABLE IF NOT EXISTS mqtt_users (
|
||||||
username VARCHAR(100) PRIMARY KEY,
|
username VARCHAR(100) PRIMARY KEY,
|
||||||
password_hash VARCHAR(255) NOT NULL,
|
password_hash VARCHAR(255) NOT NULL,
|
||||||
@@ -174,18 +171,27 @@ CREATE TABLE IF NOT EXISTS mqtt_acls (
|
|||||||
id SERIAL PRIMARY KEY,
|
id SERIAL PRIMARY KEY,
|
||||||
username VARCHAR(100) NOT NULL REFERENCES mqtt_users(username) ON DELETE CASCADE,
|
username VARCHAR(100) NOT NULL REFERENCES mqtt_users(username) ON DELETE CASCADE,
|
||||||
topic VARCHAR(500) NOT NULL,
|
topic VARCHAR(500) NOT NULL,
|
||||||
rw INT DEFAULT 1, -- 1 = read, 2 = write, 3 = readwrite
|
rw INT DEFAULT 1,
|
||||||
created_at TIMESTAMPTZ DEFAULT NOW()
|
created_at TIMESTAMPTZ DEFAULT NOW(),
|
||||||
|
CONSTRAINT mqtt_acls_username_topic_key UNIQUE (username, topic)
|
||||||
);
|
);
|
||||||
|
|
||||||
|
-- Asegurar permiso de administración para el usuario de sistema 'admin'
|
||||||
|
INSERT INTO mqtt_acls (username, topic, rw)
|
||||||
|
SELECT 'admin', '#', 7
|
||||||
|
FROM mqtt_users
|
||||||
|
WHERE username = 'admin'
|
||||||
|
ON CONFLICT (username, topic) DO UPDATE SET rw = 7;
|
||||||
|
|
||||||
|
-- Asegurar que los permisos de despliegue sean siempre Read/Write/Subscribe (7)
|
||||||
|
UPDATE mqtt_acls SET rw = 7 WHERE topic LIKE 'omnioil/deploy/%';
|
||||||
|
|
||||||
|
|
||||||
-- MÓDULO 7: MOVIMIENTOS OPERATIVOS (ANH)
|
-- MÓDULO 6: MÓDULOS ANH (Operaciones, Pruebas y Laboratorio)
|
||||||
CREATE TABLE IF NOT EXISTS operation_movements (
|
CREATE TABLE IF NOT EXISTS operation_movements (
|
||||||
id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
|
id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
|
||||||
asset_id UUID NOT NULL REFERENCES edge_assets(id) ON DELETE CASCADE,
|
asset_id UUID NOT NULL REFERENCES edge_assets(id) ON DELETE CASCADE,
|
||||||
movement_type VARCHAR(30) NOT NULL
|
movement_type VARCHAR(30) NOT NULL CHECK (movement_type IN ('VENTA', 'RECIBO', 'TRANSFERENCIA', 'QUEMA', 'CONSUMO')),
|
||||||
CHECK (movement_type IN ('VENTA', 'RECIBO', 'TRANSFERENCIA', 'QUEMA', 'CONSUMO')),
|
|
||||||
start_time TIMESTAMPTZ NOT NULL,
|
start_time TIMESTAMPTZ NOT NULL,
|
||||||
end_time TIMESTAMPTZ NOT NULL,
|
end_time TIMESTAMPTZ NOT NULL,
|
||||||
volumen_neto DECIMAL(18, 4),
|
volumen_neto DECIMAL(18, 4),
|
||||||
@@ -194,6 +200,103 @@ CREATE TABLE IF NOT EXISTS operation_movements (
|
|||||||
CONSTRAINT check_movement_times CHECK (end_time >= start_time)
|
CONSTRAINT check_movement_times CHECK (end_time >= start_time)
|
||||||
);
|
);
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS operations_downtime (
|
||||||
|
id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
|
||||||
|
asset_id UUID NOT NULL REFERENCES edge_assets(id) ON DELETE CASCADE,
|
||||||
|
start_time TIMESTAMPTZ NOT NULL,
|
||||||
|
end_time TIMESTAMPTZ,
|
||||||
|
is_planned BOOLEAN NOT NULL DEFAULT false,
|
||||||
|
reason VARCHAR(255) NOT NULL,
|
||||||
|
comments TEXT,
|
||||||
|
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS well_tests (
|
||||||
|
id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
|
||||||
|
asset_id UUID NOT NULL REFERENCES edge_assets(id) ON DELETE CASCADE,
|
||||||
|
test_date TIMESTAMPTZ NOT NULL,
|
||||||
|
test_type VARCHAR(50) NOT NULL,
|
||||||
|
bopd NUMERIC(10, 2),
|
||||||
|
bwpd NUMERIC(10, 2),
|
||||||
|
gor NUMERIC(10, 2),
|
||||||
|
bsw NUMERIC(5, 2),
|
||||||
|
wellhead_pressure NUMERIC(10, 2),
|
||||||
|
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS lab_results (
|
||||||
|
id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
|
||||||
|
asset_id UUID NOT NULL REFERENCES edge_assets(id) ON DELETE CASCADE,
|
||||||
|
sample_time TIMESTAMPTZ NOT NULL,
|
||||||
|
results JSONB NOT NULL,
|
||||||
|
lab_technician VARCHAR(150),
|
||||||
|
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS meter_readings (
|
||||||
|
id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
|
||||||
|
asset_id UUID NOT NULL REFERENCES edge_assets(id) ON DELETE CASCADE,
|
||||||
|
reading_time TIMESTAMPTZ NOT NULL,
|
||||||
|
value NUMERIC(15, 2) NOT NULL,
|
||||||
|
unit VARCHAR(20) NOT NULL,
|
||||||
|
meter_factor NUMERIC(6, 4) DEFAULT 1.0,
|
||||||
|
corrected_value NUMERIC(15, 2),
|
||||||
|
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
|
||||||
|
);
|
||||||
|
|
||||||
|
-- MÓDULO 7: ALERTAS Y NOTIFICACIONES
|
||||||
|
CREATE TABLE IF NOT EXISTS telemetry_alarms (
|
||||||
|
id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
|
||||||
|
project_id UUID NOT NULL REFERENCES edge_projects(id) ON DELETE CASCADE,
|
||||||
|
asset_id UUID REFERENCES edge_assets(id) ON DELETE CASCADE,
|
||||||
|
variable_alias VARCHAR(100),
|
||||||
|
device_name VARCHAR(100),
|
||||||
|
current_value DOUBLE PRECISION,
|
||||||
|
alarm_min DOUBLE PRECISION,
|
||||||
|
alarm_max DOUBLE PRECISION,
|
||||||
|
alarm_type VARCHAR(50) NOT NULL,
|
||||||
|
message TEXT,
|
||||||
|
timestamp TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||||
|
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS notification_configs (
|
||||||
|
id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
|
||||||
|
project_id UUID NOT NULL REFERENCES edge_projects(id) ON DELETE CASCADE,
|
||||||
|
channel_type VARCHAR(20) NOT NULL,
|
||||||
|
destination TEXT NOT NULL,
|
||||||
|
is_active BOOLEAN DEFAULT true,
|
||||||
|
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
|
||||||
|
);
|
||||||
|
|
||||||
|
-- MÓDULO 8: AUDITORÍA
|
||||||
|
CREATE TABLE IF NOT EXISTS audit_logs (
|
||||||
|
id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
|
||||||
|
user_id UUID REFERENCES users(id) ON DELETE SET NULL,
|
||||||
|
project_id UUID REFERENCES edge_projects(id) ON DELETE SET NULL,
|
||||||
|
user_email VARCHAR(255),
|
||||||
|
action VARCHAR(100) NOT NULL,
|
||||||
|
table_name VARCHAR(100),
|
||||||
|
record_id VARCHAR(100),
|
||||||
|
entity_type VARCHAR(50),
|
||||||
|
entity_id UUID,
|
||||||
|
old_data JSONB,
|
||||||
|
new_data JSONB,
|
||||||
|
change_reason TEXT,
|
||||||
|
client_ip VARCHAR(45),
|
||||||
|
created_at TIMESTAMPTZ DEFAULT NOW()
|
||||||
|
);
|
||||||
|
|
||||||
|
-- MÓDULO 9: DOMAIN OUTBOX (Mensajería interna)
|
||||||
|
CREATE TABLE IF NOT EXISTS domain_outbox (
|
||||||
|
id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
|
||||||
|
event_type VARCHAR(100) NOT NULL,
|
||||||
|
payload JSONB NOT NULL,
|
||||||
|
status VARCHAR(20) DEFAULT 'PENDING',
|
||||||
|
created_at TIMESTAMPTZ DEFAULT NOW()
|
||||||
|
);
|
||||||
|
|
||||||
|
-- ÍNDICES DE RENDIMIENTO
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_agent_logs_project ON agent_logs(project_id, timestamp DESC);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_telemetry_alarms_project ON telemetry_alarms(project_id, timestamp DESC);
|
||||||
CREATE INDEX IF NOT EXISTS idx_operation_movements_asset ON operation_movements(asset_id);
|
CREATE INDEX IF NOT EXISTS idx_operation_movements_asset ON operation_movements(asset_id);
|
||||||
CREATE INDEX IF NOT EXISTS idx_operation_movements_type ON operation_movements(movement_type);
|
|
||||||
CREATE INDEX IF NOT EXISTS idx_operation_movements_time ON operation_movements(start_time);
|
|
||||||
|
|||||||
@@ -1,28 +0,0 @@
|
|||||||
-- MIGRACIÓN CONSOLIDADA DE MEJORAS (Seguridad MQTT + Telemetría)
|
|
||||||
CREATE EXTENSION IF NOT EXISTS pgcrypto;
|
|
||||||
|
|
||||||
-- 1. Actualización de Telemetría para soportar AssetID
|
|
||||||
-- (Asumiendo que la tabla base ya existe por el initial_schema)
|
|
||||||
DO $$
|
|
||||||
BEGIN
|
|
||||||
IF NOT EXISTS (SELECT 1 FROM information_schema.columns WHERE table_name='telemetry_raw' AND column_name='asset_id') THEN
|
|
||||||
ALTER TABLE telemetry_raw ADD COLUMN asset_id UUID REFERENCES edge_assets(id) ON DELETE CASCADE;
|
|
||||||
END IF;
|
|
||||||
END $$;
|
|
||||||
|
|
||||||
-- 2. Asegurar unicidad en ACLs de MQTT
|
|
||||||
DO $$
|
|
||||||
BEGIN
|
|
||||||
-- Limpiar duplicados antes de aplicar el UNIQUE
|
|
||||||
-- Dejamos solo la fila con el ID más alto por cada (username, topic)
|
|
||||||
DELETE FROM mqtt_acls
|
|
||||||
WHERE id NOT IN (
|
|
||||||
SELECT MAX(id)
|
|
||||||
FROM mqtt_acls
|
|
||||||
GROUP BY username, topic
|
|
||||||
);
|
|
||||||
|
|
||||||
IF NOT EXISTS (SELECT 1 FROM pg_constraint WHERE conname = 'mqtt_acls_username_topic_key') THEN
|
|
||||||
ALTER TABLE mqtt_acls ADD CONSTRAINT mqtt_acls_username_topic_key UNIQUE (username, topic);
|
|
||||||
END IF;
|
|
||||||
END $$;
|
|
||||||
@@ -0,0 +1,4 @@
|
|||||||
|
-- Migration: 20260421000000_add_agent_ip.sql
|
||||||
|
-- Add agent_ip column to agent_logs table to match the EdgeAgentLog struct.
|
||||||
|
|
||||||
|
ALTER TABLE agent_logs ADD COLUMN IF NOT EXISTS agent_ip VARCHAR(45);
|
||||||
@@ -0,0 +1,5 @@
|
|||||||
|
-- Add external API integration fields to edge_assets for multitenant data collection
|
||||||
|
ALTER TABLE edge_assets
|
||||||
|
ADD COLUMN IF NOT EXISTS external_api_url TEXT,
|
||||||
|
ADD COLUMN IF NOT EXISTS external_api_key TEXT,
|
||||||
|
ADD COLUMN IF NOT EXISTS is_collector_enabled BOOLEAN DEFAULT FALSE;
|
||||||
@@ -0,0 +1,18 @@
|
|||||||
|
-- Migración para soportar el historial de reportes ANH (Resolución 0651)
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS anh_reports (
|
||||||
|
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||||
|
filename VARCHAR(255) NOT NULL,
|
||||||
|
generated_at TIMESTAMPTZ DEFAULT NOW() NOT NULL,
|
||||||
|
status VARCHAR(50) NOT NULL, -- GENERATED, SENT, FAILED
|
||||||
|
hash_sha384 TEXT NOT NULL,
|
||||||
|
content JSONB NOT NULL,
|
||||||
|
size_bytes BIGINT NOT NULL,
|
||||||
|
period_start TIMESTAMPTZ,
|
||||||
|
period_end TIMESTAMPTZ,
|
||||||
|
sent_at TIMESTAMPTZ
|
||||||
|
);
|
||||||
|
|
||||||
|
-- Índices para búsqueda rápida
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_anh_reports_generated_at ON anh_reports (generated_at DESC);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_anh_reports_status ON anh_reports (status);
|
||||||
@@ -10,13 +10,14 @@ use chrono::{Duration, Utc};
|
|||||||
use jsonwebtoken::{DecodingKey, EncodingKey, Header, TokenData, Validation, decode, encode};
|
use jsonwebtoken::{DecodingKey, EncodingKey, Header, TokenData, Validation, decode, encode};
|
||||||
use serde::{Deserialize, Serialize};
|
use serde::{Deserialize, Serialize};
|
||||||
use std::env;
|
use std::env;
|
||||||
|
use sqlx::Row;
|
||||||
|
|
||||||
#[derive(Debug)]
|
#[derive(Debug)]
|
||||||
pub enum AuthError {
|
pub enum AuthError {
|
||||||
InvalidCredentials,
|
InvalidCredentials,
|
||||||
EmailAlreadyExists,
|
|
||||||
AccountDisabled,
|
AccountDisabled,
|
||||||
InvalidInput(String),
|
RegistrationDisabled,
|
||||||
|
Forbidden,
|
||||||
Internal(anyhow::Error),
|
Internal(anyhow::Error),
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -27,12 +28,19 @@ impl IntoResponse for AuthError {
|
|||||||
StatusCode::UNAUTHORIZED,
|
StatusCode::UNAUTHORIZED,
|
||||||
"Credenciales inválidas".to_string(),
|
"Credenciales inválidas".to_string(),
|
||||||
),
|
),
|
||||||
AuthError::EmailAlreadyExists => (StatusCode::CONFLICT, "El correo ya se encuentra registrado".to_string()),
|
|
||||||
AuthError::AccountDisabled => (
|
AuthError::AccountDisabled => (
|
||||||
StatusCode::FORBIDDEN,
|
StatusCode::FORBIDDEN,
|
||||||
"Tu cuenta ha sido desactivada. Contacta al administrador.".to_string(),
|
"Tu cuenta ha sido desactivada. Contacta al administrador.".to_string(),
|
||||||
),
|
),
|
||||||
AuthError::InvalidInput(msg) => (StatusCode::BAD_REQUEST, msg),
|
AuthError::RegistrationDisabled => (
|
||||||
|
StatusCode::FORBIDDEN,
|
||||||
|
"El alta publica esta deshabilitada. Solicita acceso a un administrador."
|
||||||
|
.to_string(),
|
||||||
|
),
|
||||||
|
AuthError::Forbidden => (
|
||||||
|
StatusCode::FORBIDDEN,
|
||||||
|
"No tienes permisos para realizar esta acción".to_string(),
|
||||||
|
),
|
||||||
AuthError::Internal(ref e) => {
|
AuthError::Internal(ref e) => {
|
||||||
tracing::error!("Auth internal error: {:?}", e);
|
tracing::error!("Auth internal error: {:?}", e);
|
||||||
(
|
(
|
||||||
@@ -52,10 +60,37 @@ impl IntoResponse for AuthError {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::AuthError;
|
||||||
|
use axum::{body, http::StatusCode, response::IntoResponse};
|
||||||
|
use serde_json::{Value, json};
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn registration_disabled_maps_to_forbidden_response() {
|
||||||
|
let response = AuthError::RegistrationDisabled.into_response();
|
||||||
|
|
||||||
|
assert_eq!(response.status(), StatusCode::FORBIDDEN);
|
||||||
|
|
||||||
|
let body = body::to_bytes(response.into_body(), usize::MAX)
|
||||||
|
.await
|
||||||
|
.expect("response body");
|
||||||
|
let payload: Value = serde_json::from_slice(&body).expect("json response");
|
||||||
|
|
||||||
|
assert_eq!(
|
||||||
|
payload,
|
||||||
|
json!({
|
||||||
|
"error": "El alta publica esta deshabilitada. Solicita acceso a un administrador."
|
||||||
|
})
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
#[derive(Debug, Serialize, Deserialize, Clone)]
|
#[derive(Debug, Serialize, Deserialize, Clone)]
|
||||||
pub struct Claims {
|
pub struct Claims {
|
||||||
pub sub: String, // User ID
|
pub sub: String, // User ID
|
||||||
pub role: String,
|
pub role: String,
|
||||||
|
pub email: String,
|
||||||
pub projects: Vec<uuid::Uuid>, // Project IDs the user has access to
|
pub projects: Vec<uuid::Uuid>, // Project IDs the user has access to
|
||||||
pub exp: usize,
|
pub exp: usize,
|
||||||
}
|
}
|
||||||
@@ -73,7 +108,7 @@ pub struct TokenResponse {
|
|||||||
pub expires_in: usize,
|
pub expires_in: usize,
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn create_jwt_tokens(uid: &str, role: &str, projects: Vec<uuid::Uuid>) -> Result<TokenResponse, jsonwebtoken::errors::Error> {
|
pub fn create_jwt_tokens(uid: &str, role: &str, email: &str, projects: Vec<uuid::Uuid>) -> Result<TokenResponse, jsonwebtoken::errors::Error> {
|
||||||
let secret = env::var("JWT_SECRET").expect("JWT_SECRET must be set");
|
let secret = env::var("JWT_SECRET").expect("JWT_SECRET must be set");
|
||||||
|
|
||||||
// Access Token (15 minutos para mayor seguridad)
|
// Access Token (15 minutos para mayor seguridad)
|
||||||
@@ -85,6 +120,7 @@ pub fn create_jwt_tokens(uid: &str, role: &str, projects: Vec<uuid::Uuid>) -> Re
|
|||||||
let access_claims = Claims {
|
let access_claims = Claims {
|
||||||
sub: uid.to_owned(),
|
sub: uid.to_owned(),
|
||||||
role: role.to_owned(),
|
role: role.to_owned(),
|
||||||
|
email: email.to_owned(),
|
||||||
projects,
|
projects,
|
||||||
exp: access_exp as usize,
|
exp: access_exp as usize,
|
||||||
};
|
};
|
||||||
@@ -119,8 +155,6 @@ pub fn create_jwt_tokens(uid: &str, role: &str, projects: Vec<uuid::Uuid>) -> Re
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
use uuid::Uuid;
|
|
||||||
|
|
||||||
pub fn verify_jwt(token: &str) -> Result<TokenData<Claims>, jsonwebtoken::errors::Error> {
|
pub fn verify_jwt(token: &str) -> Result<TokenData<Claims>, jsonwebtoken::errors::Error> {
|
||||||
let secret = env::var("JWT_SECRET").expect("JWT_SECRET must be set");
|
let secret = env::var("JWT_SECRET").expect("JWT_SECRET must be set");
|
||||||
decode(
|
decode(
|
||||||
@@ -141,11 +175,14 @@ pub fn verify_refresh_jwt(token: &str) -> Result<TokenData<RefreshClaims>, jsonw
|
|||||||
|
|
||||||
impl<S> FromRequestParts<S> for Claims
|
impl<S> FromRequestParts<S> for Claims
|
||||||
where
|
where
|
||||||
|
sqlx::PgPool: axum::extract::FromRef<S>,
|
||||||
S: Send + Sync,
|
S: Send + Sync,
|
||||||
{
|
{
|
||||||
type Rejection = AuthError;
|
type Rejection = AuthError;
|
||||||
|
|
||||||
async fn from_request_parts(parts: &mut Parts, _state: &S) -> Result<Self, Self::Rejection> {
|
async fn from_request_parts(parts: &mut Parts, state: &S) -> Result<Self, Self::Rejection> {
|
||||||
|
use axum::extract::FromRef;
|
||||||
|
|
||||||
// Extraer Bearer token del header Authorization
|
// Extraer Bearer token del header Authorization
|
||||||
let auth_header = parts
|
let auth_header = parts
|
||||||
.headers
|
.headers
|
||||||
@@ -159,9 +196,53 @@ where
|
|||||||
|
|
||||||
let token = &auth_header[7..];
|
let token = &auth_header[7..];
|
||||||
|
|
||||||
// Validar el token
|
// Validar el token (Firma y Expiración)
|
||||||
let token_data = verify_jwt(token).map_err(|_| AuthError::InvalidCredentials)?;
|
let token_data = verify_jwt(token).map_err(|_| AuthError::InvalidCredentials)?;
|
||||||
|
let claims = token_data.claims;
|
||||||
|
|
||||||
Ok(token_data.claims)
|
// VALIDACIÓN EXTRA: Verificar que el usuario exista en la DB y esté activo
|
||||||
|
let pool = sqlx::PgPool::from_ref(state);
|
||||||
|
let user_id = uuid::Uuid::parse_str(&claims.sub).map_err(|_| AuthError::InvalidCredentials)?;
|
||||||
|
|
||||||
|
let user_exists = sqlx::query("SELECT is_active FROM users WHERE id = $1")
|
||||||
|
.bind(user_id)
|
||||||
|
.fetch_optional(&pool)
|
||||||
|
.await
|
||||||
|
.map_err(|e| AuthError::Internal(anyhow::anyhow!(e)))?;
|
||||||
|
|
||||||
|
match user_exists {
|
||||||
|
Some(row) => {
|
||||||
|
let is_active: bool = row.try_get("is_active").unwrap_or(false);
|
||||||
|
if !is_active {
|
||||||
|
return Err(AuthError::AccountDisabled);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
None => {
|
||||||
|
// El usuario no existe (DB limpia o usuario eliminado)
|
||||||
|
return Err(AuthError::InvalidCredentials);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Ok(claims)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Extractor que garantiza acceso solo a usuarios con rol admin o superadmin.
|
||||||
|
/// Si el JWT es válido pero el rol no es admin/superadmin, retorna 403 Forbidden.
|
||||||
|
pub struct AdminClaims(pub Claims);
|
||||||
|
|
||||||
|
impl<S> FromRequestParts<S> for AdminClaims
|
||||||
|
where
|
||||||
|
sqlx::PgPool: axum::extract::FromRef<S>,
|
||||||
|
S: Send + Sync,
|
||||||
|
{
|
||||||
|
type Rejection = AuthError;
|
||||||
|
|
||||||
|
async fn from_request_parts(parts: &mut Parts, state: &S) -> Result<Self, Self::Rejection> {
|
||||||
|
let claims = Claims::from_request_parts(parts, state).await?;
|
||||||
|
if claims.role != "admin" && claims.role != "superadmin" {
|
||||||
|
return Err(AuthError::Forbidden);
|
||||||
|
}
|
||||||
|
Ok(AdminClaims(claims))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
138
services/backend-api/src/handlers/anh_reports.rs
Normal file
138
services/backend-api/src/handlers/anh_reports.rs
Normal file
@@ -0,0 +1,138 @@
|
|||||||
|
use axum::{
|
||||||
|
extract::{Path, State},
|
||||||
|
http::{header, HeaderMap, StatusCode},
|
||||||
|
Json,
|
||||||
|
};
|
||||||
|
use sqlx::PgPool;
|
||||||
|
use uuid::Uuid;
|
||||||
|
use sha2::{Sha384, Digest};
|
||||||
|
use shared_lib::models::ANHReport;
|
||||||
|
use crate::auth::Claims;
|
||||||
|
use crate::errors::AppError;
|
||||||
|
|
||||||
|
/// GET /api/anh/reports — Listar historial de reportes.
|
||||||
|
pub async fn list_reports(
|
||||||
|
State(pool): State<PgPool>,
|
||||||
|
_claims: Claims,
|
||||||
|
) -> Result<Json<Vec<ANHReport>>, AppError> {
|
||||||
|
let reports = sqlx::query_as::<_, ANHReport>(
|
||||||
|
"SELECT id, filename, generated_at, status, hash_sha384, content, size_bytes, period_start, period_end, sent_at
|
||||||
|
FROM anh_reports
|
||||||
|
ORDER BY generated_at DESC"
|
||||||
|
)
|
||||||
|
.fetch_all(&pool)
|
||||||
|
.await?;
|
||||||
|
|
||||||
|
Ok(Json(reports))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// GET /api/anh/reports/:id — Obtener un reporte específico (JSON).
|
||||||
|
pub async fn get_report(
|
||||||
|
State(pool): State<PgPool>,
|
||||||
|
_claims: Claims,
|
||||||
|
Path(id): Path<Uuid>,
|
||||||
|
) -> Result<Json<ANHReport>, AppError> {
|
||||||
|
let report = sqlx::query_as::<_, ANHReport>(
|
||||||
|
"SELECT * FROM anh_reports WHERE id = $1"
|
||||||
|
)
|
||||||
|
.bind(id)
|
||||||
|
.fetch_optional(&pool)
|
||||||
|
.await?
|
||||||
|
.ok_or(AppError::NotFound("Reporte".to_string()))?;
|
||||||
|
|
||||||
|
Ok(Json(report))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// GET /api/anh/reports/:id/download — Descargar el archivo JSON.
|
||||||
|
pub async fn download_report(
|
||||||
|
State(pool): State<PgPool>,
|
||||||
|
Path(id): Path<Uuid>,
|
||||||
|
) -> Result<(HeaderMap, String), AppError> {
|
||||||
|
let report = sqlx::query_as::<_, ANHReport>(
|
||||||
|
"SELECT * FROM anh_reports WHERE id = $1"
|
||||||
|
)
|
||||||
|
.bind(id)
|
||||||
|
.fetch_optional(&pool)
|
||||||
|
.await?
|
||||||
|
.ok_or(AppError::NotFound("Reporte".to_string()))?;
|
||||||
|
|
||||||
|
let mut headers = HeaderMap::new();
|
||||||
|
headers.insert(
|
||||||
|
header::CONTENT_TYPE,
|
||||||
|
"application/json".parse().unwrap(),
|
||||||
|
);
|
||||||
|
headers.insert(
|
||||||
|
header::CONTENT_DISPOSITION,
|
||||||
|
format!("attachment; filename=\"{}\"", report.filename)
|
||||||
|
.parse()
|
||||||
|
.unwrap(),
|
||||||
|
);
|
||||||
|
|
||||||
|
let content = serde_json::to_string_pretty(&report.content)
|
||||||
|
.map_err(anyhow::Error::msg)?;
|
||||||
|
|
||||||
|
Ok((headers, content))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// GET /api/anh/reports/:id/hash — Descargar el hash SHA-384 del archivo.
|
||||||
|
pub async fn download_report_hash(
|
||||||
|
State(pool): State<PgPool>,
|
||||||
|
Path(id): Path<Uuid>,
|
||||||
|
) -> Result<(HeaderMap, String), AppError> {
|
||||||
|
let report = sqlx::query_as::<_, ANHReport>(
|
||||||
|
"SELECT hash_sha384, filename FROM anh_reports WHERE id = $1"
|
||||||
|
)
|
||||||
|
.bind(id)
|
||||||
|
.fetch_optional(&pool)
|
||||||
|
.await?
|
||||||
|
.ok_or(AppError::NotFound("Reporte".to_string()))?;
|
||||||
|
|
||||||
|
let mut headers = HeaderMap::new();
|
||||||
|
headers.insert(
|
||||||
|
header::CONTENT_TYPE,
|
||||||
|
"text/plain".parse().unwrap(),
|
||||||
|
);
|
||||||
|
headers.insert(
|
||||||
|
header::CONTENT_DISPOSITION,
|
||||||
|
format!("attachment; filename=\"{}.sha384\"", report.filename)
|
||||||
|
.parse()
|
||||||
|
.unwrap(),
|
||||||
|
);
|
||||||
|
|
||||||
|
Ok((headers, report.hash_sha384))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Función interna para generar y guardar un reporte.
|
||||||
|
pub async fn save_report(
|
||||||
|
pool: &PgPool,
|
||||||
|
filename: String,
|
||||||
|
content: serde_json::Value,
|
||||||
|
period_start: Option<chrono::DateTime<chrono::Utc>>,
|
||||||
|
period_end: Option<chrono::DateTime<chrono::Utc>>,
|
||||||
|
) -> Result<ANHReport, AppError> {
|
||||||
|
let json_string = serde_json::to_string(&content)
|
||||||
|
.map_err(anyhow::Error::msg)?;
|
||||||
|
|
||||||
|
// Calcular SHA-384 del contenido completo
|
||||||
|
let mut hasher = Sha384::new();
|
||||||
|
hasher.update(json_string.as_bytes());
|
||||||
|
let hash_sha384 = format!("{:x}", hasher.finalize());
|
||||||
|
let size_bytes = json_string.len() as i64;
|
||||||
|
|
||||||
|
let report = sqlx::query_as::<_, ANHReport>(
|
||||||
|
r#"INSERT INTO anh_reports (filename, status, hash_sha384, content, size_bytes, period_start, period_end)
|
||||||
|
VALUES ($1, $2, $3, $4, $5, $6, $7)
|
||||||
|
RETURNING *"#
|
||||||
|
)
|
||||||
|
.bind(filename)
|
||||||
|
.bind("GENERATED")
|
||||||
|
.bind(hash_sha384)
|
||||||
|
.bind(content)
|
||||||
|
.bind(size_bytes)
|
||||||
|
.bind(period_start)
|
||||||
|
.bind(period_end)
|
||||||
|
.fetch_one(pool)
|
||||||
|
.await?;
|
||||||
|
|
||||||
|
Ok(report)
|
||||||
|
}
|
||||||
39
services/backend-api/src/handlers/audit_helper.rs
Normal file
39
services/backend-api/src/handlers/audit_helper.rs
Normal file
@@ -0,0 +1,39 @@
|
|||||||
|
use sqlx::PgPool;
|
||||||
|
use uuid::Uuid;
|
||||||
|
use serde_json::Value;
|
||||||
|
|
||||||
|
pub async fn log_audit(
|
||||||
|
pool: &PgPool,
|
||||||
|
project_id: Option<Uuid>,
|
||||||
|
user_id: Option<Uuid>,
|
||||||
|
user_email: Option<String>,
|
||||||
|
action: &str,
|
||||||
|
entity_type: &str,
|
||||||
|
entity_id: Uuid,
|
||||||
|
old_values: Option<Value>,
|
||||||
|
new_values: Option<Value>,
|
||||||
|
ip: Option<String>,
|
||||||
|
) {
|
||||||
|
let res = sqlx::query(
|
||||||
|
r#"
|
||||||
|
INSERT INTO audit_logs
|
||||||
|
(project_id, user_id, user_email, action, entity_type, entity_id, old_values, new_values, client_ip)
|
||||||
|
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9)
|
||||||
|
"#
|
||||||
|
)
|
||||||
|
.bind(project_id)
|
||||||
|
.bind(user_id)
|
||||||
|
.bind(user_email)
|
||||||
|
.bind(action)
|
||||||
|
.bind(entity_type)
|
||||||
|
.bind(entity_id)
|
||||||
|
.bind(old_values)
|
||||||
|
.bind(new_values)
|
||||||
|
.bind(ip)
|
||||||
|
.execute(pool)
|
||||||
|
.await;
|
||||||
|
|
||||||
|
if let Err(e) = res {
|
||||||
|
tracing::error!("Failed to write audit log: {}", e);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -2,12 +2,8 @@ use crate::{
|
|||||||
auth::{AuthError, create_jwt_tokens},
|
auth::{AuthError, create_jwt_tokens},
|
||||||
db::DbPool,
|
db::DbPool,
|
||||||
};
|
};
|
||||||
use argon2::{
|
use argon2::{Argon2, password_hash::{PasswordHash, PasswordVerifier}};
|
||||||
Argon2,
|
|
||||||
password_hash::{PasswordHash, PasswordHasher, PasswordVerifier, SaltString, rand_core},
|
|
||||||
};
|
|
||||||
use axum::{Json, extract::State, response::IntoResponse};
|
use axum::{Json, extract::State, response::IntoResponse};
|
||||||
use rand_core::OsRng;
|
|
||||||
use serde::Deserialize;
|
use serde::Deserialize;
|
||||||
use serde_json::json;
|
use serde_json::json;
|
||||||
|
|
||||||
@@ -15,21 +11,18 @@ use serde_json::json;
|
|||||||
pub struct LoginPayload {
|
pub struct LoginPayload {
|
||||||
email: String,
|
email: String,
|
||||||
password: String,
|
password: String,
|
||||||
}
|
two_factor_code: Option<String>,
|
||||||
|
|
||||||
#[derive(Deserialize)]
|
|
||||||
pub struct RegisterPayload {
|
|
||||||
email: String,
|
|
||||||
password: String,
|
|
||||||
full_name: String,
|
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(sqlx::FromRow)]
|
#[derive(sqlx::FromRow)]
|
||||||
struct UserRow {
|
struct UserRow {
|
||||||
id: uuid::Uuid,
|
id: uuid::Uuid,
|
||||||
|
email: String,
|
||||||
password_hash: String,
|
password_hash: String,
|
||||||
role_name: String,
|
role_name: String,
|
||||||
is_active: bool,
|
is_active: bool,
|
||||||
|
two_factor_enabled: bool,
|
||||||
|
two_factor_secret: Option<String>,
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(sqlx::FromRow, serde::Serialize)]
|
#[derive(sqlx::FromRow, serde::Serialize)]
|
||||||
@@ -44,7 +37,7 @@ pub async fn login(
|
|||||||
) -> Result<impl IntoResponse, AuthError> {
|
) -> Result<impl IntoResponse, AuthError> {
|
||||||
let user = sqlx::query_as::<sqlx::Postgres, UserRow>(
|
let user = sqlx::query_as::<sqlx::Postgres, UserRow>(
|
||||||
r#"
|
r#"
|
||||||
SELECT u.id, u.password_hash, r.name as role_name, u.is_active
|
SELECT u.id, u.email, u.password_hash, r.name as role_name, u.is_active, u.two_factor_enabled, u.two_factor_secret
|
||||||
FROM users u
|
FROM users u
|
||||||
JOIN roles r ON u.role_id = r.id
|
JOIN roles r ON u.role_id = r.id
|
||||||
WHERE u.email = $1
|
WHERE u.email = $1
|
||||||
@@ -71,6 +64,30 @@ pub async fn login(
|
|||||||
.verify_password(payload.password.as_bytes(), &parsed_hash)
|
.verify_password(payload.password.as_bytes(), &parsed_hash)
|
||||||
.is_ok()
|
.is_ok()
|
||||||
{
|
{
|
||||||
|
// 1.5 Verificar 2FA si está habilitado
|
||||||
|
if user.two_factor_enabled {
|
||||||
|
let code = payload.two_factor_code.ok_or_else(|| {
|
||||||
|
AuthError::Internal(anyhow::anyhow!("Se requiere código de verificación (2FA)"))
|
||||||
|
})?;
|
||||||
|
|
||||||
|
let secret = user.two_factor_secret.as_ref().ok_or_else(|| {
|
||||||
|
AuthError::Internal(anyhow::anyhow!("Configuración 2FA corrupta"))
|
||||||
|
})?;
|
||||||
|
|
||||||
|
let totp = totp_rs::TOTP::new(
|
||||||
|
totp_rs::Algorithm::SHA1,
|
||||||
|
6,
|
||||||
|
1,
|
||||||
|
30,
|
||||||
|
secret.as_bytes().to_vec(),
|
||||||
|
Some("OmniOil".to_string()),
|
||||||
|
user.email.clone(),
|
||||||
|
).map_err(|_| AuthError::Internal(anyhow::anyhow!("Error al inicializar 2FA")))?;
|
||||||
|
|
||||||
|
if !totp.check_current(&code).unwrap_or(false) {
|
||||||
|
return Err(AuthError::InvalidCredentials); // O un error específico de 2FA
|
||||||
|
}
|
||||||
|
}
|
||||||
// 2. Actualizar último inicio de sesión (asíncrono)
|
// 2. Actualizar último inicio de sesión (asíncrono)
|
||||||
let pool_clone = pool.clone();
|
let pool_clone = pool.clone();
|
||||||
let user_id = user.id;
|
let user_id = user.id;
|
||||||
@@ -99,7 +116,7 @@ pub async fn login(
|
|||||||
})?;
|
})?;
|
||||||
|
|
||||||
let project_ids: Vec<uuid::Uuid> = projects.iter().map(|p| p.id).collect();
|
let project_ids: Vec<uuid::Uuid> = projects.iter().map(|p| p.id).collect();
|
||||||
let tokens = create_jwt_tokens(&user.id.to_string(), &user.role_name, project_ids)
|
let tokens = create_jwt_tokens(&user.id.to_string(), &user.role_name, &user.email, project_ids)
|
||||||
.map_err(|_| AuthError::Internal(anyhow::anyhow!("Error al crear tokens")))?;
|
.map_err(|_| AuthError::Internal(anyhow::anyhow!("Error al crear tokens")))?;
|
||||||
|
|
||||||
// 3. Almacenar el hash del refresh token en la base de datos
|
// 3. Almacenar el hash del refresh token en la base de datos
|
||||||
@@ -170,7 +187,7 @@ pub async fn refresh(
|
|||||||
.map_err(|_| AuthError::InvalidCredentials)?;
|
.map_err(|_| AuthError::InvalidCredentials)?;
|
||||||
|
|
||||||
let user = sqlx::query_as::<sqlx::Postgres, UserRow>(
|
let user = sqlx::query_as::<sqlx::Postgres, UserRow>(
|
||||||
"SELECT u.id, u.password_hash, r.name as role_name, u.is_active FROM users u JOIN roles r ON u.role_id = r.id WHERE u.id = $1"
|
"SELECT u.id, u.email, u.password_hash, r.name as role_name, u.is_active, u.two_factor_enabled, u.two_factor_secret FROM users u JOIN roles r ON u.role_id = r.id WHERE u.id = $1"
|
||||||
)
|
)
|
||||||
.bind(user_id)
|
.bind(user_id)
|
||||||
.fetch_one(&pool)
|
.fetch_one(&pool)
|
||||||
@@ -192,7 +209,7 @@ pub async fn refresh(
|
|||||||
.map_err(|_| AuthError::Internal(anyhow::anyhow!("Error de proyectos")))?;
|
.map_err(|_| AuthError::Internal(anyhow::anyhow!("Error de proyectos")))?;
|
||||||
|
|
||||||
let project_ids: Vec<uuid::Uuid> = projects.iter().map(|p| p.id).collect();
|
let project_ids: Vec<uuid::Uuid> = projects.iter().map(|p| p.id).collect();
|
||||||
let tokens = create_jwt_tokens(&user.id.to_string(), &user.role_name, project_ids)
|
let tokens = create_jwt_tokens(&user.id.to_string(), &user.role_name, &user.email, project_ids)
|
||||||
.map_err(|_| AuthError::Internal(anyhow::anyhow!("Error al refrescar")))?;
|
.map_err(|_| AuthError::Internal(anyhow::anyhow!("Error al refrescar")))?;
|
||||||
|
|
||||||
// Opcional: Rotar el refresh token aquí si se desea mayor seguridad
|
// Opcional: Rotar el refresh token aquí si se desea mayor seguridad
|
||||||
@@ -208,78 +225,141 @@ pub async fn refresh(
|
|||||||
Err(AuthError::InvalidCredentials)
|
Err(AuthError::InvalidCredentials)
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(sqlx::FromRow)]
|
|
||||||
struct RegisterResult {
|
|
||||||
id: uuid::Uuid,
|
|
||||||
}
|
|
||||||
|
|
||||||
pub async fn register(
|
pub async fn register(
|
||||||
State(pool): State<DbPool>,
|
State(pool): State<DbPool>,
|
||||||
Json(payload): Json<RegisterPayload>,
|
Json(payload): Json<SetupRegisterPayload>,
|
||||||
) -> Result<impl IntoResponse, AuthError> {
|
) -> Result<Json<serde_json::Value>, AuthError> {
|
||||||
// 1. Validaciones de entrada
|
// 1. Verificar si el email ya existe
|
||||||
if payload.email.is_empty() || !payload.email.contains('@') {
|
let existing: Option<uuid::Uuid> = sqlx::query_scalar("SELECT id FROM users WHERE email = $1")
|
||||||
return Err(AuthError::InvalidInput(
|
.bind(&payload.email)
|
||||||
"Formato de correo electrónico inválido".to_string(),
|
.fetch_optional(&pool)
|
||||||
));
|
.await
|
||||||
|
.map_err(|e| {
|
||||||
|
tracing::error!("Email check error: {}", e);
|
||||||
|
AuthError::Internal(anyhow::anyhow!("Error al verificar email"))
|
||||||
|
})?;
|
||||||
|
|
||||||
|
if existing.is_some() {
|
||||||
|
return Err(AuthError::Internal(anyhow::anyhow!("El correo electrónico ya está registrado")));
|
||||||
}
|
}
|
||||||
|
|
||||||
if payload.password.len() < 8 {
|
// 2. Hash password (Argon2)
|
||||||
return Err(AuthError::InvalidInput(
|
use argon2::{Argon2, password_hash::{self, SaltString, PasswordHasher}};
|
||||||
"La contraseña debe tener al menos 8 caracteres".to_string(),
|
|
||||||
));
|
|
||||||
}
|
|
||||||
|
|
||||||
if payload.full_name.trim().is_empty() {
|
let salt = SaltString::generate(&mut password_hash::rand_core::OsRng);
|
||||||
return Err(AuthError::InvalidInput(
|
|
||||||
"El nombre completo es obligatorio".to_string(),
|
|
||||||
));
|
|
||||||
}
|
|
||||||
|
|
||||||
// 2. Cifrar la contraseña
|
|
||||||
let salt = SaltString::generate(&mut OsRng);
|
|
||||||
let argon2 = Argon2::default();
|
let argon2 = Argon2::default();
|
||||||
let password_hash = argon2
|
let password_hash = argon2
|
||||||
.hash_password(payload.password.as_bytes(), &salt)
|
.hash_password(payload.password.as_bytes(), &salt)
|
||||||
.map_err(|e| {
|
.map_err(|e| AuthError::Internal(anyhow::anyhow!("Hash error: {}", e)))?
|
||||||
tracing::error!("Hashing error: {}", e);
|
|
||||||
AuthError::Internal(anyhow::anyhow!("Error de cifrado"))
|
|
||||||
})?
|
|
||||||
.to_string();
|
.to_string();
|
||||||
|
|
||||||
// 2. Insert the user
|
// 3. Obtener Role ID (asignamos admin para que puedan crear sus propios proyectos)
|
||||||
// We default to role 'admin' (id 3 as per init.sql) if not specified
|
let role_id: i32 = sqlx::query_scalar("SELECT id FROM roles WHERE name = 'admin'")
|
||||||
//El operador es solo para la pwa.
|
.fetch_one(&pool)
|
||||||
let register_result: RegisterResult = sqlx::query_as::<sqlx::Postgres, RegisterResult>(
|
.await
|
||||||
r#"
|
.map_err(|_| AuthError::Internal(anyhow::anyhow!("Roles no inicializados")))?;
|
||||||
INSERT INTO users (email, password_hash, full_name, role_id)
|
|
||||||
VALUES ($1, $2, $3, (SELECT id FROM roles WHERE name = 'admin'))
|
// 4. Crear usuario
|
||||||
RETURNING id
|
let user_id: uuid::Uuid = sqlx::query_scalar(
|
||||||
"#,
|
"INSERT INTO users (email, password_hash, full_name, role_id) VALUES ($1, $2, $3, $4) RETURNING id"
|
||||||
)
|
)
|
||||||
.bind(&payload.email)
|
.bind(&payload.email)
|
||||||
.bind(&password_hash)
|
.bind(&password_hash)
|
||||||
.bind(&payload.full_name)
|
.bind(&payload.full_name)
|
||||||
|
.bind(role_id)
|
||||||
.fetch_one(&pool)
|
.fetch_one(&pool)
|
||||||
.await
|
.await
|
||||||
.map_err(|e| {
|
.map_err(|e| {
|
||||||
tracing::error!("User creation error: {}", e);
|
tracing::error!("User creation error: {}", e);
|
||||||
let err_str = e.to_string().to_lowercase();
|
AuthError::Internal(anyhow::anyhow!("Error al crear usuario"))
|
||||||
if err_str.contains("unique constraint") || err_str.contains("already exists") {
|
|
||||||
AuthError::EmailAlreadyExists
|
|
||||||
} else {
|
|
||||||
AuthError::Internal(anyhow::anyhow!("Error interno al crear usuario"))
|
|
||||||
}
|
|
||||||
})?;
|
})?;
|
||||||
|
|
||||||
tracing::info!(
|
Ok(Json(json!({
|
||||||
"User created: {} with ID: {}",
|
"id": user_id,
|
||||||
payload.email,
|
"email": payload.email,
|
||||||
register_result.id
|
"status": "success",
|
||||||
);
|
"message": "Usuario registrado exitosamente"
|
||||||
|
})))
|
||||||
Ok((
|
}
|
||||||
axum::http::StatusCode::CREATED,
|
|
||||||
Json(json!({ "id": register_result.id, "email": payload.email })),
|
#[derive(serde::Serialize)]
|
||||||
))
|
pub struct SetupStatus {
|
||||||
|
pub setup_required: bool,
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn get_setup_status(
|
||||||
|
State(pool): State<DbPool>,
|
||||||
|
) -> Result<Json<SetupStatus>, AuthError> {
|
||||||
|
let count: i64 = sqlx::query_scalar("SELECT COUNT(*) FROM users")
|
||||||
|
.fetch_one(&pool)
|
||||||
|
.await
|
||||||
|
.map_err(|e| {
|
||||||
|
tracing::error!("Setup check error: {}", e);
|
||||||
|
AuthError::Internal(anyhow::anyhow!("Error al verificar estado del sistema"))
|
||||||
|
})?;
|
||||||
|
|
||||||
|
Ok(Json(SetupStatus {
|
||||||
|
setup_required: count == 0,
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Deserialize)]
|
||||||
|
pub struct SetupRegisterPayload {
|
||||||
|
pub email: String,
|
||||||
|
pub password: String,
|
||||||
|
pub full_name: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn setup_register(
|
||||||
|
State(pool): State<DbPool>,
|
||||||
|
Json(payload): Json<SetupRegisterPayload>,
|
||||||
|
) -> Result<impl IntoResponse, AuthError> {
|
||||||
|
// 1. Verificar si ya hay usuarios (Seguridad Crítica)
|
||||||
|
let count: i64 = sqlx::query_scalar("SELECT COUNT(*) FROM users")
|
||||||
|
.fetch_one(&pool)
|
||||||
|
.await
|
||||||
|
.map_err(|e| {
|
||||||
|
tracing::error!("Check count error: {}", e);
|
||||||
|
AuthError::Internal(anyhow::anyhow!("DB Error"))
|
||||||
|
})?;
|
||||||
|
|
||||||
|
if count > 0 {
|
||||||
|
return Err(AuthError::RegistrationDisabled);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 2. Hash password (Argon2 local)
|
||||||
|
use argon2::{Argon2, password_hash::{self, SaltString, PasswordHasher}};
|
||||||
|
|
||||||
|
let salt = SaltString::generate(&mut password_hash::rand_core::OsRng);
|
||||||
|
let argon2 = Argon2::default();
|
||||||
|
let password_hash = argon2
|
||||||
|
.hash_password(payload.password.as_bytes(), &salt)
|
||||||
|
.map_err(|e| AuthError::Internal(anyhow::anyhow!("Hash error: {}", e)))?
|
||||||
|
.to_string();
|
||||||
|
|
||||||
|
// 3. Obtener Role ID de admin
|
||||||
|
let role_id: i32 = sqlx::query_scalar("SELECT id FROM roles WHERE name = 'admin'")
|
||||||
|
.fetch_one(&pool)
|
||||||
|
.await
|
||||||
|
.map_err(|_| AuthError::Internal(anyhow::anyhow!("Roles no inicializados (ejecute migraciones primero)")))?;
|
||||||
|
|
||||||
|
// 4. Crear usuario
|
||||||
|
sqlx::query(
|
||||||
|
"INSERT INTO users (email, password_hash, full_name, role_id) VALUES ($1, $2, $3, $4)"
|
||||||
|
)
|
||||||
|
.bind(&payload.email)
|
||||||
|
.bind(&password_hash)
|
||||||
|
.bind(&payload.full_name)
|
||||||
|
.bind(role_id)
|
||||||
|
.execute(&pool)
|
||||||
|
.await
|
||||||
|
.map_err(|e| {
|
||||||
|
tracing::error!("Creation error: {}", e);
|
||||||
|
AuthError::Internal(anyhow::anyhow!("Error al crear primer administrador"))
|
||||||
|
})?;
|
||||||
|
|
||||||
|
Ok(Json(json!({
|
||||||
|
"status": "success",
|
||||||
|
"message": "Sistema inicializado. Ahora puede iniciar sesión."
|
||||||
|
})))
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ use uuid::Uuid;
|
|||||||
|
|
||||||
#[derive(sqlx::FromRow)]
|
#[derive(sqlx::FromRow)]
|
||||||
struct TelemetryRow {
|
struct TelemetryRow {
|
||||||
|
asset_id: Uuid,
|
||||||
asset_code: String,
|
asset_code: String,
|
||||||
time: DateTime<Utc>,
|
time: DateTime<Utc>,
|
||||||
data: Value,
|
data: Value,
|
||||||
@@ -21,6 +22,7 @@ pub async fn get_summary(
|
|||||||
let records = sqlx::query_as::<_, TelemetryRow>(
|
let records = sqlx::query_as::<_, TelemetryRow>(
|
||||||
r#"
|
r#"
|
||||||
SELECT DISTINCT ON (t.asset_id)
|
SELECT DISTINCT ON (t.asset_id)
|
||||||
|
t.asset_id,
|
||||||
a.code as asset_code,
|
a.code as asset_code,
|
||||||
t.time,
|
t.time,
|
||||||
t.data
|
t.data
|
||||||
@@ -41,6 +43,7 @@ pub async fn get_summary(
|
|||||||
.into_iter()
|
.into_iter()
|
||||||
.map(|row| {
|
.map(|row| {
|
||||||
json!({
|
json!({
|
||||||
|
"asset_id": row.asset_id,
|
||||||
"asset": row.asset_code,
|
"asset": row.asset_code,
|
||||||
"last_updated": row.time,
|
"last_updated": row.time,
|
||||||
"telemetry": row.data
|
"telemetry": row.data
|
||||||
|
|||||||
101
services/backend-api/src/handlers/downtime.rs
Normal file
101
services/backend-api/src/handlers/downtime.rs
Normal file
@@ -0,0 +1,101 @@
|
|||||||
|
use crate::auth::Claims;
|
||||||
|
use crate::errors::AppError;
|
||||||
|
use axum::{
|
||||||
|
Json,
|
||||||
|
extract::{Query, State},
|
||||||
|
http::StatusCode,
|
||||||
|
};
|
||||||
|
use serde::Deserialize;
|
||||||
|
use shared_lib::models::OperationDowntime;
|
||||||
|
use sqlx::{PgPool, Postgres};
|
||||||
|
use uuid::Uuid;
|
||||||
|
|
||||||
|
#[derive(Debug, Deserialize)]
|
||||||
|
pub struct CreateDowntimeRequest {
|
||||||
|
pub asset_id: Uuid,
|
||||||
|
pub start_time: chrono::DateTime<chrono::Utc>,
|
||||||
|
pub end_time: Option<chrono::DateTime<chrono::Utc>>,
|
||||||
|
pub is_planned: bool,
|
||||||
|
pub reason: String,
|
||||||
|
pub comments: Option<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Deserialize)]
|
||||||
|
pub struct DowntimeQueryParams {
|
||||||
|
pub asset_id: Uuid,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// GET /api/downtime — Listar paradas de un activo.
|
||||||
|
pub async fn list_downtime(
|
||||||
|
State(pool): State<PgPool>,
|
||||||
|
claims: Claims,
|
||||||
|
Query(params): Query<DowntimeQueryParams>,
|
||||||
|
) -> Result<Json<Vec<OperationDowntime>>, AppError> {
|
||||||
|
let user_id = Uuid::parse_str(&claims.sub)
|
||||||
|
.map_err(|_| AppError::BadRequest("User ID inválido".to_string()))?;
|
||||||
|
|
||||||
|
let access = sqlx::query(
|
||||||
|
r#"SELECT 1 FROM edge_assets ea
|
||||||
|
JOIN user_project_access upa ON ea.project_id = upa.project_id
|
||||||
|
WHERE ea.id = $1 AND upa.user_id = $2 AND upa.is_active = true"#,
|
||||||
|
)
|
||||||
|
.bind(params.asset_id)
|
||||||
|
.bind(user_id)
|
||||||
|
.fetch_optional(&pool)
|
||||||
|
.await?;
|
||||||
|
|
||||||
|
if access.is_none() {
|
||||||
|
return Err(AppError::Forbidden("Sin permiso para ver paradas de este activo".to_string()));
|
||||||
|
}
|
||||||
|
|
||||||
|
let downtime: Vec<OperationDowntime> = sqlx::query_as::<Postgres, OperationDowntime>(
|
||||||
|
"SELECT * FROM operations_downtime WHERE asset_id = $1 ORDER BY start_time DESC",
|
||||||
|
)
|
||||||
|
.bind(params.asset_id)
|
||||||
|
.fetch_all(&pool)
|
||||||
|
.await?;
|
||||||
|
|
||||||
|
Ok(Json(downtime))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// POST /api/downtime — Registrar una nueva parada operativa.
|
||||||
|
pub async fn create_downtime(
|
||||||
|
State(pool): State<PgPool>,
|
||||||
|
claims: Claims,
|
||||||
|
Json(req): Json<CreateDowntimeRequest>,
|
||||||
|
) -> Result<(StatusCode, Json<OperationDowntime>), AppError> {
|
||||||
|
let user_id = Uuid::parse_str(&claims.sub)
|
||||||
|
.map_err(|_| AppError::BadRequest("User ID inválido".to_string()))?;
|
||||||
|
|
||||||
|
let access = sqlx::query(
|
||||||
|
r#"SELECT 1 FROM edge_assets ea
|
||||||
|
JOIN user_project_access upa ON ea.project_id = upa.project_id
|
||||||
|
WHERE ea.id = $1 AND upa.user_id = $2 AND upa.is_active = true"#,
|
||||||
|
)
|
||||||
|
.bind(req.asset_id)
|
||||||
|
.bind(user_id)
|
||||||
|
.fetch_optional(&pool)
|
||||||
|
.await?;
|
||||||
|
|
||||||
|
if access.is_none() {
|
||||||
|
return Err(AppError::Forbidden("Sin permiso para registrar paradas en este activo".to_string()));
|
||||||
|
}
|
||||||
|
|
||||||
|
let downtime: OperationDowntime = sqlx::query_as::<Postgres, OperationDowntime>(
|
||||||
|
r#"INSERT INTO operations_downtime (
|
||||||
|
asset_id, start_time, end_time, is_planned, reason, comments
|
||||||
|
)
|
||||||
|
VALUES ($1, $2, $3, $4, $5, $6)
|
||||||
|
RETURNING *"#,
|
||||||
|
)
|
||||||
|
.bind(req.asset_id)
|
||||||
|
.bind(req.start_time)
|
||||||
|
.bind(req.end_time)
|
||||||
|
.bind(req.is_planned)
|
||||||
|
.bind(req.reason)
|
||||||
|
.bind(req.comments)
|
||||||
|
.fetch_one(&pool)
|
||||||
|
.await?;
|
||||||
|
|
||||||
|
Ok((StatusCode::CREATED, Json(downtime)))
|
||||||
|
}
|
||||||
@@ -17,6 +17,7 @@ use crate::auth::Claims;
|
|||||||
pub struct LogQueryParams {
|
pub struct LogQueryParams {
|
||||||
pub level: Option<String>,
|
pub level: Option<String>,
|
||||||
pub category: Option<String>,
|
pub category: Option<String>,
|
||||||
|
pub project_id: Option<Uuid>,
|
||||||
pub limit: Option<i64>,
|
pub limit: Option<i64>,
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -32,14 +33,16 @@ pub async fn list_agent_logs(
|
|||||||
|
|
||||||
let logs = if claims.role == "admin" {
|
let logs = if claims.role == "admin" {
|
||||||
sqlx::query_as::<_, EdgeAgentLog>(
|
sqlx::query_as::<_, EdgeAgentLog>(
|
||||||
r#"SELECT * FROM edge_agent_logs
|
r#"SELECT * FROM agent_logs
|
||||||
WHERE ($1::VARCHAR IS NULL OR level = $1)
|
WHERE ($1::VARCHAR IS NULL OR level = $1)
|
||||||
AND ($2::VARCHAR IS NULL OR category = $2)
|
AND ($2::VARCHAR IS NULL OR category = $2)
|
||||||
ORDER BY created_at DESC
|
AND ($3::UUID IS NULL OR project_id = $3)
|
||||||
LIMIT $3"#,
|
ORDER BY timestamp DESC
|
||||||
|
LIMIT $4"#,
|
||||||
)
|
)
|
||||||
.bind(¶ms.level)
|
.bind(¶ms.level)
|
||||||
.bind(¶ms.category)
|
.bind(¶ms.category)
|
||||||
|
.bind(params.project_id)
|
||||||
.bind(limit)
|
.bind(limit)
|
||||||
.fetch_all(&pool)
|
.fetch_all(&pool)
|
||||||
.await?
|
.await?
|
||||||
@@ -48,17 +51,19 @@ pub async fn list_agent_logs(
|
|||||||
.map_err(|_| AppError::BadRequest("User ID inválido".to_string()))?;
|
.map_err(|_| AppError::BadRequest("User ID inválido".to_string()))?;
|
||||||
|
|
||||||
sqlx::query_as::<_, EdgeAgentLog>(
|
sqlx::query_as::<_, EdgeAgentLog>(
|
||||||
r#"SELECT l.* FROM edge_agent_logs l
|
r#"SELECT l.* FROM agent_logs l
|
||||||
JOIN user_project_access upa ON l.project_id = upa.project_id
|
JOIN user_project_access upa ON l.project_id = upa.project_id
|
||||||
WHERE upa.user_id = $1 AND upa.is_active = true
|
WHERE upa.user_id = $1 AND upa.is_active = true
|
||||||
AND ($2::VARCHAR IS NULL OR l.level = $2)
|
AND ($2::VARCHAR IS NULL OR l.level = $2)
|
||||||
AND ($3::VARCHAR IS NULL OR l.category = $3)
|
AND ($3::VARCHAR IS NULL OR l.category = $3)
|
||||||
ORDER BY l.created_at DESC
|
AND ($4::UUID IS NULL OR l.project_id = $4)
|
||||||
LIMIT $4"#,
|
ORDER BY l.timestamp DESC
|
||||||
|
LIMIT $5"#,
|
||||||
)
|
)
|
||||||
.bind(user_id)
|
.bind(user_id)
|
||||||
.bind(¶ms.level)
|
.bind(¶ms.level)
|
||||||
.bind(¶ms.category)
|
.bind(¶ms.category)
|
||||||
|
.bind(params.project_id)
|
||||||
.bind(limit)
|
.bind(limit)
|
||||||
.fetch_all(&pool)
|
.fetch_all(&pool)
|
||||||
.await?
|
.await?
|
||||||
@@ -73,8 +78,8 @@ pub async fn create_agent_log(
|
|||||||
Json(req): Json<CreateAgentLogRequest>,
|
Json(req): Json<CreateAgentLogRequest>,
|
||||||
) -> Result<StatusCode, AppError> {
|
) -> Result<StatusCode, AppError> {
|
||||||
sqlx::query(
|
sqlx::query(
|
||||||
r#"INSERT INTO edge_agent_logs (project_id, level, category, message, context, agent_hostname, agent_ip)
|
r#"INSERT INTO agent_logs (project_id, level, category, message, context, agent_hostname, agent_ip)
|
||||||
VALUES ($1, $2, $3, $4, $5, $6, $7::INET)"#
|
VALUES ($1, $2, $3, $4, $5, $6, $7)"#
|
||||||
)
|
)
|
||||||
.bind(req.project_id)
|
.bind(req.project_id)
|
||||||
.bind(&req.level)
|
.bind(&req.level)
|
||||||
@@ -112,9 +117,9 @@ pub async fn get_agent_health(
|
|||||||
}
|
}
|
||||||
// Obtener último log de health para saber si sigue comunicando
|
// Obtener último log de health para saber si sigue comunicando
|
||||||
let last_health = sqlx::query_as::<_, EdgeAgentLog>(
|
let last_health = sqlx::query_as::<_, EdgeAgentLog>(
|
||||||
r#"SELECT * FROM edge_agent_logs
|
r#"SELECT * FROM agent_logs
|
||||||
WHERE project_id = $1 AND category = 'HEALTH'
|
WHERE project_id = $1 AND category = 'HEALTH'
|
||||||
ORDER BY created_at DESC LIMIT 1"#,
|
ORDER BY timestamp DESC LIMIT 1"#,
|
||||||
)
|
)
|
||||||
.bind(project_id)
|
.bind(project_id)
|
||||||
.fetch_optional(&pool)
|
.fetch_optional(&pool)
|
||||||
|
|||||||
@@ -17,6 +17,9 @@ pub struct UpdateAssetRequest {
|
|||||||
pub name: String,
|
pub name: String,
|
||||||
pub is_active: bool,
|
pub is_active: bool,
|
||||||
pub metadata: Option<Value>,
|
pub metadata: Option<Value>,
|
||||||
|
pub external_api_url: Option<String>,
|
||||||
|
pub external_api_key: Option<String>,
|
||||||
|
pub is_collector_enabled: Option<bool>,
|
||||||
}
|
}
|
||||||
|
|
||||||
use crate::errors::AppError;
|
use crate::errors::AppError;
|
||||||
@@ -72,8 +75,8 @@ pub async fn create_asset(
|
|||||||
}
|
}
|
||||||
|
|
||||||
let asset = sqlx::query_as::<_, EdgeAsset>(
|
let asset = sqlx::query_as::<_, EdgeAsset>(
|
||||||
r#"INSERT INTO edge_assets (project_id, code, name, asset_type, metadata)
|
r#"INSERT INTO edge_assets (project_id, code, name, asset_type, metadata, external_api_url, external_api_key, is_collector_enabled)
|
||||||
VALUES ($1, $2, $3, $4, $5)
|
VALUES ($1, $2, $3, $4, $5, $6, $7, $8)
|
||||||
RETURNING *"#
|
RETURNING *"#
|
||||||
)
|
)
|
||||||
.bind(req.project_id)
|
.bind(req.project_id)
|
||||||
@@ -81,6 +84,9 @@ pub async fn create_asset(
|
|||||||
.bind(&req.name)
|
.bind(&req.name)
|
||||||
.bind(req.asset_type)
|
.bind(req.asset_type)
|
||||||
.bind(req.metadata.unwrap_or(serde_json::json!({})))
|
.bind(req.metadata.unwrap_or(serde_json::json!({})))
|
||||||
|
.bind(req.external_api_url)
|
||||||
|
.bind(req.external_api_key)
|
||||||
|
.bind(req.is_collector_enabled.unwrap_or(false))
|
||||||
.fetch_one(&pool)
|
.fetch_one(&pool)
|
||||||
.await?;
|
.await?;
|
||||||
|
|
||||||
@@ -146,13 +152,18 @@ pub async fn update_asset(
|
|||||||
|
|
||||||
let updated = sqlx::query_as::<_, EdgeAsset>(
|
let updated = sqlx::query_as::<_, EdgeAsset>(
|
||||||
r#"UPDATE edge_assets
|
r#"UPDATE edge_assets
|
||||||
SET name = $1, is_active = $2, metadata = $3, updated_at = NOW()
|
SET name = $1, is_active = $2, metadata = $3,
|
||||||
WHERE id = $4
|
external_api_url = $4, external_api_key = $5, is_collector_enabled = $6,
|
||||||
|
updated_at = NOW()
|
||||||
|
WHERE id = $7
|
||||||
RETURNING *"#
|
RETURNING *"#
|
||||||
)
|
)
|
||||||
.bind(&req.name)
|
.bind(&req.name)
|
||||||
.bind(req.is_active)
|
.bind(req.is_active)
|
||||||
.bind(req.metadata.unwrap_or(serde_json::json!({})))
|
.bind(req.metadata.unwrap_or(serde_json::json!({})))
|
||||||
|
.bind(req.external_api_url)
|
||||||
|
.bind(req.external_api_key)
|
||||||
|
.bind(req.is_collector_enabled.unwrap_or(false))
|
||||||
.bind(id)
|
.bind(id)
|
||||||
.fetch_optional(&pool)
|
.fetch_optional(&pool)
|
||||||
.await?
|
.await?
|
||||||
|
|||||||
@@ -3,6 +3,7 @@
|
|||||||
//! Este módulo maneja la automatización del overlay de binarios de Rust
|
//! Este módulo maneja la automatización del overlay de binarios de Rust
|
||||||
//! y el registro histórico de despliegues por proyecto.
|
//! y el registro histórico de despliegues por proyecto.
|
||||||
|
|
||||||
|
use crate::auth::Claims;
|
||||||
use axum::{
|
use axum::{
|
||||||
Json,
|
Json,
|
||||||
extract::{Path, State},
|
extract::{Path, State},
|
||||||
@@ -11,7 +12,7 @@ use axum::{
|
|||||||
use shared_lib::edge_models::*;
|
use shared_lib::edge_models::*;
|
||||||
use sqlx::PgPool;
|
use sqlx::PgPool;
|
||||||
use uuid::Uuid;
|
use uuid::Uuid;
|
||||||
use crate::auth::Claims;
|
use chrono;
|
||||||
|
|
||||||
/// GET /api/edge/projects/:project_id/deployments
|
/// GET /api/edge/projects/:project_id/deployments
|
||||||
///
|
///
|
||||||
@@ -30,7 +31,10 @@ pub async fn list_deployments(
|
|||||||
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?;
|
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?;
|
||||||
|
|
||||||
if access.is_none() {
|
if access.is_none() {
|
||||||
return Err((StatusCode::FORBIDDEN, "Access denied to these project deployments".to_string()));
|
return Err((
|
||||||
|
StatusCode::FORBIDDEN,
|
||||||
|
"Access denied to these project deployments".to_string(),
|
||||||
|
));
|
||||||
}
|
}
|
||||||
|
|
||||||
let deployments = sqlx::query_as::<_, EdgeDeployment>(
|
let deployments = sqlx::query_as::<_, EdgeDeployment>(
|
||||||
@@ -63,7 +67,10 @@ pub async fn trigger_build(
|
|||||||
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?;
|
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?;
|
||||||
|
|
||||||
if access.is_none() {
|
if access.is_none() {
|
||||||
return Err((StatusCode::FORBIDDEN, "Access denied to trigger builds for this project".to_string()));
|
return Err((
|
||||||
|
StatusCode::FORBIDDEN,
|
||||||
|
"Access denied to trigger builds for this project".to_string(),
|
||||||
|
));
|
||||||
}
|
}
|
||||||
// 1. Verificación preliminar de existencia de proyecto
|
// 1. Verificación preliminar de existencia de proyecto
|
||||||
let project = sqlx::query_as::<_, EdgeProject>("SELECT * FROM edge_projects WHERE id = $1")
|
let project = sqlx::query_as::<_, EdgeProject>("SELECT * FROM edge_projects WHERE id = $1")
|
||||||
@@ -105,9 +112,16 @@ pub async fn trigger_build(
|
|||||||
.await
|
.await
|
||||||
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?;
|
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?;
|
||||||
|
|
||||||
|
// 4. Sincronizar estado en la tabla de proyectos para visibilidad inmediata en UI
|
||||||
|
let _ = sqlx::query("UPDATE edge_projects SET installer_status = 'BUILDING' WHERE id = $1")
|
||||||
|
.bind(project_id)
|
||||||
|
.execute(&pool)
|
||||||
|
.await;
|
||||||
|
|
||||||
// ─── Dispatch Build Request via MQTT ──────────────────────────────
|
// ─── Dispatch Build Request via MQTT ──────────────────────────────
|
||||||
let event = shared_lib::mqtt_messages::ProjectCreatedEvent {
|
let event = shared_lib::mqtt_messages::ProjectCreatedEvent {
|
||||||
project_id,
|
project_id,
|
||||||
|
deployment_id: Some(deployment.id),
|
||||||
name: project.name.clone(),
|
name: project.name.clone(),
|
||||||
client: project.client.clone(),
|
client: project.client.clone(),
|
||||||
timestamp: chrono::Utc::now(),
|
timestamp: chrono::Utc::now(),
|
||||||
@@ -115,6 +129,7 @@ pub async fn trigger_build(
|
|||||||
|
|
||||||
if let Ok(payload) = serde_json::to_string(&event) {
|
if let Ok(payload) = serde_json::to_string(&event) {
|
||||||
let mqtt_clone = mqtt.clone();
|
let mqtt_clone = mqtt.clone();
|
||||||
|
tracing::info!("📡 Enviando señal de construcción para despliegue {}: {}", deployment.id, payload);
|
||||||
tokio::spawn(async move {
|
tokio::spawn(async move {
|
||||||
let _ = mqtt_clone
|
let _ = mqtt_clone
|
||||||
.publish(
|
.publish(
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ use shared_lib::edge_models::*;
|
|||||||
use crate::auth::Claims;
|
use crate::auth::Claims;
|
||||||
use sqlx::PgPool;
|
use sqlx::PgPool;
|
||||||
use uuid::Uuid;
|
use uuid::Uuid;
|
||||||
|
use chrono;
|
||||||
|
|
||||||
/// GET /api/edge/projects — Listar todos los proyectos.
|
/// GET /api/edge/projects — Listar todos los proyectos.
|
||||||
pub async fn list_projects(
|
pub async fn list_projects(
|
||||||
@@ -43,12 +44,14 @@ pub async fn create_project(
|
|||||||
let mut tx = pool.begin().await.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?;
|
let mut tx = pool.begin().await.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?;
|
||||||
|
|
||||||
let project = sqlx::query_as::<_, EdgeProject>(
|
let project = sqlx::query_as::<_, EdgeProject>(
|
||||||
r#"INSERT INTO edge_projects (name, client, description, metadata)
|
r#"INSERT INTO edge_projects (name, client, operator_name, contract_number, description, metadata)
|
||||||
VALUES ($1, $2, $3, $4)
|
VALUES ($1, $2, $3, $4, $5, $6)
|
||||||
RETURNING *"#,
|
RETURNING *"#,
|
||||||
)
|
)
|
||||||
.bind(&req.name)
|
.bind(&req.name)
|
||||||
.bind(&req.client)
|
.bind(&req.client)
|
||||||
|
.bind(&req.operator_name)
|
||||||
|
.bind(&req.contract_number)
|
||||||
.bind(&req.description)
|
.bind(&req.description)
|
||||||
.bind(req.metadata.unwrap_or(serde_json::json!({})))
|
.bind(req.metadata.unwrap_or(serde_json::json!({})))
|
||||||
.fetch_one(&mut *tx)
|
.fetch_one(&mut *tx)
|
||||||
@@ -72,6 +75,7 @@ pub async fn create_project(
|
|||||||
// ─── Dispatch Domain Event via MQTT (Internal Messaging) ──────────────────────────────
|
// ─── Dispatch Domain Event via MQTT (Internal Messaging) ──────────────────────────────
|
||||||
let event = shared_lib::mqtt_messages::ProjectCreatedEvent {
|
let event = shared_lib::mqtt_messages::ProjectCreatedEvent {
|
||||||
project_id: project.id,
|
project_id: project.id,
|
||||||
|
deployment_id: None,
|
||||||
name: project.name.clone(),
|
name: project.name.clone(),
|
||||||
client: project.client.clone(),
|
client: project.client.clone(),
|
||||||
timestamp: chrono::Utc::now(),
|
timestamp: chrono::Utc::now(),
|
||||||
@@ -108,9 +112,8 @@ pub async fn get_project(
|
|||||||
Path(id): Path<Uuid>,
|
Path(id): Path<Uuid>,
|
||||||
) -> Result<Json<EdgeProject>, (StatusCode, String)> {
|
) -> Result<Json<EdgeProject>, (StatusCode, String)> {
|
||||||
// Validar acceso
|
// Validar acceso
|
||||||
if claims.role != "admin" {
|
|
||||||
let access = sqlx::query("SELECT 1 FROM user_project_access WHERE user_id = $1 AND project_id = $2 AND is_active = true")
|
let access = sqlx::query("SELECT 1 FROM user_project_access WHERE user_id = $1 AND project_id = $2 AND is_active = true")
|
||||||
.bind(Uuid::parse_str(&claims.sub).unwrap())
|
.bind(Uuid::parse_str(&claims.sub).map_err(|_| (StatusCode::UNAUTHORIZED, "Invalid user ID".to_string()))?)
|
||||||
.bind(id)
|
.bind(id)
|
||||||
.fetch_optional(&pool)
|
.fetch_optional(&pool)
|
||||||
.await
|
.await
|
||||||
@@ -119,7 +122,6 @@ pub async fn get_project(
|
|||||||
if access.is_none() {
|
if access.is_none() {
|
||||||
return Err((StatusCode::FORBIDDEN, "Access denied to this project".to_string()));
|
return Err((StatusCode::FORBIDDEN, "Access denied to this project".to_string()));
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
|
||||||
let project = sqlx::query_as::<_, EdgeProject>("SELECT * FROM edge_projects WHERE id = $1")
|
let project = sqlx::query_as::<_, EdgeProject>("SELECT * FROM edge_projects WHERE id = $1")
|
||||||
.bind(id)
|
.bind(id)
|
||||||
@@ -212,9 +214,8 @@ pub async fn get_project_full_config(
|
|||||||
Path(id): Path<Uuid>,
|
Path(id): Path<Uuid>,
|
||||||
) -> Result<Json<ProjectConfig>, (StatusCode, String)> {
|
) -> Result<Json<ProjectConfig>, (StatusCode, String)> {
|
||||||
// Validar acceso
|
// Validar acceso
|
||||||
if claims.role != "admin" {
|
|
||||||
let access = sqlx::query("SELECT 1 FROM user_project_access WHERE user_id = $1 AND project_id = $2 AND is_active = true")
|
let access = sqlx::query("SELECT 1 FROM user_project_access WHERE user_id = $1 AND project_id = $2 AND is_active = true")
|
||||||
.bind(Uuid::parse_str(&claims.sub).unwrap())
|
.bind(Uuid::parse_str(&claims.sub).map_err(|_| (StatusCode::UNAUTHORIZED, "Invalid user ID".to_string()))?)
|
||||||
.bind(id)
|
.bind(id)
|
||||||
.fetch_optional(&pool)
|
.fetch_optional(&pool)
|
||||||
.await
|
.await
|
||||||
@@ -223,7 +224,6 @@ pub async fn get_project_full_config(
|
|||||||
if access.is_none() {
|
if access.is_none() {
|
||||||
return Err((StatusCode::FORBIDDEN, "Access denied to this project".to_string()));
|
return Err((StatusCode::FORBIDDEN, "Access denied to this project".to_string()));
|
||||||
}
|
}
|
||||||
}
|
|
||||||
use shared_lib::models::EdgeAsset;
|
use shared_lib::models::EdgeAsset;
|
||||||
|
|
||||||
let project = sqlx::query_as::<_, EdgeProject>("SELECT * FROM edge_projects WHERE id = $1")
|
let project = sqlx::query_as::<_, EdgeProject>("SELECT * FROM edge_projects WHERE id = $1")
|
||||||
@@ -322,15 +322,15 @@ pub async fn download_installer(
|
|||||||
|
|
||||||
// Dado que el Backend hace el fetch, usamos el dominio interno de Docker, no el publico!
|
// Dado que el Backend hace el fetch, usamos el dominio interno de Docker, no el publico!
|
||||||
let endpoint_url =
|
let endpoint_url =
|
||||||
std::env::var("MINIO_ENDPOINT_URL").unwrap_or_else(|_| "http://minio:9000".to_string());
|
std::env::var("MINIO_ENDPOINT_URL").expect("MINIO_ENDPOINT_URL must be set");
|
||||||
|
|
||||||
// Configurar credenciales explícitas
|
// Configurar credenciales explícitas
|
||||||
let access_key = std::env::var("AWS_ACCESS_KEY_ID")
|
let access_key = std::env::var("AWS_ACCESS_KEY_ID")
|
||||||
.or_else(|_| std::env::var("MINIO_USER"))
|
.or_else(|_| std::env::var("MINIO_USER"))
|
||||||
.unwrap_or_else(|_| "admin_s3".to_string());
|
.expect("S3/MinIO credentials (AWS_ACCESS_KEY_ID or MINIO_USER) must be set");
|
||||||
let secret_key = std::env::var("AWS_SECRET_ACCESS_KEY")
|
let secret_key = std::env::var("AWS_SECRET_ACCESS_KEY")
|
||||||
.or_else(|_| std::env::var("MINIO_PASSWORD"))
|
.or_else(|_| std::env::var("MINIO_PASSWORD"))
|
||||||
.unwrap_or_else(|_| "otra_clave_segura_minio".to_string());
|
.expect("S3/MinIO credentials (AWS_SECRET_ACCESS_KEY or MINIO_PASSWORD) must be set");
|
||||||
|
|
||||||
let credentials = aws_sdk_s3::config::Credentials::new(
|
let credentials = aws_sdk_s3::config::Credentials::new(
|
||||||
access_key,
|
access_key,
|
||||||
@@ -391,3 +391,155 @@ pub async fn download_installer(
|
|||||||
|
|
||||||
Ok(response)
|
Ok(response)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// POST /api/edge/projects/:id/deploy — Generar y desplegar flows.json vía MQTT.
|
||||||
|
pub async fn deploy_project(
|
||||||
|
State(pool): State<PgPool>,
|
||||||
|
State(mqtt): State<rumqttc::AsyncClient>,
|
||||||
|
claims: Claims,
|
||||||
|
Path(id): Path<Uuid>,
|
||||||
|
) -> Result<Json<serde_json::Value>, (StatusCode, String)> {
|
||||||
|
// 1. Validar acceso
|
||||||
|
let access = sqlx::query("SELECT 1 FROM user_project_access WHERE user_id = $1 AND project_id = $2 AND is_active = true")
|
||||||
|
.bind(Uuid::parse_str(&claims.sub).map_err(|_| (StatusCode::UNAUTHORIZED, "Invalid user ID".to_string()))?)
|
||||||
|
.bind(id)
|
||||||
|
.fetch_optional(&pool)
|
||||||
|
.await
|
||||||
|
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?;
|
||||||
|
|
||||||
|
if access.is_none() {
|
||||||
|
return Err((StatusCode::FORBIDDEN, "Access denied to deploy this project".to_string()));
|
||||||
|
}
|
||||||
|
|
||||||
|
// 2. Obtener config FULL (Project + Assets + Devices + Variables)
|
||||||
|
// Reutilizamos el código del full config pero para uso interno
|
||||||
|
let project = sqlx::query_as::<_, EdgeProject>("SELECT * FROM edge_projects WHERE id = $1")
|
||||||
|
.bind(id)
|
||||||
|
.fetch_optional(&pool)
|
||||||
|
.await
|
||||||
|
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?
|
||||||
|
.ok_or((StatusCode::NOT_FOUND, "Project not found".to_string()))?;
|
||||||
|
|
||||||
|
let assets = sqlx::query_as::<_, shared_lib::models::EdgeAsset>(
|
||||||
|
"SELECT * FROM edge_assets WHERE project_id = $1 ORDER BY name",
|
||||||
|
)
|
||||||
|
.bind(id)
|
||||||
|
.fetch_all(&pool)
|
||||||
|
.await
|
||||||
|
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?;
|
||||||
|
|
||||||
|
let mut asset_configs = Vec::new();
|
||||||
|
for asset in assets {
|
||||||
|
let devices = sqlx::query_as::<_, EdgeDevice>(
|
||||||
|
"SELECT * FROM edge_devices WHERE asset_id = $1 ORDER BY name",
|
||||||
|
)
|
||||||
|
.bind(asset.id)
|
||||||
|
.fetch_all(&pool)
|
||||||
|
.await
|
||||||
|
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?;
|
||||||
|
|
||||||
|
let mut device_configs = Vec::new();
|
||||||
|
for device in devices {
|
||||||
|
let variables = sqlx::query_as::<_, EdgeVariable>(
|
||||||
|
"SELECT * FROM edge_variables WHERE device_id = $1 ORDER BY alias",
|
||||||
|
)
|
||||||
|
.bind(device.id)
|
||||||
|
.fetch_all(&pool)
|
||||||
|
.await
|
||||||
|
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?;
|
||||||
|
|
||||||
|
device_configs.push(DeviceConfig { device, variables });
|
||||||
|
}
|
||||||
|
asset_configs.push(AssetConfig { asset, devices: device_configs });
|
||||||
|
}
|
||||||
|
|
||||||
|
let full_config = ProjectConfig {
|
||||||
|
project,
|
||||||
|
assets: asset_configs,
|
||||||
|
};
|
||||||
|
|
||||||
|
// 3. Publicar en MQTT para que el sistema de despliegue lo procese
|
||||||
|
// Tópico: omnioil/control/deploy/{project_id}
|
||||||
|
let payload = serde_json::to_string(&full_config)
|
||||||
|
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, format!("Failed to serialize config: {}", e)))?;
|
||||||
|
|
||||||
|
let topic = format!("omnioil/control/deploy/{}", id);
|
||||||
|
|
||||||
|
mqtt.publish(topic, rumqttc::QoS::AtLeastOnce, false, payload)
|
||||||
|
.await
|
||||||
|
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, format!("MQTT Publish failed: {}", e)))?;
|
||||||
|
|
||||||
|
tracing::info!("🚀 Despliegue solicitado para proyecto: {}. Configuración publicada en MQTT.", id);
|
||||||
|
|
||||||
|
Ok(Json(serde_json::json!({
|
||||||
|
"message": "Despliegue iniciado correctamente",
|
||||||
|
"project_id": id,
|
||||||
|
"status": "QUEUED"
|
||||||
|
})))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// POST /api/edge/projects/:id/rebuild — Forzar la reconstrucción del instalador (MSI).
|
||||||
|
pub async fn rebuild_installer(
|
||||||
|
State(pool): State<PgPool>,
|
||||||
|
State(mqtt): State<rumqttc::AsyncClient>,
|
||||||
|
claims: Claims,
|
||||||
|
Path(id): Path<Uuid>,
|
||||||
|
) -> Result<Json<serde_json::Value>, (StatusCode, String)> {
|
||||||
|
// 1. Validar acceso
|
||||||
|
let access = sqlx::query("SELECT 1 FROM user_project_access WHERE user_id = $1 AND project_id = $2 AND is_active = true")
|
||||||
|
.bind(Uuid::parse_str(&claims.sub).map_err(|_| (StatusCode::UNAUTHORIZED, "Invalid user ID".to_string()))?)
|
||||||
|
.bind(id)
|
||||||
|
.fetch_optional(&pool)
|
||||||
|
.await
|
||||||
|
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?;
|
||||||
|
|
||||||
|
if access.is_none() && claims.role != "admin" {
|
||||||
|
return Err((StatusCode::FORBIDDEN, "Only admins or authorized users can trigger installer rebuilds".to_string()));
|
||||||
|
}
|
||||||
|
// Si no tiene acceso directo y es admin, tal vez queramos permitirlo en el futuro,
|
||||||
|
// pero el requerimiento es separacion TOTAL.
|
||||||
|
// Así que exigimos acceso directo.
|
||||||
|
if access.is_none() {
|
||||||
|
return Err((StatusCode::FORBIDDEN, "Access denied to this project".to_string()));
|
||||||
|
}
|
||||||
|
|
||||||
|
// 2. Obtener datos básicos del proyecto
|
||||||
|
let project = sqlx::query_as::<_, EdgeProject>("SELECT * FROM edge_projects WHERE id = $1")
|
||||||
|
.bind(id)
|
||||||
|
.fetch_optional(&pool)
|
||||||
|
.await
|
||||||
|
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?
|
||||||
|
.ok_or((StatusCode::NOT_FOUND, "Project not found".to_string()))?;
|
||||||
|
|
||||||
|
// 3. Resetear el estado del instalador en la DB para que la UI muestre el cambio
|
||||||
|
sqlx::query("UPDATE edge_projects SET installer_status = 'BUILDING', installer_url = NULL WHERE id = $1")
|
||||||
|
.bind(id)
|
||||||
|
.execute(&pool)
|
||||||
|
.await
|
||||||
|
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?;
|
||||||
|
|
||||||
|
// 4. Disparar el evento de creación (que el orquestador ya sabe manejar)
|
||||||
|
// Usamos el propio project_id como deployment_id para asegurar determinismo
|
||||||
|
let event = shared_lib::mqtt_messages::ProjectCreatedEvent {
|
||||||
|
project_id: project.id,
|
||||||
|
deployment_id: Some(project.id),
|
||||||
|
name: project.name.clone(),
|
||||||
|
client: project.client.clone(),
|
||||||
|
timestamp: chrono::Utc::now(),
|
||||||
|
};
|
||||||
|
|
||||||
|
let payload = serde_json::to_string(&event)
|
||||||
|
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, format!("Failed to serialize event: {}", e)))?;
|
||||||
|
|
||||||
|
mqtt.publish("omnioil/events/project_created", rumqttc::QoS::AtLeastOnce, false, payload)
|
||||||
|
.await
|
||||||
|
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, format!("MQTT Publish failed: {}", e)))?;
|
||||||
|
|
||||||
|
tracing::info!("🛠️ Reconstrucción de instalador solicitada para proyecto: {}", id);
|
||||||
|
|
||||||
|
Ok(Json(serde_json::json!({
|
||||||
|
"message": "Reconstrucción de instalador iniciada",
|
||||||
|
"project_id": id,
|
||||||
|
"status": "BUILDING"
|
||||||
|
})))
|
||||||
|
}
|
||||||
|
|||||||
113
services/backend-api/src/handlers/lab_results.rs
Normal file
113
services/backend-api/src/handlers/lab_results.rs
Normal file
@@ -0,0 +1,113 @@
|
|||||||
|
use crate::auth::Claims;
|
||||||
|
use crate::handlers::audit_helper;
|
||||||
|
use crate::errors::AppError;
|
||||||
|
use axum::{
|
||||||
|
Json,
|
||||||
|
extract::{Query, State},
|
||||||
|
http::StatusCode,
|
||||||
|
};
|
||||||
|
use serde::Deserialize;
|
||||||
|
use shared_lib::models::LabResult;
|
||||||
|
use sqlx::{PgPool, Postgres, Row};
|
||||||
|
use uuid::Uuid;
|
||||||
|
|
||||||
|
#[derive(Debug, Deserialize)]
|
||||||
|
pub struct CreateLabResultRequest {
|
||||||
|
pub asset_id: Uuid,
|
||||||
|
pub sample_time: chrono::DateTime<chrono::Utc>,
|
||||||
|
pub results: serde_json::Value,
|
||||||
|
pub lab_technician: Option<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Deserialize)]
|
||||||
|
pub struct LabResultQueryParams {
|
||||||
|
pub asset_id: Uuid,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// GET /api/lab-results — Listar resultados de laboratorio de un activo.
|
||||||
|
pub async fn list_lab_results(
|
||||||
|
State(pool): State<PgPool>,
|
||||||
|
claims: Claims,
|
||||||
|
Query(params): Query<LabResultQueryParams>,
|
||||||
|
) -> Result<Json<Vec<LabResult>>, AppError> {
|
||||||
|
let user_id = Uuid::parse_str(&claims.sub)
|
||||||
|
.map_err(|_| AppError::BadRequest("User ID inválido".to_string()))?;
|
||||||
|
|
||||||
|
let access = sqlx::query(
|
||||||
|
r#"SELECT 1 FROM edge_assets ea
|
||||||
|
JOIN user_project_access upa ON ea.project_id = upa.project_id
|
||||||
|
WHERE ea.id = $1 AND upa.user_id = $2 AND upa.is_active = true"#,
|
||||||
|
)
|
||||||
|
.bind(params.asset_id)
|
||||||
|
.bind(user_id)
|
||||||
|
.fetch_optional(&pool)
|
||||||
|
.await?;
|
||||||
|
|
||||||
|
if access.is_none() {
|
||||||
|
return Err(AppError::Forbidden("Sin permiso para ver resultados de este activo".to_string()));
|
||||||
|
}
|
||||||
|
|
||||||
|
let records: Vec<LabResult> = sqlx::query_as::<Postgres, LabResult>(
|
||||||
|
"SELECT * FROM lab_results WHERE asset_id = $1 ORDER BY sample_time DESC",
|
||||||
|
)
|
||||||
|
.bind(params.asset_id)
|
||||||
|
.fetch_all(&pool)
|
||||||
|
.await?;
|
||||||
|
|
||||||
|
Ok(Json(records))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// POST /api/lab-results — Registrar un nuevo resultado de laboratorio.
|
||||||
|
pub async fn create_lab_result(
|
||||||
|
State(pool): State<PgPool>,
|
||||||
|
claims: Claims,
|
||||||
|
Json(req): Json<CreateLabResultRequest>,
|
||||||
|
) -> Result<(StatusCode, Json<LabResult>), AppError> {
|
||||||
|
let user_id = Uuid::parse_str(&claims.sub)
|
||||||
|
.map_err(|_| AppError::BadRequest("User ID inválido".to_string()))?;
|
||||||
|
|
||||||
|
let project_row = sqlx::query(
|
||||||
|
r#"SELECT ea.project_id FROM edge_assets ea
|
||||||
|
JOIN user_project_access upa ON ea.project_id = upa.project_id
|
||||||
|
WHERE ea.id = $1 AND upa.user_id = $2 AND upa.is_active = true"#,
|
||||||
|
)
|
||||||
|
.bind(req.asset_id)
|
||||||
|
.bind(user_id)
|
||||||
|
.fetch_optional(&pool)
|
||||||
|
.await?;
|
||||||
|
|
||||||
|
let project_id: Uuid = match project_row {
|
||||||
|
Some(row) => row.get("project_id"),
|
||||||
|
None => return Err(AppError::Forbidden("Sin permiso para registrar resultados en este activo".to_string())),
|
||||||
|
};
|
||||||
|
|
||||||
|
let record: LabResult = sqlx::query_as::<Postgres, LabResult>(
|
||||||
|
r#"INSERT INTO lab_results (
|
||||||
|
asset_id, sample_time, results, lab_technician
|
||||||
|
)
|
||||||
|
VALUES ($1, $2, $3, $4)
|
||||||
|
RETURNING *"#,
|
||||||
|
)
|
||||||
|
.bind(req.asset_id)
|
||||||
|
.bind(req.sample_time)
|
||||||
|
.bind(req.results)
|
||||||
|
.bind(req.lab_technician)
|
||||||
|
.fetch_one(&pool)
|
||||||
|
.await?;
|
||||||
|
|
||||||
|
// ─── Auditoría ────────────────────────────────────────────────────────────
|
||||||
|
audit_helper::log_audit(
|
||||||
|
&pool,
|
||||||
|
Some(project_id),
|
||||||
|
Some(user_id),
|
||||||
|
Some(claims.email),
|
||||||
|
"CREATE",
|
||||||
|
"LAB_RESULT",
|
||||||
|
record.id,
|
||||||
|
None,
|
||||||
|
Some(serde_json::to_value(&record).unwrap_or_default()),
|
||||||
|
None,
|
||||||
|
).await;
|
||||||
|
|
||||||
|
Ok((StatusCode::CREATED, Json(record)))
|
||||||
|
}
|
||||||
102
services/backend-api/src/handlers/meters.rs
Normal file
102
services/backend-api/src/handlers/meters.rs
Normal file
@@ -0,0 +1,102 @@
|
|||||||
|
use crate::auth::Claims;
|
||||||
|
use crate::errors::AppError;
|
||||||
|
use axum::{
|
||||||
|
Json,
|
||||||
|
extract::{Query, State},
|
||||||
|
http::StatusCode,
|
||||||
|
};
|
||||||
|
use bigdecimal::BigDecimal;
|
||||||
|
use serde::Deserialize;
|
||||||
|
use shared_lib::models::MeterReading;
|
||||||
|
use sqlx::{PgPool, Postgres};
|
||||||
|
use uuid::Uuid;
|
||||||
|
|
||||||
|
#[derive(Debug, Deserialize)]
|
||||||
|
pub struct CreateMeterReadingRequest {
|
||||||
|
pub asset_id: Uuid,
|
||||||
|
pub reading_time: chrono::DateTime<chrono::Utc>,
|
||||||
|
pub value: BigDecimal,
|
||||||
|
pub unit: String,
|
||||||
|
pub meter_factor: Option<BigDecimal>,
|
||||||
|
pub corrected_value: Option<BigDecimal>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Deserialize)]
|
||||||
|
pub struct MeterReadingQueryParams {
|
||||||
|
pub asset_id: Uuid,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// GET /api/meters/readings — Listar lecturas de un medidor.
|
||||||
|
pub async fn list_meter_readings(
|
||||||
|
State(pool): State<PgPool>,
|
||||||
|
claims: Claims,
|
||||||
|
Query(params): Query<MeterReadingQueryParams>,
|
||||||
|
) -> Result<Json<Vec<MeterReading>>, AppError> {
|
||||||
|
let user_id = Uuid::parse_str(&claims.sub)
|
||||||
|
.map_err(|_| AppError::BadRequest("User ID inválido".to_string()))?;
|
||||||
|
|
||||||
|
let access = sqlx::query(
|
||||||
|
r#"SELECT 1 FROM edge_assets ea
|
||||||
|
JOIN user_project_access upa ON ea.project_id = upa.project_id
|
||||||
|
WHERE ea.id = $1 AND upa.user_id = $2 AND upa.is_active = true"#,
|
||||||
|
)
|
||||||
|
.bind(params.asset_id)
|
||||||
|
.bind(user_id)
|
||||||
|
.fetch_optional(&pool)
|
||||||
|
.await?;
|
||||||
|
|
||||||
|
if access.is_none() {
|
||||||
|
return Err(AppError::Forbidden("Sin permiso para ver lecturas de este activo".to_string()));
|
||||||
|
}
|
||||||
|
|
||||||
|
let records: Vec<MeterReading> = sqlx::query_as::<Postgres, MeterReading>(
|
||||||
|
"SELECT * FROM meter_readings WHERE asset_id = $1 ORDER BY reading_time DESC",
|
||||||
|
)
|
||||||
|
.bind(params.asset_id)
|
||||||
|
.fetch_all(&pool)
|
||||||
|
.await?;
|
||||||
|
|
||||||
|
Ok(Json(records))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// POST /api/meters/readings — Registrar una nueva lectura.
|
||||||
|
pub async fn create_meter_reading(
|
||||||
|
State(pool): State<PgPool>,
|
||||||
|
claims: Claims,
|
||||||
|
Json(req): Json<CreateMeterReadingRequest>,
|
||||||
|
) -> Result<(StatusCode, Json<MeterReading>), AppError> {
|
||||||
|
let user_id = Uuid::parse_str(&claims.sub)
|
||||||
|
.map_err(|_| AppError::BadRequest("User ID inválido".to_string()))?;
|
||||||
|
|
||||||
|
let access = sqlx::query(
|
||||||
|
r#"SELECT 1 FROM edge_assets ea
|
||||||
|
JOIN user_project_access upa ON ea.project_id = upa.project_id
|
||||||
|
WHERE ea.id = $1 AND upa.user_id = $2 AND upa.is_active = true"#,
|
||||||
|
)
|
||||||
|
.bind(req.asset_id)
|
||||||
|
.bind(user_id)
|
||||||
|
.fetch_optional(&pool)
|
||||||
|
.await?;
|
||||||
|
|
||||||
|
if access.is_none() {
|
||||||
|
return Err(AppError::Forbidden("Sin permiso para registrar lecturas en este activo".to_string()));
|
||||||
|
}
|
||||||
|
|
||||||
|
let record: MeterReading = sqlx::query_as::<Postgres, MeterReading>(
|
||||||
|
r#"INSERT INTO meter_readings (
|
||||||
|
asset_id, reading_time, value, unit, meter_factor, corrected_value
|
||||||
|
)
|
||||||
|
VALUES ($1, $2, $3, $4, $5, $6)
|
||||||
|
RETURNING *"#,
|
||||||
|
)
|
||||||
|
.bind(req.asset_id)
|
||||||
|
.bind(req.reading_time)
|
||||||
|
.bind(req.value)
|
||||||
|
.bind(req.unit)
|
||||||
|
.bind(req.meter_factor)
|
||||||
|
.bind(req.corrected_value)
|
||||||
|
.fetch_one(&pool)
|
||||||
|
.await?;
|
||||||
|
|
||||||
|
Ok((StatusCode::CREATED, Json(record)))
|
||||||
|
}
|
||||||
@@ -1,10 +1,17 @@
|
|||||||
pub mod auth;
|
pub mod auth;
|
||||||
pub mod dashboard;
|
pub mod dashboard;
|
||||||
|
pub mod downtime;
|
||||||
pub mod edge_agents;
|
pub mod edge_agents;
|
||||||
pub mod edge_assets;
|
pub mod edge_assets;
|
||||||
pub mod edge_deployments;
|
pub mod edge_deployments;
|
||||||
pub mod edge_devices;
|
pub mod edge_devices;
|
||||||
pub mod edge_projects;
|
pub mod edge_projects;
|
||||||
pub mod edge_variables;
|
pub mod edge_variables;
|
||||||
|
pub mod lab_results;
|
||||||
|
pub mod meters;
|
||||||
pub mod operations_movements;
|
pub mod operations_movements;
|
||||||
pub mod telemetry;
|
pub mod telemetry;
|
||||||
|
pub mod well_tests;
|
||||||
|
pub mod audit_helper;
|
||||||
|
pub mod users;
|
||||||
|
pub mod anh_reports;
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
use crate::auth::Claims;
|
use crate::auth::Claims;
|
||||||
|
use crate::handlers::audit_helper;
|
||||||
use crate::errors::AppError;
|
use crate::errors::AppError;
|
||||||
use axum::{
|
use axum::{
|
||||||
Json,
|
Json,
|
||||||
@@ -8,7 +9,7 @@ use axum::{
|
|||||||
use bigdecimal::BigDecimal;
|
use bigdecimal::BigDecimal;
|
||||||
use serde::Deserialize;
|
use serde::Deserialize;
|
||||||
use shared_lib::models::{MovementType, OperationMovement};
|
use shared_lib::models::{MovementType, OperationMovement};
|
||||||
use sqlx::{PgPool, Postgres};
|
use sqlx::{PgPool, Postgres, Row};
|
||||||
use uuid::Uuid;
|
use uuid::Uuid;
|
||||||
|
|
||||||
#[derive(Debug, Deserialize)]
|
#[derive(Debug, Deserialize)]
|
||||||
@@ -70,8 +71,8 @@ pub async fn create_movement(
|
|||||||
let user_id = Uuid::parse_str(&claims.sub)
|
let user_id = Uuid::parse_str(&claims.sub)
|
||||||
.map_err(|_| AppError::BadRequest("User ID inválido".to_string()))?;
|
.map_err(|_| AppError::BadRequest("User ID inválido".to_string()))?;
|
||||||
|
|
||||||
let access = sqlx::query(
|
let project_row = sqlx::query(
|
||||||
r#"SELECT 1 FROM edge_assets ea
|
r#"SELECT ea.project_id FROM edge_assets ea
|
||||||
JOIN user_project_access upa ON ea.project_id = upa.project_id
|
JOIN user_project_access upa ON ea.project_id = upa.project_id
|
||||||
WHERE ea.id = $1 AND upa.user_id = $2 AND upa.is_active = true"#,
|
WHERE ea.id = $1 AND upa.user_id = $2 AND upa.is_active = true"#,
|
||||||
)
|
)
|
||||||
@@ -80,9 +81,10 @@ pub async fn create_movement(
|
|||||||
.fetch_optional(&pool)
|
.fetch_optional(&pool)
|
||||||
.await?;
|
.await?;
|
||||||
|
|
||||||
if access.is_none() {
|
let project_id: Uuid = match project_row {
|
||||||
return Err(AppError::Forbidden("Sin permiso para registrar movimientos en este activo".to_string()));
|
Some(row) => row.get("project_id"),
|
||||||
}
|
None => return Err(AppError::Forbidden("Sin permiso para registrar movimientos en este activo".to_string())),
|
||||||
|
};
|
||||||
|
|
||||||
let details = serde_json::json!({
|
let details = serde_json::json!({
|
||||||
"observaciones": req.observaciones,
|
"observaciones": req.observaciones,
|
||||||
@@ -105,6 +107,20 @@ pub async fn create_movement(
|
|||||||
.fetch_one(&pool)
|
.fetch_one(&pool)
|
||||||
.await?;
|
.await?;
|
||||||
|
|
||||||
|
// ─── Auditoría ────────────────────────────────────────────────────────────
|
||||||
|
audit_helper::log_audit(
|
||||||
|
&pool,
|
||||||
|
Some(project_id),
|
||||||
|
Some(user_id),
|
||||||
|
Some(claims.email), // Claims tiene email
|
||||||
|
"CREATE",
|
||||||
|
"MOVEMENT",
|
||||||
|
movement.id,
|
||||||
|
None,
|
||||||
|
Some(serde_json::to_value(&movement).unwrap_or_default()),
|
||||||
|
None, // Podríamos extraer IP del extractor ConnectInfo de axum
|
||||||
|
).await;
|
||||||
|
|
||||||
Ok((StatusCode::CREATED, Json(movement)))
|
Ok((StatusCode::CREATED, Json(movement)))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
496
services/backend-api/src/handlers/users.rs
Normal file
496
services/backend-api/src/handlers/users.rs
Normal file
@@ -0,0 +1,496 @@
|
|||||||
|
//! Handlers para gestión de usuarios (solo admins).
|
||||||
|
|
||||||
|
use crate::auth::AdminClaims;
|
||||||
|
use crate::handlers::audit_helper;
|
||||||
|
use argon2::{Argon2, password_hash::{SaltString, PasswordHasher, rand_core::OsRng}};
|
||||||
|
use axum::{
|
||||||
|
Json,
|
||||||
|
extract::{Path, Query, State},
|
||||||
|
http::StatusCode,
|
||||||
|
};
|
||||||
|
use chrono::{DateTime, Utc};
|
||||||
|
use serde::{Deserialize, Serialize};
|
||||||
|
use sqlx::PgPool;
|
||||||
|
use uuid::Uuid;
|
||||||
|
|
||||||
|
// ─── Request / Response structs ──────────────────────────────────────────────
|
||||||
|
|
||||||
|
#[derive(Deserialize)]
|
||||||
|
pub struct ListUsersParams {
|
||||||
|
pub role: Option<String>,
|
||||||
|
pub active: Option<bool>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Deserialize)]
|
||||||
|
pub struct CreateUserRequest {
|
||||||
|
pub email: String,
|
||||||
|
pub full_name: String,
|
||||||
|
pub password: String,
|
||||||
|
pub role_id: i32,
|
||||||
|
pub project_ids: Option<Vec<Uuid>>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Deserialize)]
|
||||||
|
pub struct UpdateUserRequest {
|
||||||
|
pub full_name: Option<String>,
|
||||||
|
pub role_id: Option<i32>,
|
||||||
|
pub is_active: Option<bool>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Deserialize)]
|
||||||
|
pub struct ResetPasswordRequest {
|
||||||
|
pub new_password: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Deserialize)]
|
||||||
|
pub struct SetUserProjectsRequest {
|
||||||
|
pub project_ids: Vec<Uuid>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(sqlx::FromRow, Serialize)]
|
||||||
|
pub struct UserListRow {
|
||||||
|
pub id: Uuid,
|
||||||
|
pub email: String,
|
||||||
|
pub full_name: Option<String>,
|
||||||
|
pub role_name: String,
|
||||||
|
pub is_active: Option<bool>,
|
||||||
|
pub project_count: Option<i64>,
|
||||||
|
pub created_at: Option<DateTime<Utc>>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(sqlx::FromRow, Serialize)]
|
||||||
|
pub struct UserDetailRow {
|
||||||
|
pub id: Uuid,
|
||||||
|
pub email: String,
|
||||||
|
pub full_name: Option<String>,
|
||||||
|
pub role_name: String,
|
||||||
|
pub role_id: i32,
|
||||||
|
pub is_active: Option<bool>,
|
||||||
|
pub created_at: Option<DateTime<Utc>>,
|
||||||
|
pub last_login: Option<DateTime<Utc>>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(sqlx::FromRow, Serialize)]
|
||||||
|
pub struct ProjectAccess {
|
||||||
|
pub id: Uuid,
|
||||||
|
pub name: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(sqlx::FromRow, Serialize)]
|
||||||
|
pub struct RoleRow {
|
||||||
|
pub id: i32,
|
||||||
|
pub name: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── Helpers ─────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
fn hash_password(password: &str) -> Result<String, (StatusCode, String)> {
|
||||||
|
let salt = SaltString::generate(&mut OsRng);
|
||||||
|
let argon2 = Argon2::default();
|
||||||
|
let hash = argon2
|
||||||
|
.hash_password(password.as_bytes(), &salt)
|
||||||
|
.map_err(|e| {
|
||||||
|
tracing::error!("Failed to hash password: {}", e);
|
||||||
|
(StatusCode::INTERNAL_SERVER_ERROR, "Error interno al procesar contraseña".to_string())
|
||||||
|
})?;
|
||||||
|
Ok(hash.to_string())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Check if a role_id corresponds to superadmin
|
||||||
|
async fn is_superadmin_role(pool: &PgPool, role_id: i32) -> Result<bool, (StatusCode, String)> {
|
||||||
|
let role = sqlx::query_as::<_, RoleRow>("SELECT id, name FROM roles WHERE id = $1")
|
||||||
|
.bind(role_id)
|
||||||
|
.fetch_optional(pool)
|
||||||
|
.await
|
||||||
|
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?
|
||||||
|
.ok_or((StatusCode::BAD_REQUEST, "Rol no encontrado".to_string()))?;
|
||||||
|
Ok(role.name == "superadmin")
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── Handlers ────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
/// GET /api/admin/users — Listar usuarios con filtros opcionales.
|
||||||
|
pub async fn list_users(
|
||||||
|
admin: AdminClaims,
|
||||||
|
State(pool): State<PgPool>,
|
||||||
|
Query(params): Query<ListUsersParams>,
|
||||||
|
) -> Result<Json<Vec<UserListRow>>, (StatusCode, String)> {
|
||||||
|
let _ = &admin;
|
||||||
|
|
||||||
|
let users = sqlx::query_as::<_, UserListRow>(
|
||||||
|
r#"
|
||||||
|
SELECT u.id, u.email, u.full_name, r.name AS role_name, u.is_active,
|
||||||
|
(SELECT COUNT(*) FROM user_project_access upa WHERE upa.user_id = u.id AND upa.is_active = true) AS project_count,
|
||||||
|
u.created_at
|
||||||
|
FROM users u
|
||||||
|
JOIN roles r ON u.role_id = r.id
|
||||||
|
WHERE ($1::text IS NULL OR r.name = $1)
|
||||||
|
AND ($2::bool IS NULL OR u.is_active = $2)
|
||||||
|
ORDER BY u.created_at DESC
|
||||||
|
"#,
|
||||||
|
)
|
||||||
|
.bind(¶ms.role)
|
||||||
|
.bind(params.active)
|
||||||
|
.fetch_all(&pool)
|
||||||
|
.await
|
||||||
|
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?;
|
||||||
|
|
||||||
|
Ok(Json(users))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// POST /api/admin/users — Crear un usuario.
|
||||||
|
pub async fn create_user(
|
||||||
|
admin: AdminClaims,
|
||||||
|
State(pool): State<PgPool>,
|
||||||
|
Json(req): Json<CreateUserRequest>,
|
||||||
|
) -> Result<(StatusCode, Json<serde_json::Value>), (StatusCode, String)> {
|
||||||
|
// Validaciones
|
||||||
|
if req.email.is_empty() || req.password.is_empty() || req.full_name.is_empty() {
|
||||||
|
return Err((StatusCode::BAD_REQUEST, "Campos obligatorios faltantes".to_string()));
|
||||||
|
}
|
||||||
|
|
||||||
|
if req.password.len() < 6 {
|
||||||
|
return Err((StatusCode::BAD_REQUEST, "La contraseña debe tener al menos 6 caracteres".to_string()));
|
||||||
|
}
|
||||||
|
|
||||||
|
// Block superadmin role
|
||||||
|
if is_superadmin_role(&pool, req.role_id).await? {
|
||||||
|
return Err((StatusCode::BAD_REQUEST, "No se puede asignar el rol superadmin".to_string()));
|
||||||
|
}
|
||||||
|
|
||||||
|
let password_hash = hash_password(&req.password)?;
|
||||||
|
|
||||||
|
let mut tx = pool.begin().await.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?;
|
||||||
|
|
||||||
|
let user = sqlx::query_as::<_, UserDetailRow>(
|
||||||
|
r#"
|
||||||
|
INSERT INTO users (email, full_name, password_hash, role_id)
|
||||||
|
VALUES ($1, $2, $3, $4)
|
||||||
|
RETURNING id, email, full_name, role_id,
|
||||||
|
(SELECT name FROM roles WHERE id = $4) AS role_name,
|
||||||
|
is_active, created_at, last_login
|
||||||
|
"#,
|
||||||
|
)
|
||||||
|
.bind(&req.email)
|
||||||
|
.bind(&req.full_name)
|
||||||
|
.bind(&password_hash)
|
||||||
|
.bind(req.role_id)
|
||||||
|
.fetch_one(&mut *tx)
|
||||||
|
.await
|
||||||
|
.map_err(|e| {
|
||||||
|
// Check for unique violation (23505)
|
||||||
|
if let sqlx::Error::Database(ref db_err) = e {
|
||||||
|
if db_err.code().as_deref() == Some("23505") {
|
||||||
|
return (StatusCode::CONFLICT, "El email ya está registrado".to_string());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
(StatusCode::INTERNAL_SERVER_ERROR, e.to_string())
|
||||||
|
})?;
|
||||||
|
|
||||||
|
// Insert project access if provided
|
||||||
|
if let Some(ref project_ids) = req.project_ids {
|
||||||
|
for pid in project_ids {
|
||||||
|
sqlx::query(
|
||||||
|
"INSERT INTO user_project_access (user_id, project_id) VALUES ($1, $2) ON CONFLICT DO NOTHING"
|
||||||
|
)
|
||||||
|
.bind(user.id)
|
||||||
|
.bind(pid)
|
||||||
|
.execute(&mut *tx)
|
||||||
|
.await
|
||||||
|
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
tx.commit().await.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?;
|
||||||
|
|
||||||
|
// Audit
|
||||||
|
let admin_id = Uuid::parse_str(&admin.0.sub).ok();
|
||||||
|
audit_helper::log_audit(
|
||||||
|
&pool,
|
||||||
|
None,
|
||||||
|
admin_id,
|
||||||
|
Some(admin.0.email.clone()),
|
||||||
|
"CREATE",
|
||||||
|
"USER",
|
||||||
|
user.id,
|
||||||
|
None,
|
||||||
|
Some(serde_json::json!({
|
||||||
|
"email": user.email,
|
||||||
|
"full_name": user.full_name,
|
||||||
|
"role": user.role_name,
|
||||||
|
})),
|
||||||
|
None,
|
||||||
|
).await;
|
||||||
|
|
||||||
|
Ok((StatusCode::CREATED, Json(serde_json::json!({
|
||||||
|
"id": user.id,
|
||||||
|
"email": user.email,
|
||||||
|
"full_name": user.full_name,
|
||||||
|
"role_name": user.role_name,
|
||||||
|
"is_active": user.is_active,
|
||||||
|
}))))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// GET /api/admin/users/:id — Detalle de un usuario con proyectos asignados.
|
||||||
|
pub async fn get_user(
|
||||||
|
admin: AdminClaims,
|
||||||
|
State(pool): State<PgPool>,
|
||||||
|
Path(id): Path<Uuid>,
|
||||||
|
) -> Result<Json<serde_json::Value>, (StatusCode, String)> {
|
||||||
|
let _ = &admin;
|
||||||
|
|
||||||
|
let user = sqlx::query_as::<_, UserDetailRow>(
|
||||||
|
r#"
|
||||||
|
SELECT u.id, u.email, u.full_name, r.name AS role_name, u.role_id, u.is_active, u.created_at, u.last_login
|
||||||
|
FROM users u
|
||||||
|
JOIN roles r ON u.role_id = r.id
|
||||||
|
WHERE u.id = $1
|
||||||
|
"#,
|
||||||
|
)
|
||||||
|
.bind(id)
|
||||||
|
.fetch_optional(&pool)
|
||||||
|
.await
|
||||||
|
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?
|
||||||
|
.ok_or((StatusCode::NOT_FOUND, "Usuario no encontrado".to_string()))?;
|
||||||
|
|
||||||
|
let projects = sqlx::query_as::<_, ProjectAccess>(
|
||||||
|
r#"
|
||||||
|
SELECT ep.id, ep.name
|
||||||
|
FROM edge_projects ep
|
||||||
|
JOIN user_project_access upa ON upa.project_id = ep.id
|
||||||
|
WHERE upa.user_id = $1 AND upa.is_active = true
|
||||||
|
ORDER BY ep.name ASC
|
||||||
|
"#,
|
||||||
|
)
|
||||||
|
.bind(id)
|
||||||
|
.fetch_all(&pool)
|
||||||
|
.await
|
||||||
|
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?;
|
||||||
|
|
||||||
|
Ok(Json(serde_json::json!({
|
||||||
|
"id": user.id,
|
||||||
|
"email": user.email,
|
||||||
|
"full_name": user.full_name,
|
||||||
|
"role_name": user.role_name,
|
||||||
|
"role_id": user.role_id,
|
||||||
|
"is_active": user.is_active,
|
||||||
|
"created_at": user.created_at,
|
||||||
|
"last_login": user.last_login,
|
||||||
|
"projects": projects,
|
||||||
|
})))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// PUT /api/admin/users/:id — Actualizar datos de un usuario.
|
||||||
|
pub async fn update_user(
|
||||||
|
admin: AdminClaims,
|
||||||
|
State(pool): State<PgPool>,
|
||||||
|
Path(id): Path<Uuid>,
|
||||||
|
Json(req): Json<UpdateUserRequest>,
|
||||||
|
) -> Result<Json<serde_json::Value>, (StatusCode, String)> {
|
||||||
|
let admin_id = Uuid::parse_str(&admin.0.sub)
|
||||||
|
.map_err(|_| (StatusCode::UNAUTHORIZED, "Invalid admin ID".to_string()))?;
|
||||||
|
|
||||||
|
// Block self-deactivation
|
||||||
|
if admin_id == id {
|
||||||
|
if let Some(false) = req.is_active {
|
||||||
|
return Err((StatusCode::BAD_REQUEST, "No puedes desactivarte a ti mismo".to_string()));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Block superadmin role assignment
|
||||||
|
if let Some(role_id) = req.role_id {
|
||||||
|
if is_superadmin_role(&pool, role_id).await? {
|
||||||
|
return Err((StatusCode::BAD_REQUEST, "No se puede asignar el rol superadmin".to_string()));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check target user exists and is not superadmin (unless caller is superadmin)
|
||||||
|
let target = sqlx::query_as::<_, UserDetailRow>(
|
||||||
|
r#"
|
||||||
|
SELECT u.id, u.email, u.full_name, r.name AS role_name, u.role_id, u.is_active, u.created_at, u.last_login
|
||||||
|
FROM users u
|
||||||
|
JOIN roles r ON u.role_id = r.id
|
||||||
|
WHERE u.id = $1
|
||||||
|
"#,
|
||||||
|
)
|
||||||
|
.bind(id)
|
||||||
|
.fetch_optional(&pool)
|
||||||
|
.await
|
||||||
|
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?
|
||||||
|
.ok_or((StatusCode::NOT_FOUND, "Usuario no encontrado".to_string()))?;
|
||||||
|
|
||||||
|
if target.role_name == "superadmin" && admin.0.role != "superadmin" {
|
||||||
|
return Err((StatusCode::FORBIDDEN, "No tienes permisos para modificar un superadmin".to_string()));
|
||||||
|
}
|
||||||
|
|
||||||
|
let user = sqlx::query_as::<_, UserDetailRow>(
|
||||||
|
r#"
|
||||||
|
UPDATE users SET
|
||||||
|
full_name = COALESCE($2, full_name),
|
||||||
|
role_id = COALESCE($3, role_id),
|
||||||
|
is_active = COALESCE($4, is_active)
|
||||||
|
WHERE id = $1
|
||||||
|
RETURNING id, email, full_name, role_id,
|
||||||
|
(SELECT name FROM roles WHERE id = COALESCE($3, role_id)) AS role_name,
|
||||||
|
is_active, created_at, last_login
|
||||||
|
"#,
|
||||||
|
)
|
||||||
|
.bind(id)
|
||||||
|
.bind(&req.full_name)
|
||||||
|
.bind(req.role_id)
|
||||||
|
.bind(req.is_active)
|
||||||
|
.fetch_one(&pool)
|
||||||
|
.await
|
||||||
|
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?;
|
||||||
|
|
||||||
|
// Audit
|
||||||
|
audit_helper::log_audit(
|
||||||
|
&pool,
|
||||||
|
None,
|
||||||
|
Some(admin_id),
|
||||||
|
Some(admin.0.email.clone()),
|
||||||
|
"UPDATE",
|
||||||
|
"USER",
|
||||||
|
user.id,
|
||||||
|
Some(serde_json::json!({
|
||||||
|
"full_name": target.full_name,
|
||||||
|
"role": target.role_name,
|
||||||
|
"is_active": target.is_active,
|
||||||
|
})),
|
||||||
|
Some(serde_json::json!({
|
||||||
|
"full_name": user.full_name,
|
||||||
|
"role": user.role_name,
|
||||||
|
"is_active": user.is_active,
|
||||||
|
})),
|
||||||
|
None,
|
||||||
|
).await;
|
||||||
|
|
||||||
|
Ok(Json(serde_json::json!({
|
||||||
|
"id": user.id,
|
||||||
|
"email": user.email,
|
||||||
|
"full_name": user.full_name,
|
||||||
|
"role_name": user.role_name,
|
||||||
|
"is_active": user.is_active,
|
||||||
|
})))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// POST /api/admin/users/:id/reset-password — Restablecer contraseña de un usuario.
|
||||||
|
pub async fn reset_password(
|
||||||
|
admin: AdminClaims,
|
||||||
|
State(pool): State<PgPool>,
|
||||||
|
Path(id): Path<Uuid>,
|
||||||
|
Json(req): Json<ResetPasswordRequest>,
|
||||||
|
) -> Result<Json<serde_json::Value>, (StatusCode, String)> {
|
||||||
|
if req.new_password.len() < 6 {
|
||||||
|
return Err((StatusCode::BAD_REQUEST, "La contraseña debe tener al menos 6 caracteres".to_string()));
|
||||||
|
}
|
||||||
|
|
||||||
|
let password_hash = hash_password(&req.new_password)?;
|
||||||
|
|
||||||
|
let result = sqlx::query("UPDATE users SET password_hash = $2 WHERE id = $1")
|
||||||
|
.bind(id)
|
||||||
|
.bind(&password_hash)
|
||||||
|
.execute(&pool)
|
||||||
|
.await
|
||||||
|
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?;
|
||||||
|
|
||||||
|
if result.rows_affected() == 0 {
|
||||||
|
return Err((StatusCode::NOT_FOUND, "Usuario no encontrado".to_string()));
|
||||||
|
}
|
||||||
|
|
||||||
|
// Audit
|
||||||
|
let admin_id = Uuid::parse_str(&admin.0.sub).ok();
|
||||||
|
audit_helper::log_audit(
|
||||||
|
&pool,
|
||||||
|
None,
|
||||||
|
admin_id,
|
||||||
|
Some(admin.0.email.clone()),
|
||||||
|
"RESET_PASSWORD",
|
||||||
|
"USER",
|
||||||
|
id,
|
||||||
|
None,
|
||||||
|
None,
|
||||||
|
None,
|
||||||
|
).await;
|
||||||
|
|
||||||
|
Ok(Json(serde_json::json!({
|
||||||
|
"message": "Contraseña restablecida exitosamente"
|
||||||
|
})))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// PUT /api/admin/users/:id/projects — Asignar proyectos a un usuario (reemplaza acceso existente).
|
||||||
|
pub async fn set_user_projects(
|
||||||
|
admin: AdminClaims,
|
||||||
|
State(pool): State<PgPool>,
|
||||||
|
Path(id): Path<Uuid>,
|
||||||
|
Json(req): Json<SetUserProjectsRequest>,
|
||||||
|
) -> Result<Json<serde_json::Value>, (StatusCode, String)> {
|
||||||
|
// Verify user exists
|
||||||
|
let exists = sqlx::query("SELECT 1 FROM users WHERE id = $1")
|
||||||
|
.bind(id)
|
||||||
|
.fetch_optional(&pool)
|
||||||
|
.await
|
||||||
|
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?;
|
||||||
|
|
||||||
|
if exists.is_none() {
|
||||||
|
return Err((StatusCode::NOT_FOUND, "Usuario no encontrado".to_string()));
|
||||||
|
}
|
||||||
|
|
||||||
|
let mut tx = pool.begin().await.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?;
|
||||||
|
|
||||||
|
// Delete existing access
|
||||||
|
sqlx::query("DELETE FROM user_project_access WHERE user_id = $1")
|
||||||
|
.bind(id)
|
||||||
|
.execute(&mut *tx)
|
||||||
|
.await
|
||||||
|
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?;
|
||||||
|
|
||||||
|
// Re-insert
|
||||||
|
for pid in &req.project_ids {
|
||||||
|
sqlx::query("INSERT INTO user_project_access (user_id, project_id) VALUES ($1, $2)")
|
||||||
|
.bind(id)
|
||||||
|
.bind(pid)
|
||||||
|
.execute(&mut *tx)
|
||||||
|
.await
|
||||||
|
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?;
|
||||||
|
}
|
||||||
|
|
||||||
|
tx.commit().await.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?;
|
||||||
|
|
||||||
|
// Audit
|
||||||
|
let admin_id = Uuid::parse_str(&admin.0.sub).ok();
|
||||||
|
audit_helper::log_audit(
|
||||||
|
&pool,
|
||||||
|
None,
|
||||||
|
admin_id,
|
||||||
|
Some(admin.0.email.clone()),
|
||||||
|
"UPDATE_PROJECTS",
|
||||||
|
"USER",
|
||||||
|
id,
|
||||||
|
None,
|
||||||
|
Some(serde_json::json!({ "project_ids": req.project_ids })),
|
||||||
|
None,
|
||||||
|
).await;
|
||||||
|
|
||||||
|
Ok(Json(serde_json::json!({
|
||||||
|
"message": "Acceso a proyectos actualizado",
|
||||||
|
"count": req.project_ids.len(),
|
||||||
|
})))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// GET /api/admin/roles — Listar roles disponibles (excluye superadmin).
|
||||||
|
pub async fn list_roles(
|
||||||
|
admin: AdminClaims,
|
||||||
|
State(pool): State<PgPool>,
|
||||||
|
) -> Result<Json<Vec<RoleRow>>, (StatusCode, String)> {
|
||||||
|
let _ = &admin;
|
||||||
|
|
||||||
|
let roles = sqlx::query_as::<_, RoleRow>(
|
||||||
|
"SELECT id, name FROM roles WHERE name != 'superadmin' ORDER BY id ASC"
|
||||||
|
)
|
||||||
|
.fetch_all(&pool)
|
||||||
|
.await
|
||||||
|
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?;
|
||||||
|
|
||||||
|
Ok(Json(roles))
|
||||||
|
}
|
||||||
128
services/backend-api/src/handlers/well_tests.rs
Normal file
128
services/backend-api/src/handlers/well_tests.rs
Normal file
@@ -0,0 +1,128 @@
|
|||||||
|
use crate::auth::Claims;
|
||||||
|
use crate::handlers::audit_helper;
|
||||||
|
use crate::errors::AppError;
|
||||||
|
use axum::{
|
||||||
|
Json,
|
||||||
|
extract::{Query, State},
|
||||||
|
http::StatusCode,
|
||||||
|
};
|
||||||
|
use bigdecimal::BigDecimal;
|
||||||
|
use serde::Deserialize;
|
||||||
|
use shared_lib::models::WellTest;
|
||||||
|
use sqlx::{PgPool, Postgres, Row};
|
||||||
|
use uuid::Uuid;
|
||||||
|
|
||||||
|
#[derive(Debug, Deserialize)]
|
||||||
|
pub struct CreateWellTestRequest {
|
||||||
|
pub asset_id: Uuid,
|
||||||
|
pub test_date: chrono::DateTime<chrono::Utc>,
|
||||||
|
pub test_type: String,
|
||||||
|
pub bopd: Option<BigDecimal>,
|
||||||
|
pub bwpd: Option<BigDecimal>,
|
||||||
|
pub gor: Option<BigDecimal>,
|
||||||
|
pub bsw: Option<BigDecimal>,
|
||||||
|
pub choke_size: Option<String>,
|
||||||
|
pub wellhead_pressure: Option<BigDecimal>,
|
||||||
|
pub casing_pressure: Option<BigDecimal>,
|
||||||
|
pub comments: Option<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Deserialize)]
|
||||||
|
pub struct WellTestQueryParams {
|
||||||
|
pub asset_id: Uuid,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// GET /api/well-tests — Listar pruebas de un pozo.
|
||||||
|
pub async fn list_well_tests(
|
||||||
|
State(pool): State<PgPool>,
|
||||||
|
claims: Claims,
|
||||||
|
Query(params): Query<WellTestQueryParams>,
|
||||||
|
) -> Result<Json<Vec<WellTest>>, AppError> {
|
||||||
|
let user_id = Uuid::parse_str(&claims.sub)
|
||||||
|
.map_err(|_| AppError::BadRequest("User ID inválido".to_string()))?;
|
||||||
|
|
||||||
|
let access = sqlx::query(
|
||||||
|
r#"SELECT 1 FROM edge_assets ea
|
||||||
|
JOIN user_project_access upa ON ea.project_id = upa.project_id
|
||||||
|
WHERE ea.id = $1 AND upa.user_id = $2 AND upa.is_active = true"#,
|
||||||
|
)
|
||||||
|
.bind(params.asset_id)
|
||||||
|
.bind(user_id)
|
||||||
|
.fetch_optional(&pool)
|
||||||
|
.await?;
|
||||||
|
|
||||||
|
if access.is_none() {
|
||||||
|
return Err(AppError::Forbidden("Sin permiso para ver pruebas de este activo".to_string()));
|
||||||
|
}
|
||||||
|
|
||||||
|
let records: Vec<WellTest> = sqlx::query_as::<Postgres, WellTest>(
|
||||||
|
"SELECT * FROM well_tests WHERE asset_id = $1 ORDER BY test_date DESC",
|
||||||
|
)
|
||||||
|
.bind(params.asset_id)
|
||||||
|
.fetch_all(&pool)
|
||||||
|
.await?;
|
||||||
|
|
||||||
|
Ok(Json(records))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// POST /api/well-tests — Registrar una prueba de pozo.
|
||||||
|
pub async fn create_well_test(
|
||||||
|
State(pool): State<PgPool>,
|
||||||
|
claims: Claims,
|
||||||
|
Json(req): Json<CreateWellTestRequest>,
|
||||||
|
) -> Result<(StatusCode, Json<WellTest>), AppError> {
|
||||||
|
let user_id = Uuid::parse_str(&claims.sub)
|
||||||
|
.map_err(|_| AppError::BadRequest("User ID inválido".to_string()))?;
|
||||||
|
|
||||||
|
let project_row = sqlx::query(
|
||||||
|
r#"SELECT ea.project_id FROM edge_assets ea
|
||||||
|
JOIN user_project_access upa ON ea.project_id = upa.project_id
|
||||||
|
WHERE ea.id = $1 AND upa.user_id = $2 AND upa.is_active = true"#,
|
||||||
|
)
|
||||||
|
.bind(req.asset_id)
|
||||||
|
.bind(user_id)
|
||||||
|
.fetch_optional(&pool)
|
||||||
|
.await?;
|
||||||
|
|
||||||
|
let project_id: Uuid = match project_row {
|
||||||
|
Some(row) => row.get("project_id"),
|
||||||
|
None => return Err(AppError::Forbidden("Sin permiso para registrar pruebas en este activo".to_string())),
|
||||||
|
};
|
||||||
|
|
||||||
|
let record: WellTest = sqlx::query_as::<Postgres, WellTest>(
|
||||||
|
r#"INSERT INTO well_tests (
|
||||||
|
asset_id, test_date, test_type, bopd, bwpd, gor, bsw, choke_size, wellhead_pressure, casing_pressure, comments
|
||||||
|
)
|
||||||
|
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11)
|
||||||
|
RETURNING *"#,
|
||||||
|
)
|
||||||
|
.bind(req.asset_id)
|
||||||
|
.bind(req.test_date)
|
||||||
|
.bind(req.test_type)
|
||||||
|
.bind(req.bopd)
|
||||||
|
.bind(req.bwpd)
|
||||||
|
.bind(req.gor)
|
||||||
|
.bind(req.bsw)
|
||||||
|
.bind(req.choke_size)
|
||||||
|
.bind(req.wellhead_pressure)
|
||||||
|
.bind(req.casing_pressure)
|
||||||
|
.bind(req.comments)
|
||||||
|
.fetch_one(&pool)
|
||||||
|
.await?;
|
||||||
|
|
||||||
|
// ─── Auditoría ────────────────────────────────────────────────────────────
|
||||||
|
audit_helper::log_audit(
|
||||||
|
&pool,
|
||||||
|
Some(project_id),
|
||||||
|
Some(user_id),
|
||||||
|
Some(claims.email),
|
||||||
|
"CREATE",
|
||||||
|
"WELL_TEST",
|
||||||
|
record.id,
|
||||||
|
None,
|
||||||
|
Some(serde_json::to_value(&record).unwrap_or_default()),
|
||||||
|
None,
|
||||||
|
).await;
|
||||||
|
|
||||||
|
Ok((StatusCode::CREATED, Json(record)))
|
||||||
|
}
|
||||||
@@ -1,22 +1,38 @@
|
|||||||
mod auth;
|
mod auth;
|
||||||
mod db;
|
mod db;
|
||||||
mod handlers;
|
|
||||||
mod errors;
|
mod errors;
|
||||||
|
mod handlers;
|
||||||
|
mod notifier;
|
||||||
|
mod watchdog;
|
||||||
|
|
||||||
use axum::{
|
use axum::{
|
||||||
Router,
|
Router,
|
||||||
|
extract::{
|
||||||
|
FromRef, Query, State,
|
||||||
|
ws::{Message, WebSocket, WebSocketUpgrade},
|
||||||
|
},
|
||||||
|
response::IntoResponse,
|
||||||
routing::{delete, get, post, put},
|
routing::{delete, get, post, put},
|
||||||
};
|
};
|
||||||
use dotenv::dotenv;
|
use dotenvy::dotenv;
|
||||||
|
use serde::{Deserialize, Serialize};
|
||||||
use std::net::SocketAddr;
|
use std::net::SocketAddr;
|
||||||
|
use tokio::sync::broadcast;
|
||||||
use tracing_subscriber::{layer::SubscriberExt, util::SubscriberInitExt};
|
use tracing_subscriber::{layer::SubscriberExt, util::SubscriberInitExt};
|
||||||
|
|
||||||
use axum::extract::FromRef;
|
use std::sync::Arc;
|
||||||
|
|
||||||
|
#[derive(Clone, Debug, Serialize, Deserialize)]
|
||||||
|
pub struct TelemetryEvent {
|
||||||
|
pub project_id: Arc<str>,
|
||||||
|
pub payload: Arc<str>,
|
||||||
|
}
|
||||||
|
|
||||||
#[derive(Clone)]
|
#[derive(Clone)]
|
||||||
pub struct AppState {
|
pub struct AppState {
|
||||||
pub pool: sqlx::PgPool,
|
pub pool: sqlx::PgPool,
|
||||||
pub mqtt_client: rumqttc::AsyncClient,
|
pub mqtt_client: rumqttc::AsyncClient,
|
||||||
|
pub tx: broadcast::Sender<TelemetryEvent>,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl FromRef<AppState> for sqlx::PgPool {
|
impl FromRef<AppState> for sqlx::PgPool {
|
||||||
@@ -100,13 +116,14 @@ async fn main() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// 4. AHORA SÍ: Conectar al MQTT broker (ya que el usuario existe en la DB)
|
// 4. AHORA SÍ: Conectar al MQTT broker (ya que el usuario existe en la DB)
|
||||||
let mqtt_host = std::env::var("MQTT_HOST").unwrap_or_else(|_| "localhost".to_string());
|
let mqtt_host = std::env::var("MQTT_HOST").expect("MQTT_HOST must be set");
|
||||||
let mqtt_port: u16 = std::env::var("MQTT_PORT")
|
let mqtt_port: u16 = std::env::var("MQTT_PORT")
|
||||||
.unwrap_or_else(|_| "1883".to_string())
|
.expect("MQTT_PORT must be set")
|
||||||
.parse()
|
.parse()
|
||||||
.unwrap_or(1883);
|
.expect("MQTT_PORT must be a valid number");
|
||||||
|
|
||||||
let mut mqtt_options = rumqttc::MqttOptions::new("omnioil-backend-api", &mqtt_host, mqtt_port);
|
let client_id = format!("omnioil-backend-api-{}", uuid::Uuid::new_v4());
|
||||||
|
let mut mqtt_options = rumqttc::MqttOptions::new(client_id, &mqtt_host, mqtt_port);
|
||||||
mqtt_options.set_keep_alive(std::time::Duration::from_secs(30));
|
mqtt_options.set_keep_alive(std::time::Duration::from_secs(30));
|
||||||
|
|
||||||
if let (Ok(u), Ok(p)) = (std::env::var("MQTT_USER"), std::env::var("MQTT_PASSWORD")) {
|
if let (Ok(u), Ok(p)) = (std::env::var("MQTT_USER"), std::env::var("MQTT_PASSWORD")) {
|
||||||
@@ -115,32 +132,85 @@ async fn main() {
|
|||||||
|
|
||||||
let (mqtt_client, mut eventloop) = rumqttc::AsyncClient::new(mqtt_options, 10);
|
let (mqtt_client, mut eventloop) = rumqttc::AsyncClient::new(mqtt_options, 10);
|
||||||
|
|
||||||
// Spawn MQTT event loop para mantener la conexión viva
|
let (tx, _) = broadcast::channel::<TelemetryEvent>(1000);
|
||||||
|
let state = AppState {
|
||||||
|
pool: pool.clone(),
|
||||||
|
mqtt_client: mqtt_client.clone(),
|
||||||
|
tx: tx.clone(),
|
||||||
|
};
|
||||||
|
|
||||||
|
// Spawn MQTT event loop para procesar mensajes
|
||||||
|
let tx_relay = tx.clone();
|
||||||
tokio::spawn(async move {
|
tokio::spawn(async move {
|
||||||
loop {
|
loop {
|
||||||
if let Err(e) = eventloop.poll().await {
|
match eventloop.poll().await {
|
||||||
|
Ok(notification) => {
|
||||||
|
if let rumqttc::Event::Incoming(rumqttc::Packet::Publish(publish)) = notification {
|
||||||
|
// Relay any JSON message from omnioil topics to WebSocket
|
||||||
|
if publish.topic.starts_with("omnioil/") {
|
||||||
|
let topic_parts: Vec<&str> = publish.topic.split('/').collect();
|
||||||
|
let project_id = topic_parts.get(2).unwrap_or(&"unknown").to_string();
|
||||||
|
|
||||||
|
let decompressed = shared_lib::compression::decompress_if_needed(&publish.payload);
|
||||||
|
|
||||||
|
if let Ok(payload_str) = String::from_utf8(decompressed.to_vec()) {
|
||||||
|
tracing::debug!("Relaying MQTT message to WebSocket: Topic={}, Project={}", publish.topic, project_id);
|
||||||
|
|
||||||
|
// Envolver en un evento unificado
|
||||||
|
let event = TelemetryEvent {
|
||||||
|
project_id: Arc::from(project_id.to_lowercase()), // Pre-normalizado para velocidad
|
||||||
|
payload: Arc::from(payload_str),
|
||||||
|
};
|
||||||
|
|
||||||
|
match tx_relay.send(event) {
|
||||||
|
Ok(receivers) => {
|
||||||
|
tracing::info!("📥 MQTT Message routed to {} UI listeners", receivers);
|
||||||
|
}
|
||||||
|
Err(e) => {
|
||||||
|
tracing::warn!("⚠️ No active UI listeners for project {}: {:?}", project_id, e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
tracing::error!("❌ Failed to parse MQTT payload as UTF-8 from topic: {}", publish.topic);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Err(e) => {
|
||||||
tracing::error!("MQTT connection error: {:?}", e);
|
tracing::error!("MQTT connection error: {:?}", e);
|
||||||
tokio::time::sleep(std::time::Duration::from_secs(5)).await;
|
tokio::time::sleep(std::time::Duration::from_secs(5)).await;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Suscribirse a todo el árbol de omnioil para relay a UI
|
||||||
|
if let Err(e) = mqtt_client
|
||||||
|
.subscribe("omnioil/#", rumqttc::QoS::AtMostOnce)
|
||||||
|
.await
|
||||||
|
{
|
||||||
|
tracing::error!("Failed to subscribe to omnioil: {:?}", e);
|
||||||
|
}
|
||||||
|
|
||||||
let state = AppState { pool, mqtt_client };
|
// 5. Iniciar Watchdog (Monitoreo de cumplimiento ANH)
|
||||||
|
let watchdog_pool = pool.clone();
|
||||||
|
tokio::spawn(async move {
|
||||||
|
watchdog::run_watchdog(watchdog_pool).await;
|
||||||
|
});
|
||||||
|
|
||||||
// Rutas Edge System
|
// Rutas Edge System
|
||||||
let edge_routes = Router::new()
|
let edge_routes = Router::new()
|
||||||
// Projects
|
// Projects
|
||||||
.route("/projects", get(handlers::edge_projects::list_projects))
|
|
||||||
.route("/projects", post(handlers::edge_projects::create_project))
|
|
||||||
.route("/projects/{id}", get(handlers::edge_projects::get_project))
|
|
||||||
.route(
|
.route(
|
||||||
"/projects/{id}",
|
"/projects",
|
||||||
put(handlers::edge_projects::update_project),
|
get(handlers::edge_projects::list_projects)
|
||||||
|
.post(handlers::edge_projects::create_project),
|
||||||
)
|
)
|
||||||
.route(
|
.route(
|
||||||
"/projects/{id}",
|
"/projects/{id}",
|
||||||
delete(handlers::edge_projects::delete_project),
|
get(handlers::edge_projects::get_project)
|
||||||
|
.put(handlers::edge_projects::update_project)
|
||||||
|
.delete(handlers::edge_projects::delete_project),
|
||||||
)
|
)
|
||||||
.route(
|
.route(
|
||||||
"/projects/{id}/full",
|
"/projects/{id}/full",
|
||||||
@@ -150,6 +220,14 @@ async fn main() {
|
|||||||
"/projects/{id}/installer",
|
"/projects/{id}/installer",
|
||||||
get(handlers::edge_projects::download_installer),
|
get(handlers::edge_projects::download_installer),
|
||||||
)
|
)
|
||||||
|
.route(
|
||||||
|
"/projects/{id}/deploy",
|
||||||
|
post(handlers::edge_projects::deploy_project),
|
||||||
|
)
|
||||||
|
.route(
|
||||||
|
"/projects/{id}/rebuild",
|
||||||
|
post(handlers::edge_projects::rebuild_installer),
|
||||||
|
)
|
||||||
// Assets per project
|
// Assets per project
|
||||||
.route(
|
.route(
|
||||||
"/projects/{project_id}/assets",
|
"/projects/{project_id}/assets",
|
||||||
@@ -157,40 +235,33 @@ async fn main() {
|
|||||||
)
|
)
|
||||||
// Assets general
|
// Assets general
|
||||||
.route("/assets", post(handlers::edge_assets::create_asset))
|
.route("/assets", post(handlers::edge_assets::create_asset))
|
||||||
.route("/assets/{id}", get(handlers::edge_assets::get_asset))
|
.route(
|
||||||
.route("/assets/{id}", put(handlers::edge_assets::update_asset))
|
"/assets/{id}",
|
||||||
|
get(handlers::edge_assets::get_asset)
|
||||||
|
.put(handlers::edge_assets::update_asset)
|
||||||
|
.delete(handlers::edge_assets::delete_asset),
|
||||||
|
)
|
||||||
.route("/health", get(|| async { "OK" }))
|
.route("/health", get(|| async { "OK" }))
|
||||||
.route("/assets/{id}", delete(handlers::edge_assets::delete_asset))
|
|
||||||
// Devices (bajo assets)
|
// Devices (bajo assets)
|
||||||
.route(
|
.route(
|
||||||
"/assets/{asset_id}/devices",
|
"/assets/{asset_id}/devices",
|
||||||
get(handlers::edge_devices::list_devices),
|
get(handlers::edge_devices::list_devices).post(handlers::edge_devices::create_device),
|
||||||
)
|
)
|
||||||
.route(
|
|
||||||
"/assets/{asset_id}/devices",
|
|
||||||
post(handlers::edge_devices::create_device),
|
|
||||||
)
|
|
||||||
.route("/devices/{id}", put(handlers::edge_devices::update_device))
|
|
||||||
.route(
|
.route(
|
||||||
"/devices/{id}",
|
"/devices/{id}",
|
||||||
delete(handlers::edge_devices::delete_device),
|
put(handlers::edge_devices::update_device)
|
||||||
|
.delete(handlers::edge_devices::delete_device),
|
||||||
)
|
)
|
||||||
// Variables
|
// Variables
|
||||||
.route(
|
.route(
|
||||||
"/devices/{device_id}/variables",
|
"/devices/{device_id}/variables",
|
||||||
get(handlers::edge_variables::list_variables),
|
get(handlers::edge_variables::list_variables)
|
||||||
)
|
.post(handlers::edge_variables::create_variable),
|
||||||
.route(
|
|
||||||
"/devices/{device_id}/variables",
|
|
||||||
post(handlers::edge_variables::create_variable),
|
|
||||||
)
|
)
|
||||||
.route(
|
.route(
|
||||||
"/variables/{id}",
|
"/variables/{id}",
|
||||||
put(handlers::edge_variables::update_variable),
|
put(handlers::edge_variables::update_variable)
|
||||||
)
|
.delete(handlers::edge_variables::delete_variable),
|
||||||
.route(
|
|
||||||
"/variables/{id}",
|
|
||||||
delete(handlers::edge_variables::delete_variable),
|
|
||||||
)
|
)
|
||||||
// Deployments
|
// Deployments
|
||||||
.route(
|
.route(
|
||||||
@@ -202,10 +273,10 @@ async fn main() {
|
|||||||
post(handlers::edge_deployments::trigger_build),
|
post(handlers::edge_deployments::trigger_build),
|
||||||
)
|
)
|
||||||
// Agent Logs & Health
|
// Agent Logs & Health
|
||||||
.route("/agents/logs", get(handlers::edge_agents::list_agent_logs))
|
|
||||||
.route(
|
.route(
|
||||||
"/agents/logs",
|
"/agents/logs",
|
||||||
post(handlers::edge_agents::create_agent_log),
|
get(handlers::edge_agents::list_agent_logs)
|
||||||
|
.post(handlers::edge_agents::create_agent_log),
|
||||||
)
|
)
|
||||||
.route(
|
.route(
|
||||||
"/agents/{project_id}/health",
|
"/agents/{project_id}/health",
|
||||||
@@ -215,7 +286,7 @@ async fn main() {
|
|||||||
// Configurar CORS (lista separada por comas en ALLOWED_ORIGINS)
|
// Configurar CORS (lista separada por comas en ALLOWED_ORIGINS)
|
||||||
use tower_http::cors::AllowOrigin;
|
use tower_http::cors::AllowOrigin;
|
||||||
let allowed_origins_env = std::env::var("ALLOWED_ORIGINS")
|
let allowed_origins_env = std::env::var("ALLOWED_ORIGINS")
|
||||||
.unwrap_or_else(|_| "http://localhost:3000,http://localhost:5173,https://mobile.omnioil.app,https://app.omnioil.app".to_string());
|
.expect("ALLOWED_ORIGINS must be set (comma separated list)");
|
||||||
let allowed_list: Vec<axum::http::HeaderValue> = allowed_origins_env
|
let allowed_list: Vec<axum::http::HeaderValue> = allowed_origins_env
|
||||||
.split(',')
|
.split(',')
|
||||||
.filter_map(|s| axum::http::HeaderValue::from_str(s.trim()).ok())
|
.filter_map(|s| axum::http::HeaderValue::from_str(s.trim()).ok())
|
||||||
@@ -224,24 +295,27 @@ async fn main() {
|
|||||||
let allow_origin = if allowed_list.is_empty() {
|
let allow_origin = if allowed_list.is_empty() {
|
||||||
// Fallback para evitar panics: localhost dev
|
// Fallback para evitar panics: localhost dev
|
||||||
AllowOrigin::list(vec![
|
AllowOrigin::list(vec![
|
||||||
axum::http::HeaderValue::from_static("http://localhost:3000"),
|
axum::http::header::HeaderValue::from_static("http://localhost:3000"),
|
||||||
|
axum::http::header::HeaderValue::from_static("http://127.0.0.1:3000"),
|
||||||
|
axum::http::header::HeaderValue::from_static("http://localhost:5173"),
|
||||||
])
|
])
|
||||||
} else {
|
} else {
|
||||||
AllowOrigin::list(allowed_list)
|
// Añadir variantes comunes de localhost si están en la lista
|
||||||
|
let mut extended_list = allowed_list.clone();
|
||||||
|
if allowed_origins_env.contains("localhost") && !allowed_origins_env.contains("127.0.0.1") {
|
||||||
|
if let Ok(val) = axum::http::HeaderValue::from_str(
|
||||||
|
&allowed_origins_env.replace("localhost", "127.0.0.1"),
|
||||||
|
) {
|
||||||
|
extended_list.push(val);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
AllowOrigin::list(extended_list)
|
||||||
};
|
};
|
||||||
|
|
||||||
let cors = tower_http::cors::CorsLayer::new()
|
let cors = tower_http::cors::CorsLayer::new()
|
||||||
.allow_origin(allow_origin)
|
.allow_origin(allow_origin)
|
||||||
.allow_methods([
|
.allow_methods(tower_http::cors::Any)
|
||||||
axum::http::Method::GET,
|
.allow_headers(tower_http::cors::Any);
|
||||||
axum::http::Method::POST,
|
|
||||||
axum::http::Method::PUT,
|
|
||||||
axum::http::Method::DELETE,
|
|
||||||
])
|
|
||||||
.allow_headers([
|
|
||||||
axum::http::header::AUTHORIZATION,
|
|
||||||
axum::http::header::CONTENT_TYPE,
|
|
||||||
]);
|
|
||||||
|
|
||||||
// TODO: Rate limiting deshabilitado temporalmente — tower_governor 0.8.0
|
// TODO: Rate limiting deshabilitado temporalmente — tower_governor 0.8.0
|
||||||
// causa 500 silencioso dentro de Docker al no poder extraer ConnectInfo.
|
// causa 500 silencioso dentro de Docker al no poder extraer ConnectInfo.
|
||||||
@@ -252,12 +326,35 @@ async fn main() {
|
|||||||
let auth_routes = Router::new()
|
let auth_routes = Router::new()
|
||||||
.route("/login", post(handlers::auth::login))
|
.route("/login", post(handlers::auth::login))
|
||||||
.route("/refresh", post(handlers::auth::refresh))
|
.route("/refresh", post(handlers::auth::refresh))
|
||||||
.route("/register", post(handlers::auth::register));
|
.route("/register", post(handlers::auth::register))
|
||||||
|
.route("/setup/status", get(handlers::auth::get_setup_status))
|
||||||
|
.route("/setup/register", post(handlers::auth::setup_register));
|
||||||
|
|
||||||
|
// Rutas Admin (gestión de usuarios — solo admins via AdminClaims)
|
||||||
|
let admin_routes = Router::new()
|
||||||
|
.route(
|
||||||
|
"/users",
|
||||||
|
get(handlers::users::list_users).post(handlers::users::create_user),
|
||||||
|
)
|
||||||
|
.route(
|
||||||
|
"/users/{id}",
|
||||||
|
get(handlers::users::get_user).put(handlers::users::update_user),
|
||||||
|
)
|
||||||
|
.route(
|
||||||
|
"/users/{id}/reset-password",
|
||||||
|
post(handlers::users::reset_password),
|
||||||
|
)
|
||||||
|
.route(
|
||||||
|
"/users/{id}/projects",
|
||||||
|
put(handlers::users::set_user_projects),
|
||||||
|
)
|
||||||
|
.route("/roles", get(handlers::users::list_roles));
|
||||||
|
|
||||||
// Rutas app
|
// Rutas app
|
||||||
let app = Router::new()
|
let app = Router::new()
|
||||||
.route("/", get(root))
|
.route("/", get(root))
|
||||||
.nest("/api/auth", auth_routes)
|
.nest("/api/auth", auth_routes)
|
||||||
|
.nest("/api/admin", admin_routes)
|
||||||
.route(
|
.route(
|
||||||
"/api/dashboard/summary",
|
"/api/dashboard/summary",
|
||||||
get(handlers::dashboard::get_summary),
|
get(handlers::dashboard::get_summary),
|
||||||
@@ -268,16 +365,55 @@ async fn main() {
|
|||||||
"/api/telemetry/{asset_id}",
|
"/api/telemetry/{asset_id}",
|
||||||
get(handlers::telemetry::get_asset_telemetry),
|
get(handlers::telemetry::get_asset_telemetry),
|
||||||
)
|
)
|
||||||
|
// Reportes ANH (Resolución 0651)
|
||||||
|
.route(
|
||||||
|
"/api/anh/reports",
|
||||||
|
get(handlers::anh_reports::list_reports),
|
||||||
|
)
|
||||||
|
.route(
|
||||||
|
"/api/anh/reports/{id}",
|
||||||
|
get(handlers::anh_reports::get_report),
|
||||||
|
)
|
||||||
|
.route(
|
||||||
|
"/api/anh/reports/{id}/download",
|
||||||
|
get(handlers::anh_reports::download_report),
|
||||||
|
)
|
||||||
|
.route(
|
||||||
|
"/api/anh/reports/{id}/hash",
|
||||||
|
get(handlers::anh_reports::download_report_hash),
|
||||||
|
)
|
||||||
// Tank inventory
|
// Tank inventory
|
||||||
.route(
|
.route(
|
||||||
"/api/tanks",
|
"/api/tanks",
|
||||||
get(handlers::edge_assets::list_tanks_by_project),
|
get(handlers::edge_assets::list_tanks_by_project),
|
||||||
)
|
)
|
||||||
// Movimientos operativos (ANH)
|
// Datos Operativos (ANH 0651)
|
||||||
.route("/api/movements", get(handlers::operations_movements::list_movements))
|
.route(
|
||||||
.route("/api/movements", post(handlers::operations_movements::create_movement))
|
"/api/movements",
|
||||||
|
get(handlers::operations_movements::list_movements)
|
||||||
|
.post(handlers::operations_movements::create_movement),
|
||||||
|
)
|
||||||
|
.route(
|
||||||
|
"/api/downtime",
|
||||||
|
get(handlers::downtime::list_downtime).post(handlers::downtime::create_downtime),
|
||||||
|
)
|
||||||
|
.route(
|
||||||
|
"/api/well-tests",
|
||||||
|
get(handlers::well_tests::list_well_tests).post(handlers::well_tests::create_well_test),
|
||||||
|
)
|
||||||
|
.route(
|
||||||
|
"/api/lab-results",
|
||||||
|
get(handlers::lab_results::list_lab_results)
|
||||||
|
.post(handlers::lab_results::create_lab_result),
|
||||||
|
)
|
||||||
|
.route(
|
||||||
|
"/api/meters/readings",
|
||||||
|
get(handlers::meters::list_meter_readings).post(handlers::meters::create_meter_reading),
|
||||||
|
)
|
||||||
// Edge System API
|
// Edge System API
|
||||||
.nest("/api/edge", edge_routes)
|
.nest("/api/edge", edge_routes)
|
||||||
|
// WebSocket Stream (Relay de Telemetría)
|
||||||
|
.route("/api/ws/telemetry", get(ws_telemetry_handler))
|
||||||
// Estado compartido y Middleware
|
// Estado compartido y Middleware
|
||||||
.layer(cors)
|
.layer(cors)
|
||||||
.with_state(state);
|
.with_state(state);
|
||||||
@@ -292,3 +428,88 @@ async fn main() {
|
|||||||
async fn root() -> &'static str {
|
async fn root() -> &'static str {
|
||||||
"OMNIOIL API - Rust Axum 🚀 (Auth & Dashboard Ready)"
|
"OMNIOIL API - Rust Axum 🚀 (Auth & Dashboard Ready)"
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[derive(Deserialize)]
|
||||||
|
struct WsParams {
|
||||||
|
project_id: Option<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn ws_telemetry_handler(
|
||||||
|
ws: WebSocketUpgrade,
|
||||||
|
query: Query<WsParams>,
|
||||||
|
State(state): State<AppState>,
|
||||||
|
) -> impl IntoResponse {
|
||||||
|
let project_id = query.project_id.clone();
|
||||||
|
tracing::debug!(
|
||||||
|
"New WebSocket upgrade request for telemetry. Filter: {:?}",
|
||||||
|
project_id
|
||||||
|
);
|
||||||
|
ws.on_upgrade(move |socket| handle_socket(socket, state, project_id))
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn handle_socket(mut socket: WebSocket, state: AppState, filter_project_id: Option<String>) {
|
||||||
|
tracing::info!(
|
||||||
|
"🔌 WebSocket connection upgraded and active for project: {:?}",
|
||||||
|
filter_project_id
|
||||||
|
);
|
||||||
|
let mut rx = state.tx.subscribe();
|
||||||
|
|
||||||
|
// Ping interval para detectar conexiones zombi
|
||||||
|
let mut ping_interval = tokio::time::interval(std::time::Duration::from_secs(15));
|
||||||
|
ping_interval.tick().await; // consume el tick inmediato
|
||||||
|
|
||||||
|
loop {
|
||||||
|
tokio::select! {
|
||||||
|
// Branch 1: Recibir evento del broadcast y enviarlo al cliente
|
||||||
|
result = rx.recv() => {
|
||||||
|
match result {
|
||||||
|
Ok(event) => {
|
||||||
|
if let Some(ref target_id) = filter_project_id {
|
||||||
|
// target_id debe normalizarse una vez fuera del loop para máxima velocidad
|
||||||
|
if &*event.project_id != target_id.to_lowercase() {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if socket.send(Message::Text(event.payload.to_string().into())).await.is_err() {
|
||||||
|
tracing::debug!("WebSocket client disconnected (send failed)");
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Err(tokio::sync::broadcast::error::RecvError::Lagged(count)) => {
|
||||||
|
tracing::warn!("WebSocket receiver lagged by {} messages", count);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
Err(tokio::sync::broadcast::error::RecvError::Closed) => {
|
||||||
|
tracing::error!("Broadcast channel closed");
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// Branch 2: Leer mensajes del cliente (detectar Close/Pong/desconexión)
|
||||||
|
msg = socket.recv() => {
|
||||||
|
match msg {
|
||||||
|
Some(Ok(Message::Close(_))) | None => {
|
||||||
|
tracing::debug!("WebSocket client closed connection");
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
Some(Ok(Message::Pong(_))) => {
|
||||||
|
// Cliente respondió al ping, la conexión está viva
|
||||||
|
}
|
||||||
|
Some(Err(_)) => {
|
||||||
|
tracing::debug!("WebSocket read error, dropping connection");
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
_ => {} // Ignorar otros mensajes (Text, Binary del cliente)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// Branch 3: Enviar ping periódico para detectar zombis
|
||||||
|
_ = ping_interval.tick() => {
|
||||||
|
if socket.send(Message::Ping(vec![1, 2, 3].into())).await.is_err() {
|
||||||
|
tracing::debug!("WebSocket ping failed, client is dead");
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
tracing::info!("🔌 WebSocket connection closed for project: {:?}", filter_project_id);
|
||||||
|
}
|
||||||
|
|||||||
84
services/backend-api/src/notifier.rs
Normal file
84
services/backend-api/src/notifier.rs
Normal file
@@ -0,0 +1,84 @@
|
|||||||
|
use async_trait::async_trait;
|
||||||
|
use sqlx::{PgPool, Row};
|
||||||
|
use uuid::Uuid;
|
||||||
|
|
||||||
|
#[async_trait]
|
||||||
|
pub trait NotificationProvider: Send + Sync {
|
||||||
|
async fn send(&self, destination: &str, subject: &str, body: &str) -> anyhow::Result<()>;
|
||||||
|
fn channel_type(&self) -> &str;
|
||||||
|
}
|
||||||
|
|
||||||
|
pub struct EmailProvider;
|
||||||
|
|
||||||
|
#[async_trait]
|
||||||
|
impl NotificationProvider for EmailProvider {
|
||||||
|
fn channel_type(&self) -> &str { "EMAIL" }
|
||||||
|
|
||||||
|
async fn send(&self, destination: &str, subject: &str, body: &str) -> anyhow::Result<()> {
|
||||||
|
tracing::info!("📧 Sending Email to {}: {} | Body: {}", destination, subject, body);
|
||||||
|
// Aquí iría la integración con SendGrid/SMTP
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub struct TelegramProvider;
|
||||||
|
|
||||||
|
#[async_trait]
|
||||||
|
impl NotificationProvider for TelegramProvider {
|
||||||
|
fn channel_type(&self) -> &str { "TELEGRAM" }
|
||||||
|
|
||||||
|
async fn send(&self, destination: &str, _subject: &str, body: &str) -> anyhow::Result<()> {
|
||||||
|
tracing::info!("✈️ Sending Telegram to {}: {}", destination, body);
|
||||||
|
// Aquí iría la integración con bot.send_message
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub struct Notifier {
|
||||||
|
providers: Vec<Box<dyn NotificationProvider>>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Notifier {
|
||||||
|
pub fn new() -> Self {
|
||||||
|
Self {
|
||||||
|
providers: vec![
|
||||||
|
Box::new(EmailProvider),
|
||||||
|
Box::new(TelegramProvider),
|
||||||
|
],
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn dispatch_alarm_notifications(&self, pool: &PgPool, project_id: Uuid, alarm_msg: &str) -> anyhow::Result<()> {
|
||||||
|
// 1. Obtener destinos configurados para este proyecto
|
||||||
|
let configs = sqlx::query(
|
||||||
|
"SELECT channel_type, destination FROM notification_configs WHERE project_id = $1 AND is_active = true"
|
||||||
|
)
|
||||||
|
.bind(project_id)
|
||||||
|
.fetch_all(pool)
|
||||||
|
.await?;
|
||||||
|
|
||||||
|
for config in configs {
|
||||||
|
let channel_type: String = config.get("channel_type");
|
||||||
|
let destination: String = config.get("destination");
|
||||||
|
|
||||||
|
if let Some(provider) = self.providers.iter().find(|p| p.channel_type() == channel_type) {
|
||||||
|
let res = provider.send(&destination, "OmniOil Alarm Alert", alarm_msg).await;
|
||||||
|
|
||||||
|
let status = if res.is_ok() { "SENT" } else { "FAILED" };
|
||||||
|
let error = res.err().map(|e| e.to_string());
|
||||||
|
|
||||||
|
let _ = sqlx::query(
|
||||||
|
"INSERT INTO notification_logs (project_id, channel_type, status, error_message) VALUES ($1, $2, $3, $4)"
|
||||||
|
)
|
||||||
|
.bind(project_id)
|
||||||
|
.bind(&channel_type)
|
||||||
|
.bind(status)
|
||||||
|
.bind(error)
|
||||||
|
.execute(pool)
|
||||||
|
.await;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
}
|
||||||
118
services/backend-api/src/watchdog.rs
Normal file
118
services/backend-api/src/watchdog.rs
Normal file
@@ -0,0 +1,118 @@
|
|||||||
|
use crate::notifier::Notifier;
|
||||||
|
use sqlx::{PgPool, Row};
|
||||||
|
use std::time::Duration;
|
||||||
|
use chrono::Utc;
|
||||||
|
use uuid::Uuid;
|
||||||
|
|
||||||
|
pub async fn run_watchdog(pool: PgPool) {
|
||||||
|
tracing::info!("🐕 Watchdog started: Monitoring telemetry silence & health...");
|
||||||
|
|
||||||
|
let notifier = Notifier::new();
|
||||||
|
let mut interval = tokio::time::interval(Duration::from_secs(60));
|
||||||
|
|
||||||
|
loop {
|
||||||
|
interval.tick().await;
|
||||||
|
|
||||||
|
if let Err(e) = check_telemetry_silence(&pool, ¬ifier).await {
|
||||||
|
tracing::error!("Watchdog error (silence check): {}", e);
|
||||||
|
}
|
||||||
|
|
||||||
|
if let Err(e) = check_unhealthy_agents(&pool, ¬ifier).await {
|
||||||
|
tracing::error!("Watchdog error (health check): {}", e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn check_telemetry_silence(pool: &PgPool, notifier: &Notifier) -> anyhow::Result<()> {
|
||||||
|
// Buscar proyectos activos que NO hayan tenido telemetría en los últimos 10 minutos
|
||||||
|
let rows = sqlx::query(
|
||||||
|
r#"
|
||||||
|
SELECT p.id, p.name
|
||||||
|
FROM edge_projects p
|
||||||
|
WHERE p.is_active = true
|
||||||
|
AND NOT EXISTS (
|
||||||
|
SELECT 1 FROM telemetry_raw t
|
||||||
|
WHERE t.project_id = p.id
|
||||||
|
AND t.time > NOW() - INTERVAL '10 minutes'
|
||||||
|
)
|
||||||
|
AND EXISTS (
|
||||||
|
SELECT 1 FROM edge_assets a WHERE a.project_id = p.id
|
||||||
|
)
|
||||||
|
"#
|
||||||
|
)
|
||||||
|
.fetch_all(pool)
|
||||||
|
.await?;
|
||||||
|
|
||||||
|
for row in rows {
|
||||||
|
let proj_id: Uuid = row.get("id");
|
||||||
|
let proj_name: String = row.get("name");
|
||||||
|
let msg = format!("ALERTA ANH: No se recibe telemetría del proyecto {} hace más de 10 min.", proj_name);
|
||||||
|
|
||||||
|
let already_alerted = sqlx::query(
|
||||||
|
r#"
|
||||||
|
SELECT 1 FROM telemetry_alarms
|
||||||
|
WHERE project_id = $1
|
||||||
|
AND alarm_type = 'TELEMETRY_SILENCE'
|
||||||
|
AND timestamp > NOW() - INTERVAL '1 hour'
|
||||||
|
LIMIT 1
|
||||||
|
"#
|
||||||
|
)
|
||||||
|
.bind(proj_id)
|
||||||
|
.fetch_optional(pool)
|
||||||
|
.await?;
|
||||||
|
|
||||||
|
if already_alerted.is_none() {
|
||||||
|
tracing::warn!("⚠️ Detectado silencio prolongado en project: {}", proj_name);
|
||||||
|
|
||||||
|
sqlx::query(
|
||||||
|
"INSERT INTO telemetry_alarms (project_id, device_name, alarm_type, message, timestamp)
|
||||||
|
VALUES ($1, 'SYSTEM_WATCHDOG', 'TELEMETRY_SILENCE', $2, $3)"
|
||||||
|
)
|
||||||
|
.bind(proj_id)
|
||||||
|
.bind(&msg)
|
||||||
|
.bind(Utc::now())
|
||||||
|
.execute(pool)
|
||||||
|
.await?;
|
||||||
|
|
||||||
|
// 📢 Notificar a los canales configurados
|
||||||
|
let _ = notifier.dispatch_alarm_notifications(pool, proj_id, &msg).await;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn check_unhealthy_agents(pool: &PgPool, notifier: &Notifier) -> anyhow::Result<()> {
|
||||||
|
// Proyectos con heartbeats perdidos (> 5 minutos)
|
||||||
|
let lost_agents = sqlx::query(
|
||||||
|
r#"
|
||||||
|
SELECT id, name
|
||||||
|
FROM edge_projects
|
||||||
|
WHERE is_active = true
|
||||||
|
AND (last_health_report->>'timestamp')::TIMESTAMPTZ < NOW() - INTERVAL '5 minutes'
|
||||||
|
"#
|
||||||
|
)
|
||||||
|
.fetch_all(pool)
|
||||||
|
.await?;
|
||||||
|
|
||||||
|
for row in lost_agents {
|
||||||
|
let agent_id: Uuid = row.get("id");
|
||||||
|
let agent_name: String = row.get("name");
|
||||||
|
let msg = format!("Falla de Comunicación: El agente del proyecto {} no responde.", agent_name);
|
||||||
|
|
||||||
|
sqlx::query(
|
||||||
|
"INSERT INTO telemetry_alarms (project_id, device_name, alarm_type, message, timestamp)
|
||||||
|
VALUES ($1, 'SYSTEM_WATCHDOG', 'AGENT_OFFLINE', $2, $3)"
|
||||||
|
)
|
||||||
|
.bind(agent_id)
|
||||||
|
.bind(&msg)
|
||||||
|
.bind(Utc::now())
|
||||||
|
.execute(pool)
|
||||||
|
.await?;
|
||||||
|
|
||||||
|
// 📢 Notificar a los canales configurados
|
||||||
|
let _ = notifier.dispatch_alarm_notifications(pool, agent_id, &msg).await;
|
||||||
|
}
|
||||||
|
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
@@ -4,19 +4,18 @@ version = "0.1.0"
|
|||||||
edition = "2024"
|
edition = "2024"
|
||||||
|
|
||||||
[dependencies]
|
[dependencies]
|
||||||
shared-lib = { path = "../shared-lib" }
|
shared-lib = { workspace = true }
|
||||||
flow-generator = { path = "../flow-generator" }
|
flow-generator = { path = "../flow-generator" }
|
||||||
tokio = { version = "1.50.0", features = ["full"] }
|
tokio = { workspace = true }
|
||||||
sqlx = { version = "0.8.6", features = ["runtime-tokio-rustls", "postgres", "uuid", "chrono", "json", "bigdecimal"] }
|
sqlx = { workspace = true }
|
||||||
serde = { version = "1.0.228", features = ["derive"] }
|
serde = { workspace = true }
|
||||||
serde_json = "1.0.149"
|
serde_json = { workspace = true }
|
||||||
uuid = { version = "1.23.0", features = ["v4"] }
|
uuid = { workspace = true }
|
||||||
chrono = { version = "0.4.44", features = ["serde"] }
|
chrono = { workspace = true }
|
||||||
bollard = "0.20.2"
|
rumqttc = { workspace = true }
|
||||||
rumqttc = "0.25.1"
|
tracing = { workspace = true }
|
||||||
tracing = "0.1.44"
|
tracing-subscriber = { workspace = true }
|
||||||
tracing-subscriber = { version = "0.3.23", features = ["env-filter"] }
|
dotenvy = { workspace = true }
|
||||||
dotenv = "0.15.0"
|
|
||||||
sha2 = "0.11.0"
|
sha2 = "0.11.0"
|
||||||
hex = "0.4.3"
|
hex = "0.4.3"
|
||||||
flate2 = "1.1.9"
|
flate2 = "1.1.9"
|
||||||
@@ -27,3 +26,4 @@ http-body-util = "0.1.2"
|
|||||||
aws-config = "1.5"
|
aws-config = "1.5"
|
||||||
aws-sdk-s3 = "1.50"
|
aws-sdk-s3 = "1.50"
|
||||||
bcrypt = "0.17"
|
bcrypt = "0.17"
|
||||||
|
reqwest = { workspace = true, features = ["json", "rustls", "stream"] }
|
||||||
|
|||||||
@@ -4,6 +4,7 @@
|
|||||||
//! de contenedores Node-RED industriales.
|
//! de contenedores Node-RED industriales.
|
||||||
|
|
||||||
pub mod orchestrator;
|
pub mod orchestrator;
|
||||||
|
pub mod registry_push;
|
||||||
pub mod templates;
|
pub mod templates;
|
||||||
pub mod utils;
|
pub mod utils;
|
||||||
|
|
||||||
|
|||||||
@@ -1,20 +1,13 @@
|
|||||||
//! Orquestación de comandos contra el Engine de Docker.
|
//! Orquestación de configuración SCADA (Nativo).
|
||||||
//!
|
//!
|
||||||
//! Este módulo maneja la comunicación de red con el daemon local o remoto
|
//! Este módulo ahora se encarga exclusivamente de la generación de
|
||||||
//! para la generación y distribución de imágenes.
|
//! archivos de configuración para agentes nativos.
|
||||||
|
|
||||||
use bollard::auth::DockerCredentials;
|
use flow_generator::flow_builder;
|
||||||
use bollard::query_parameters::{BuildImageOptionsBuilder, PushImageOptionsBuilder};
|
|
||||||
use bollard::{body_full, Docker};
|
|
||||||
use chrono::Utc;
|
|
||||||
use flow_generator::flow_builder::{self, MqttBrokerConfig};
|
|
||||||
use shared_lib::edge_models::ProjectConfig;
|
use shared_lib::edge_models::ProjectConfig;
|
||||||
use uuid::Uuid;
|
use uuid::Uuid;
|
||||||
|
use chrono::Utc;
|
||||||
use super::templates::*;
|
use super::templates::*;
|
||||||
use super::utils::*;
|
|
||||||
use bytes::Bytes;
|
|
||||||
use futures_util::StreamExt;
|
|
||||||
|
|
||||||
/// Resultado DTO de un build exitoso.
|
/// Resultado DTO de un build exitoso.
|
||||||
#[derive(Debug, Clone)]
|
#[derive(Debug, Clone)]
|
||||||
@@ -23,121 +16,28 @@ pub struct BuildResult {
|
|||||||
pub image_hash: String,
|
pub image_hash: String,
|
||||||
pub deployment_id: Uuid,
|
pub deployment_id: Uuid,
|
||||||
pub flows_json: String,
|
pub flows_json: String,
|
||||||
|
pub package_json: String,
|
||||||
|
pub settings_js: String,
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Orquestación completa de la imagen SCADA.
|
/// Genera solo los archivos de configuración (útil para agentes nativos/MSI)
|
||||||
///
|
pub fn generate_config_only(
|
||||||
/// Realiza:
|
|
||||||
/// 1. Generación de flows.json
|
|
||||||
/// 2. Docker build (via Bollard con contexto en memoria)
|
|
||||||
/// 3. Identificación de hash de versión.
|
|
||||||
pub async fn build_scada_image(
|
|
||||||
docker: &Docker,
|
|
||||||
config: &ProjectConfig,
|
config: &ProjectConfig,
|
||||||
registry_url: &str,
|
) -> Result<(String, String, String, String), Box<dyn std::error::Error + Send + Sync>> {
|
||||||
deployment_id: Uuid,
|
|
||||||
) -> Result<BuildResult, Box<dyn std::error::Error + Send + Sync>> {
|
|
||||||
let project_name = &config.project.name;
|
let project_name = &config.project.name;
|
||||||
let project_slug = sanitize_slug(project_name);
|
|
||||||
|
|
||||||
tracing::info!("📦 Construyendo imagen SCADA de proyecto: {}", project_name);
|
|
||||||
|
|
||||||
// 1. Generar flows.json dinámicos
|
// 1. Generar flows.json dinámicos
|
||||||
let mqtt_config = MqttBrokerConfig::default();
|
let flow_result = flow_builder::generate_flows(config)?;
|
||||||
let flow_result = flow_builder::generate_flows(config, &mqtt_config)?;
|
|
||||||
|
|
||||||
// 2. Generar plantillas Dockerfile/Settings/Package
|
// 2. Generar plantillas Settings/Package
|
||||||
let dockerfile = generate_dockerfile(&flow_result.npm_packages);
|
let dockerfile = generate_dockerfile(&flow_result.npm_packages);
|
||||||
let package_json = flow_builder::generate_package_json(&flow_result.npm_packages);
|
let package_json = flow_builder::generate_package_json(&flow_result.npm_packages);
|
||||||
let settings_js = generate_settings_js(project_name);
|
let settings_js = generate_settings_js(project_name);
|
||||||
|
|
||||||
// 3. Crear contexto de build (Tarball)
|
Ok((flow_result.flows_json, package_json, settings_js, dockerfile))
|
||||||
let build_context = create_build_context(
|
|
||||||
&dockerfile,
|
|
||||||
&flow_result.flows_json,
|
|
||||||
&package_json,
|
|
||||||
&settings_js,
|
|
||||||
)?;
|
|
||||||
|
|
||||||
// 4. Identificar Tag de la imagen
|
|
||||||
let timestamp = Utc::now().format("%Y%m%d%H%M%S");
|
|
||||||
let image_tag = format!("{}/scada-{}:{}", registry_url, project_slug, timestamp);
|
|
||||||
|
|
||||||
// 5. Ejecutar Docker Build
|
|
||||||
let build_options = BuildImageOptionsBuilder::new()
|
|
||||||
.t(&image_tag)
|
|
||||||
.rm(true)
|
|
||||||
.forcerm(true)
|
|
||||||
.build();
|
|
||||||
let mut build_stream = docker.build_image(
|
|
||||||
build_options,
|
|
||||||
None,
|
|
||||||
Some(body_full(Bytes::from(build_context))),
|
|
||||||
);
|
|
||||||
|
|
||||||
while let Some(result) = build_stream.next().await {
|
|
||||||
match result {
|
|
||||||
Ok(output) => {
|
|
||||||
if let Some(stream) = output.stream {
|
|
||||||
tracing::debug!("Docker: {}", stream.trim());
|
|
||||||
}
|
|
||||||
if let Some(error) = output.error_detail {
|
|
||||||
let msg = error.message.as_deref().unwrap_or("Error desconocido");
|
|
||||||
return Err(format!("Docker build fatal error: {}", msg).into());
|
|
||||||
}
|
|
||||||
}
|
|
||||||
Err(e) => return Err(format!("Build stream error: {}", e).into()),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
let image_hash = calculate_image_hash(&flow_result.flows_json, &dockerfile);
|
|
||||||
tracing::info!(
|
|
||||||
"✅ Imagen Generada con Éxito: {} ({})",
|
|
||||||
image_tag,
|
|
||||||
image_hash
|
|
||||||
);
|
|
||||||
|
|
||||||
Ok(BuildResult {
|
|
||||||
image_tag,
|
|
||||||
image_hash,
|
|
||||||
deployment_id,
|
|
||||||
flows_json: flow_result.flows_json,
|
|
||||||
})
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Sube la imagen generada al registry privado (DOCKER PUSH).
|
pub fn sanitize_slug(name: &str) -> String {
|
||||||
pub async fn push_image(
|
|
||||||
docker: &Docker,
|
|
||||||
image_tag: &str,
|
|
||||||
) -> Result<(), Box<dyn std::error::Error + Send + Sync>> {
|
|
||||||
tracing::info!("📤 Distribuyendo imagen al Registry: {}", image_tag);
|
|
||||||
|
|
||||||
let push_options = PushImageOptionsBuilder::new()
|
|
||||||
.tag(image_tag.split(':').last().unwrap_or("latest"))
|
|
||||||
.build();
|
|
||||||
let mut push_stream =
|
|
||||||
docker.push_image(image_tag, Some(push_options), None::<DockerCredentials>);
|
|
||||||
|
|
||||||
while let Some(result) = push_stream.next().await {
|
|
||||||
match result {
|
|
||||||
Ok(output) => {
|
|
||||||
if let Some(status) = &output.status {
|
|
||||||
tracing::debug!("Push Status: {}", status);
|
|
||||||
}
|
|
||||||
if let Some(error) = &output.error_detail {
|
|
||||||
let msg = error.message.as_deref().unwrap_or("Error desconocido");
|
|
||||||
return Err(format!("Docker push fatal error: {}", msg).into());
|
|
||||||
}
|
|
||||||
}
|
|
||||||
Err(e) => return Err(format!("Push stream isolation error: {}", e).into()),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
tracing::info!("✅ Imagen Pushed: {}", image_tag);
|
|
||||||
Ok(())
|
|
||||||
}
|
|
||||||
|
|
||||||
fn sanitize_slug(name: &str) -> String {
|
|
||||||
name.to_lowercase()
|
name.to_lowercase()
|
||||||
.replace(' ', "-")
|
.replace(' ', "-")
|
||||||
.chars()
|
.chars()
|
||||||
|
|||||||
8
services/build-orchestrator/src/builder/registry_push.rs
Normal file
8
services/build-orchestrator/src/builder/registry_push.rs
Normal file
@@ -0,0 +1,8 @@
|
|||||||
|
//! Registry Push (Deprecated - Docker removed)
|
||||||
|
//!
|
||||||
|
//! Este módulo ha sido desactivado ya que el sistema ahora usa despliegue nativo.
|
||||||
|
|
||||||
|
pub async fn push_image_via_http(_tag: &str) -> Result<(), Box<dyn std::error::Error + Send + Sync>> {
|
||||||
|
// No-op en modo nativo
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
@@ -7,39 +7,33 @@
|
|||||||
/// Genera el contenido del Dockerfile optimizado para Node-RED industrial.
|
/// Genera el contenido del Dockerfile optimizado para Node-RED industrial.
|
||||||
///
|
///
|
||||||
/// Implementa una estrategia de multi-stage build para reducir el tamaño final de la imagen.
|
/// Implementa una estrategia de multi-stage build para reducir el tamaño final de la imagen.
|
||||||
pub fn generate_dockerfile(npm_packages: &[String]) -> String {
|
pub fn generate_dockerfile(_npm_packages: &[String]) -> String {
|
||||||
let _package_list = npm_packages.join(" ");
|
|
||||||
|
|
||||||
format!(
|
format!(
|
||||||
r#"# =============================================================================
|
r#"# =============================================================================
|
||||||
# OmniOil SCADA - Auto-generated Dockerfile
|
# OmniOil SCADA - Auto-generated Dockerfile (Optimized Single-Stage)
|
||||||
# Runtime: Node-RED with industrial protocol support
|
|
||||||
# =============================================================================
|
# =============================================================================
|
||||||
|
|
||||||
# Stage 1: Install npm dependencies
|
|
||||||
FROM nodered/node-red:4.0.0 AS builder
|
|
||||||
|
|
||||||
USER root
|
|
||||||
WORKDIR /tmp
|
|
||||||
COPY package.json /tmp/package.json
|
|
||||||
RUN npm install --production --no-optional 2>&1
|
|
||||||
|
|
||||||
USER node-red
|
|
||||||
|
|
||||||
# Stage 2: Runtime minimal
|
|
||||||
FROM nodered/node-red:4.0.0
|
FROM nodered/node-red:4.0.0
|
||||||
|
|
||||||
# Copiar node_modules instalados (optimizando cache)
|
USER root
|
||||||
COPY --from=builder /tmp/node_modules /data/node_modules
|
|
||||||
|
|
||||||
# Copiar configuración de flujos generada
|
# 1. Instalar dependencias en el directorio de datos (/data)
|
||||||
|
WORKDIR /data
|
||||||
|
COPY package.json /data/package.json
|
||||||
|
RUN npm install --production --no-optional && \
|
||||||
|
npm cache clean --force && \
|
||||||
|
rm -rf /tmp/*
|
||||||
|
|
||||||
|
# 2. Copiar configuración de flujos y settings
|
||||||
COPY flows.json /data/flows.json
|
COPY flows.json /data/flows.json
|
||||||
COPY settings.js /data/settings.js
|
COPY settings.js /data/settings.js
|
||||||
|
|
||||||
# Desactivar el sistema de proyectos de Node-RED (usamos flows.json directo)
|
# 3. Configuración de ejecución
|
||||||
ENV NODE_RED_ENABLE_PROJECTS=false
|
ENV NODE_RED_ENABLE_PROJECTS=false
|
||||||
ENV NODE_RED_FLOW_FILE=/data/flows.json
|
ENV NODE_RED_FLOW_FILE=/data/flows.json
|
||||||
|
|
||||||
|
# 4. Volver al directorio de la aplicación para que el entrypoint funcione
|
||||||
|
WORKDIR /usr/src/node-red
|
||||||
|
USER node-red
|
||||||
EXPOSE 1880
|
EXPOSE 1880
|
||||||
|
|
||||||
HEALTHCHECK --interval=30s --timeout=10s --retries=3 \
|
HEALTHCHECK --interval=30s --timeout=10s --retries=3 \
|
||||||
@@ -59,6 +53,7 @@ pub fn generate_settings_js(project_name: &str) -> String {
|
|||||||
module.exports = {{
|
module.exports = {{
|
||||||
flowFile: "flows.json",
|
flowFile: "flows.json",
|
||||||
flowFilePretty: true,
|
flowFilePretty: true,
|
||||||
|
atomicSave: false, // CRÍTICO: Evita EBUSY en Windows/Docker al no usar rename
|
||||||
uiPort: process.env.PORT || 1880,
|
uiPort: process.env.PORT || 1880,
|
||||||
diagnostics: {{ enabled: true, ui: true }},
|
diagnostics: {{ enabled: true, ui: true }},
|
||||||
runtimeState: {{ enabled: false, ui: false }},
|
runtimeState: {{ enabled: false, ui: false }},
|
||||||
|
|||||||
@@ -1,41 +1,135 @@
|
|||||||
use aws_config::meta::region::RegionProviderChain;
|
use aws_config::meta::region::RegionProviderChain;
|
||||||
use aws_sdk_s3::{Client, config::Region};
|
use aws_sdk_s3::{Client, config::Region};
|
||||||
|
use chrono::Utc;
|
||||||
use serde_json::json;
|
use serde_json::json;
|
||||||
use shared_lib::edge_models::EdgeProject;
|
|
||||||
use shared_lib::mqtt_messages::ProjectCreatedEvent;
|
use shared_lib::mqtt_messages::ProjectCreatedEvent;
|
||||||
use shared_lib::overlay::generate_custom_binary;
|
use shared_lib::overlay::generate_custom_binary;
|
||||||
use sqlx::PgPool;
|
use sqlx::PgPool;
|
||||||
use uuid::Uuid;
|
use uuid::Uuid;
|
||||||
|
async fn update_deployment_status(
|
||||||
|
pool: &PgPool,
|
||||||
|
project_id: Uuid,
|
||||||
|
deployment_id: Option<Uuid>,
|
||||||
|
stage: &str,
|
||||||
|
status: &str,
|
||||||
|
payload: Option<serde_json::Value>,
|
||||||
|
) -> Result<(), sqlx::Error> {
|
||||||
|
if let Some(did) = deployment_id {
|
||||||
|
let log_entry = json!({
|
||||||
|
"stage": stage,
|
||||||
|
"status": status,
|
||||||
|
"timestamp": Utc::now(),
|
||||||
|
"details": payload.unwrap_or(json!({}))
|
||||||
|
});
|
||||||
|
|
||||||
|
// Apprend to build_metadata JSON list
|
||||||
|
sqlx::query(
|
||||||
|
r#"UPDATE edge_deployments
|
||||||
|
SET build_metadata = build_metadata || $1::jsonb,
|
||||||
|
status = CASE WHEN $2 = 'FAILED' THEN 'FAILED' ELSE status END,
|
||||||
|
error_message = CASE WHEN $2 = 'FAILED' THEN $3 ELSE error_message END
|
||||||
|
WHERE id = $4"#,
|
||||||
|
)
|
||||||
|
.bind(json!([log_entry]))
|
||||||
|
.bind(status)
|
||||||
|
.bind(if status == "FAILED" {
|
||||||
|
Some(stage.to_string())
|
||||||
|
} else {
|
||||||
|
None
|
||||||
|
})
|
||||||
|
.bind(did)
|
||||||
|
.execute(pool)
|
||||||
|
.await?;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Also update project level status for global visibility
|
||||||
|
let _ = sqlx::query("UPDATE edge_projects SET installer_status = $1 WHERE id = $2")
|
||||||
|
.bind(if status == "FAILED" {
|
||||||
|
"FAILED"
|
||||||
|
} else if status == "READY" {
|
||||||
|
"READY"
|
||||||
|
} else {
|
||||||
|
"BUILDING"
|
||||||
|
})
|
||||||
|
.bind(project_id)
|
||||||
|
.execute(pool)
|
||||||
|
.await;
|
||||||
|
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
pub async fn handle_project_created(
|
pub async fn handle_project_created(
|
||||||
pool: PgPool,
|
pool: PgPool,
|
||||||
|
mqtt_client: rumqttc::AsyncClient,
|
||||||
event: ProjectCreatedEvent,
|
event: ProjectCreatedEvent,
|
||||||
) -> Result<(), Box<dyn std::error::Error + Send + Sync>> {
|
) -> Result<(), Box<dyn std::error::Error + Send + Sync>> {
|
||||||
let project_id = event.project_id;
|
let project_id = event.project_id;
|
||||||
|
let deployment_id = event.deployment_id;
|
||||||
|
|
||||||
tracing::info!(
|
tracing::info!(
|
||||||
"🚀 Recibido evento de ProjectCreated. Iniciando worker MSI para {}",
|
"🚀 Recibido evento de ProjectCreated (Deploy: {:?}). Iniciando worker MSI para {}",
|
||||||
|
deployment_id,
|
||||||
project_id
|
project_id
|
||||||
);
|
);
|
||||||
|
|
||||||
// 1. Actualizar DB: Marcamos el estado en BUILDING
|
// 1. Marcar inicio
|
||||||
let _ = sqlx::query("UPDATE edge_projects SET installer_status = 'BUILDING' WHERE id = $1")
|
let _ = update_deployment_status(
|
||||||
|
&pool,
|
||||||
|
project_id,
|
||||||
|
deployment_id,
|
||||||
|
"INITIALIZING",
|
||||||
|
"PROCESSING",
|
||||||
|
Some(json!({"message": "Iniciando worker MSI", "client": event.client})),
|
||||||
|
)
|
||||||
|
.await;
|
||||||
|
|
||||||
|
tracing::info!("🔔 MSI Worker: Notifying UI about INITIALIZING state via MQTT");
|
||||||
|
let _ = crate::publisher::publish_build_status(&mqtt_client, project_id, "INITIALIZING", "Iniciando generación de instalador...").await;
|
||||||
|
|
||||||
|
// 1b. Obtener o generar Credenciales MQTT y Secretos para este Agente
|
||||||
|
let (mqtt_user, mqtt_password, master_secret) = {
|
||||||
|
let existing = sqlx::query_as::<_, (Option<String>, Option<String>)>(
|
||||||
|
"SELECT mqtt_password, agent_master_secret FROM edge_projects WHERE id = $1",
|
||||||
|
)
|
||||||
|
.bind(project_id)
|
||||||
|
.fetch_one(&pool)
|
||||||
|
.await?;
|
||||||
|
|
||||||
|
match existing {
|
||||||
|
(Some(pass), Some(secret)) => {
|
||||||
|
tracing::info!(
|
||||||
|
"♻️ Reutilizando credenciales y secreto maestro existentes para proyecto: {}",
|
||||||
|
project_id
|
||||||
|
);
|
||||||
|
(project_id.to_string(), pass, secret)
|
||||||
|
}
|
||||||
|
_ => {
|
||||||
|
let new_pass = Uuid::new_v4().to_string();
|
||||||
|
let new_secret = Uuid::new_v4().to_string();
|
||||||
|
tracing::info!(
|
||||||
|
"🔑 Generando nuevas credenciales y secreto maestro para proyecto: {}",
|
||||||
|
project_id
|
||||||
|
);
|
||||||
|
|
||||||
|
// Persistir en DB para futuros rebuilds
|
||||||
|
sqlx::query("UPDATE edge_projects SET mqtt_password = $1, agent_master_secret = $2 WHERE id = $3")
|
||||||
|
.bind(&new_pass)
|
||||||
|
.bind(&new_secret)
|
||||||
.bind(project_id)
|
.bind(project_id)
|
||||||
.execute(&pool)
|
.execute(&pool)
|
||||||
.await?;
|
.await?;
|
||||||
|
|
||||||
// 1b. Generar Credenciales MQTT para este Agente (Project Isolation)
|
(project_id.to_string(), new_pass, new_secret)
|
||||||
let mqtt_user = project_id.to_string();
|
}
|
||||||
let mqtt_password = Uuid::new_v4().to_string(); // Contraseña aleatoria única para este instalador
|
}
|
||||||
|
};
|
||||||
|
|
||||||
// 1c. Registrar en Infraestructura (Postgres con Auto-Hashing)
|
// 1c. Sincronizar Identidad MQTT en DB (Postgres con Auto-Hashing para Mosquitto)
|
||||||
tracing::info!("🔑 Registrando identidad MQTT dinámica en DB para el proyecto: {}", project_id);
|
|
||||||
|
|
||||||
// Insertamos o actualizamos el usuario con hasheo Bcrypt (bf) nativo
|
|
||||||
sqlx::query(
|
sqlx::query(
|
||||||
"INSERT INTO mqtt_users (username, password_hash)
|
"INSERT INTO mqtt_users (username, password_hash)
|
||||||
VALUES ($1, crypt($2, gen_salt('bf', 10)))
|
VALUES ($1, crypt($2, gen_salt('bf', 10)))
|
||||||
ON CONFLICT (username)
|
ON CONFLICT (username)
|
||||||
DO UPDATE SET password_hash = EXCLUDED.password_hash"
|
DO UPDATE SET password_hash = EXCLUDED.password_hash",
|
||||||
)
|
)
|
||||||
.bind(&mqtt_user)
|
.bind(&mqtt_user)
|
||||||
.bind(&mqtt_password)
|
.bind(&mqtt_password)
|
||||||
@@ -43,7 +137,6 @@ pub async fn handle_project_created(
|
|||||||
.await?;
|
.await?;
|
||||||
|
|
||||||
// Asignamos los permisos (ACL) dinámicos:
|
// Asignamos los permisos (ACL) dinámicos:
|
||||||
// Todo bajo el prefijo omnioil/{id}/# para simplificar, o específicos:
|
|
||||||
let topics = vec![
|
let topics = vec![
|
||||||
format!("omnioil/health/{}", project_id),
|
format!("omnioil/health/{}", project_id),
|
||||||
format!("omnioil/logs/{}", project_id),
|
format!("omnioil/logs/{}", project_id),
|
||||||
@@ -54,7 +147,7 @@ pub async fn handle_project_created(
|
|||||||
|
|
||||||
for t in topics {
|
for t in topics {
|
||||||
sqlx::query(
|
sqlx::query(
|
||||||
"INSERT INTO mqtt_acls (username, topic, rw) VALUES ($1, $2, 3) ON CONFLICT (username, topic) DO UPDATE SET rw = 3"
|
"INSERT INTO mqtt_acls (username, topic, rw) VALUES ($1, $2, 7) ON CONFLICT (username, topic) DO UPDATE SET rw = 7"
|
||||||
)
|
)
|
||||||
.bind(&mqtt_user)
|
.bind(&mqtt_user)
|
||||||
.bind(&t)
|
.bind(&t)
|
||||||
@@ -67,12 +160,16 @@ pub async fn handle_project_created(
|
|||||||
"agent": {
|
"agent": {
|
||||||
"project_id": project_id,
|
"project_id": project_id,
|
||||||
"hostname": event.name.to_lowercase().replace(' ', "-"),
|
"hostname": event.name.to_lowercase().replace(' ', "-"),
|
||||||
|
"master_secret": master_secret,
|
||||||
"health_check_interval_secs": 30
|
"health_check_interval_secs": 30
|
||||||
},
|
},
|
||||||
"mqtt": {
|
"mqtt": {
|
||||||
"host": std::env::var("MQTT_PUBLIC_HOST").unwrap_or_else(|_| "localhost".to_string()),
|
"host": std::env::var("MQTT_PUBLIC_HOST").expect("MQTT_PUBLIC_HOST must be set"),
|
||||||
"port": std::env::var("MQTT_PORT").unwrap_or_else(|_| "1883".to_string()).parse::<u16>().unwrap_or(1883),
|
"port": std::env::var("MQTT_PUBLIC_PORT")
|
||||||
"use_ws": std::env::var("MQTT_USE_WS").unwrap_or_else(|_| "false".to_string()).parse::<bool>().unwrap_or(false),
|
.expect("MQTT_PUBLIC_PORT must be set")
|
||||||
|
.parse::<u16>()
|
||||||
|
.expect("MQTT_PUBLIC_PORT must be a valid number"),
|
||||||
|
"use_ws": std::env::var("MQTT_USE_WS").expect("MQTT_USE_WS must be set").parse::<bool>().expect("MQTT_USE_WS must be true or false"),
|
||||||
"username": mqtt_user,
|
"username": mqtt_user,
|
||||||
"password": mqtt_password
|
"password": mqtt_password
|
||||||
},
|
},
|
||||||
@@ -82,15 +179,39 @@ pub async fn handle_project_created(
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
// ─── Proceso de Inyección (Overlay) ───────
|
// 🔍 SEGUIMIENTO DE INYECCIÓN DE DATOS
|
||||||
let template_path = std::path::Path::new("target/x86_64-pc-windows-gnu/release/edge-agent.exe");
|
/*
|
||||||
|
tracing::info!("================================================================");
|
||||||
|
tracing::info!("💉 INYECTANDO CONFIGURACIÓN EN EL AGENTE (MSI BUILD)");
|
||||||
|
tracing::info!("Variables de Entorno Detectadas:");
|
||||||
|
tracing::info!(" - MQTT_PUBLIC_HOST: {:?}", std::env::var("MQTT_PUBLIC_HOST").ok());
|
||||||
|
tracing::info!(" - MQTT_PUBLIC_PORT: {:?}", std::env::var("MQTT_PUBLIC_PORT").ok());
|
||||||
|
tracing::info!(" - MQTT_USE_WS: {:?}", std::env::var("MQTT_USE_WS").ok());
|
||||||
|
tracing::info!(" - INSTALLER_INCOMING: {:?}", std::env::var("INSTALLER_INCOMING").ok());
|
||||||
|
tracing::info!(" - INSTALLER_OUTPUT: {:?}", std::env::var("INSTALLER_OUTPUT").ok());
|
||||||
|
tracing::info!("----------------------------------------------------------------");
|
||||||
|
tracing::info!("JSON de Configuración Inyectado:");
|
||||||
|
if let Ok(pretty_json) = serde_json::to_string_pretty(&agent_config) {
|
||||||
|
for line in pretty_json.lines() {
|
||||||
|
tracing::info!(" {}", line);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
tracing::info!("================================================================");
|
||||||
|
*/
|
||||||
|
// 2. Extraer dinámicamente la versión del edge-agent desde su Cargo.toml
|
||||||
|
let edge_agent_toml_path = std::env::var("EDGE_AGENT_CARGO_TOML")
|
||||||
|
.unwrap_or_else(|_| "services/edge-agent/Cargo.toml".to_string());
|
||||||
|
|
||||||
// Extraer dinámicamente la versión del edge-agent desde su Cargo.toml
|
let edge_agent_toml = std::fs::read_to_string(&edge_agent_toml_path).unwrap_or_default();
|
||||||
let edge_agent_toml = std::fs::read_to_string("services/edge-agent/Cargo.toml").unwrap_or_default();
|
|
||||||
let mut version = "0.0.0".to_string();
|
let mut version = "0.0.0".to_string();
|
||||||
for line in edge_agent_toml.lines() {
|
for line in edge_agent_toml.lines() {
|
||||||
if line.starts_with("version =") {
|
if line.starts_with("version =") {
|
||||||
version = line.replace("version =", "").replace("\"", "").trim().to_string();
|
version = line
|
||||||
|
.replace("version =", "")
|
||||||
|
.replace("\"", "")
|
||||||
|
.replace("'", "")
|
||||||
|
.trim()
|
||||||
|
.to_string();
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -108,32 +229,108 @@ pub async fn handle_project_created(
|
|||||||
&project_id.to_string()[..8]
|
&project_id.to_string()[..8]
|
||||||
);
|
);
|
||||||
|
|
||||||
let incoming_dir = std::path::Path::new("services/edge-agent/incoming");
|
let incoming_dir_str = std::env::var("INSTALLER_INCOMING")
|
||||||
let output_dir = std::path::Path::new("services/edge-agent/output");
|
.unwrap_or_else(|_| "services/edge-agent/incoming".to_string());
|
||||||
|
let output_dir_str = std::env::var("INSTALLER_OUTPUT")
|
||||||
|
.unwrap_or_else(|_| "services/edge-agent/output".to_string());
|
||||||
|
|
||||||
|
let incoming_dir = std::path::Path::new(&incoming_dir_str);
|
||||||
|
let output_dir = std::path::Path::new(&output_dir_str);
|
||||||
|
|
||||||
|
// Asegurar que los directorios existen
|
||||||
|
std::fs::create_dir_all(incoming_dir)?;
|
||||||
|
std::fs::create_dir_all(output_dir)?;
|
||||||
|
|
||||||
let exe_path = incoming_dir.join(&exe_name);
|
let exe_path = incoming_dir.join(&exe_name);
|
||||||
let msi_path = output_dir.join(&msi_name);
|
let msi_path = output_dir.join(&msi_name);
|
||||||
|
|
||||||
if let Err(e) = generate_custom_binary(template_path, &exe_path, &agent_config) {
|
// 🔥 LIMPIEZA PREVIA: Borrar el MSI y el EXE viejo si existen
|
||||||
|
if msi_path.exists() {
|
||||||
|
let _ = std::fs::remove_file(&msi_path);
|
||||||
|
tracing::info!("♻️ Borrando MSI antiguo: {:?}", msi_name);
|
||||||
|
}
|
||||||
|
if exe_path.exists() {
|
||||||
|
let _ = std::fs::remove_file(&exe_path);
|
||||||
|
tracing::info!("♻️ Borrando EXE personalizado antiguo: {:?}", exe_name);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 🛠️ COMPILACIÓN AUTOMÁTICA DEL AGENTE (FORZAR REFRESH)
|
||||||
|
tracing::info!("🛠️ Compilando binario base del agente (cargo build)...");
|
||||||
|
let build_status = std::process::Command::new("cargo")
|
||||||
|
.args(["build", "-p", "edge-agent", "--release"])
|
||||||
|
.status();
|
||||||
|
|
||||||
|
match build_status {
|
||||||
|
Ok(s) if s.success() => tracing::info!("✅ Compilación del agente completada con éxito."),
|
||||||
|
_ => {
|
||||||
|
tracing::warn!("⚠️ Falló cargo build o no se pudo ejecutar. Intentando usar binario existente...");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Intentar encontrar el binario recién compilado (ahora preferimos release)
|
||||||
|
let template_paths = [
|
||||||
|
"target/release/edge-agent.exe",
|
||||||
|
"target/x86_64-pc-windows-gnu/release/edge-agent.exe",
|
||||||
|
"target/debug/edge-agent.exe",
|
||||||
|
];
|
||||||
|
|
||||||
|
let template_path_found = template_paths.iter()
|
||||||
|
.map(std::path::Path::new)
|
||||||
|
.find(|p| p.exists() && p.is_file())
|
||||||
|
.ok_or_else(|| {
|
||||||
|
let err = "❌ Error fatal: No se encontró el binario base tras intentar compilarlo.";
|
||||||
|
tracing::error!("{}", err);
|
||||||
|
err
|
||||||
|
})?;
|
||||||
|
|
||||||
|
tracing::info!("📂 Usando plantilla de agente fresca desde: {:?}", template_path_found);
|
||||||
|
|
||||||
|
if let Err(e) = generate_custom_binary(template_path_found, &exe_path, &agent_config) {
|
||||||
tracing::error!("❌ Error FATAL en inyección nativa: {}", e);
|
tracing::error!("❌ Error FATAL en inyección nativa: {}", e);
|
||||||
let _ = sqlx::query("UPDATE edge_projects SET installer_status = 'FAILED' WHERE id = $1")
|
let _ = update_deployment_status(
|
||||||
.bind(project_id)
|
&pool,
|
||||||
.execute(&pool)
|
project_id,
|
||||||
|
deployment_id,
|
||||||
|
"OVERLAY_INJECTION",
|
||||||
|
"FAILED",
|
||||||
|
Some(json!({"error": e.to_string()})),
|
||||||
|
)
|
||||||
.await;
|
.await;
|
||||||
return Err(e.into());
|
return Err(e.into());
|
||||||
}
|
}
|
||||||
|
|
||||||
tracing::info!("✅ Overlay COMPLETADO. Esperando que el worker de WiX genere el MSI...");
|
let _ = update_deployment_status(
|
||||||
|
&pool,
|
||||||
|
project_id,
|
||||||
|
deployment_id,
|
||||||
|
"OVERLAY_COMPLETED",
|
||||||
|
"PROCESSING",
|
||||||
|
Some(json!({"exe_name": exe_name})),
|
||||||
|
)
|
||||||
|
.await;
|
||||||
|
|
||||||
|
tracing::info!("✅ Overlay COMPLETADO en {:?}. Esperando MSI...", exe_path);
|
||||||
|
tracing::info!("🔔 MSI Worker: Starting WIX_GENERATION phase");
|
||||||
|
let _ = crate::publisher::publish_build_status(&mqtt_client, project_id, "WIX_GENERATION", "Generando paquete MSI (WiX)...").await;
|
||||||
|
|
||||||
// Esperar a que el worker MSI procese el archivo (máx 60 segundos)
|
// Esperar a que el worker MSI procese el archivo (máx 60 segundos)
|
||||||
let mut retries = 0;
|
let mut retries = 0;
|
||||||
|
let wait_timeout_secs = std::env::var("MSI_BUILD_TIMEOUT_SECS")
|
||||||
|
.unwrap_or_else(|_| "60".to_string())
|
||||||
|
.parse::<u64>()
|
||||||
|
.unwrap_or(60);
|
||||||
|
|
||||||
while !msi_path.exists() {
|
while !msi_path.exists() {
|
||||||
if retries > 30 {
|
if retries > (wait_timeout_secs / 2) {
|
||||||
tracing::error!("❌ Timeout esperando el MSI desde msi_worker.");
|
tracing::error!("❌ Timeout esperando el MSI en {:?}", msi_path);
|
||||||
let _ =
|
let _ = update_deployment_status(
|
||||||
sqlx::query("UPDATE edge_projects SET installer_status = 'FAILED' WHERE id = $1")
|
&pool,
|
||||||
.bind(project_id)
|
project_id,
|
||||||
.execute(&pool)
|
deployment_id,
|
||||||
|
"WIX_GENERATION",
|
||||||
|
"FAILED",
|
||||||
|
Some(json!({"error": format!("Timeout esperando MSI en {:?}", msi_path)})),
|
||||||
|
)
|
||||||
.await;
|
.await;
|
||||||
return Err("Timeout esperando MSI".into());
|
return Err("Timeout esperando MSI".into());
|
||||||
}
|
}
|
||||||
@@ -141,16 +338,18 @@ pub async fn handle_project_created(
|
|||||||
retries += 1;
|
retries += 1;
|
||||||
}
|
}
|
||||||
|
|
||||||
tracing::info!("✅ MSI detectado en el output. Iniciando subida a S3/MinIO.");
|
tracing::info!(
|
||||||
|
"✅ MSI detectado en {:?}. Iniciando subida a S3/MinIO.",
|
||||||
|
msi_path
|
||||||
|
);
|
||||||
|
tracing::info!("🔔 MSI Worker: Starting UPLOADING phase");
|
||||||
|
let _ = crate::publisher::publish_build_status(&mqtt_client, project_id, "UPLOADING", "Subiendo instalador a la nube...").await;
|
||||||
|
|
||||||
// ─── Subida a MinIO ───────
|
// ─── Subida a MinIO ───────
|
||||||
let region_provider = RegionProviderChain::default_provider().or_else(Region::new("us-east-1"));
|
let region_provider = RegionProviderChain::default_provider().or_else(Region::new("us-east-1"));
|
||||||
// En Docker Compose normalmente se usa http://minio:9000 internamente
|
|
||||||
let endpoint_url =
|
let endpoint_url =
|
||||||
std::env::var("MINIO_ENDPOINT_URL").unwrap_or_else(|_| "http://minio:9000".to_string());
|
std::env::var("MINIO_ENDPOINT_URL").expect("MINIO_ENDPOINT_URL must be set");
|
||||||
|
|
||||||
// Cargo la config basándome en AWS_ACCESS_KEY_ID local.
|
|
||||||
// Asumimos que entorno tiene AWS_ACCESS_KEY_ID = admin_s3, AWS_SECRET_ACCESS_KEY = otra_clave_segura_minio
|
|
||||||
let config = aws_config::from_env()
|
let config = aws_config::from_env()
|
||||||
.region(region_provider)
|
.region(region_provider)
|
||||||
.endpoint_url(&endpoint_url)
|
.endpoint_url(&endpoint_url)
|
||||||
@@ -158,22 +357,21 @@ pub async fn handle_project_created(
|
|||||||
.await;
|
.await;
|
||||||
|
|
||||||
let s3_config = aws_sdk_s3::config::Builder::from(&config)
|
let s3_config = aws_sdk_s3::config::Builder::from(&config)
|
||||||
.force_path_style(true) // Forzar path style para Minio
|
.force_path_style(true)
|
||||||
.build();
|
.build();
|
||||||
|
|
||||||
let client = Client::from_conf(s3_config);
|
let client = Client::from_conf(s3_config);
|
||||||
|
let bucket_name =
|
||||||
// Asumimos que el bucket se llama 'installers'
|
std::env::var("S3_INSTALLERS_BUCKET").expect("S3_INSTALLERS_BUCKET must be set");
|
||||||
let bucket_name = "installers";
|
|
||||||
|
|
||||||
// Intentamos crear el bucket por si no existe
|
// Intentamos crear el bucket por si no existe
|
||||||
let _ = client.create_bucket().bucket(bucket_name).send().await;
|
let _ = client.create_bucket().bucket(&bucket_name).send().await;
|
||||||
|
|
||||||
let body = aws_sdk_s3::primitives::ByteStream::from_path(&msi_path).await?;
|
let body = aws_sdk_s3::primitives::ByteStream::from_path(&msi_path).await?;
|
||||||
|
|
||||||
match client
|
match client
|
||||||
.put_object()
|
.put_object()
|
||||||
.bucket(bucket_name)
|
.bucket(&bucket_name)
|
||||||
.key(&msi_name)
|
.key(&msi_name)
|
||||||
.body(body)
|
.body(body)
|
||||||
.send()
|
.send()
|
||||||
@@ -182,12 +380,23 @@ pub async fn handle_project_created(
|
|||||||
Ok(_) => {
|
Ok(_) => {
|
||||||
tracing::info!("✅ Instalador subido a Minio correctamente.");
|
tracing::info!("✅ Instalador subido a Minio correctamente.");
|
||||||
|
|
||||||
// Link externo para el frontend (generalmente localhost para pruebas locales, cambiar en prod)
|
|
||||||
let external_url = std::env::var("MINIO_PUBLIC_URL")
|
let external_url = std::env::var("MINIO_PUBLIC_URL")
|
||||||
.unwrap_or_else(|_| "http://localhost:9000".to_string());
|
.unwrap_or_else(|_| "http://localhost:9000".to_string());
|
||||||
let download_url = format!("{}/{}/{}", external_url, bucket_name, msi_name);
|
let download_url = format!("{}/{}/{}", external_url, bucket_name, msi_name);
|
||||||
|
|
||||||
// 3. Actualizamos DB a READY con la URL
|
let _ = update_deployment_status(
|
||||||
|
&pool,
|
||||||
|
project_id,
|
||||||
|
deployment_id,
|
||||||
|
"READY",
|
||||||
|
"READY",
|
||||||
|
Some(json!({"url": download_url})),
|
||||||
|
)
|
||||||
|
.await;
|
||||||
|
|
||||||
|
tracing::info!("🔔 MSI Worker: FINALIZING - Setting status to READY");
|
||||||
|
let _ = crate::publisher::publish_build_status(&mqtt_client, project_id, "READY", "Instalador listo para descargar").await;
|
||||||
|
|
||||||
let _ = sqlx::query(
|
let _ = sqlx::query(
|
||||||
"UPDATE edge_projects SET installer_status = 'READY', installer_url = $1 WHERE id = $2"
|
"UPDATE edge_projects SET installer_status = 'READY', installer_url = $1 WHERE id = $2"
|
||||||
)
|
)
|
||||||
@@ -198,10 +407,14 @@ pub async fn handle_project_created(
|
|||||||
}
|
}
|
||||||
Err(e) => {
|
Err(e) => {
|
||||||
tracing::error!("❌ Fallo la subida a Minio: {:?}", e);
|
tracing::error!("❌ Fallo la subida a Minio: {:?}", e);
|
||||||
let _ =
|
let _ = update_deployment_status(
|
||||||
sqlx::query("UPDATE edge_projects SET installer_status = 'FAILED' WHERE id = $1")
|
&pool,
|
||||||
.bind(project_id)
|
project_id,
|
||||||
.execute(&pool)
|
deployment_id,
|
||||||
|
"S3_UPLOAD",
|
||||||
|
"FAILED",
|
||||||
|
Some(json!({"error": format!("{:?}", e)})),
|
||||||
|
)
|
||||||
.await;
|
.await;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,7 +2,9 @@ mod builder;
|
|||||||
mod publisher;
|
mod publisher;
|
||||||
mod installer_worker;
|
mod installer_worker;
|
||||||
|
|
||||||
use dotenv::dotenv;
|
use uuid::Uuid;
|
||||||
|
|
||||||
|
use dotenvy::dotenv;
|
||||||
use sqlx::postgres::PgPoolOptions;
|
use sqlx::postgres::PgPoolOptions;
|
||||||
use std::env;
|
use std::env;
|
||||||
use tracing_subscriber::{layer::SubscriberExt, util::SubscriberInitExt};
|
use tracing_subscriber::{layer::SubscriberExt, util::SubscriberInitExt};
|
||||||
@@ -11,9 +13,7 @@ use tracing_subscriber::{layer::SubscriberExt, util::SubscriberInitExt};
|
|||||||
#[derive(Clone)]
|
#[derive(Clone)]
|
||||||
pub struct OrchestratorState {
|
pub struct OrchestratorState {
|
||||||
pub db_pool: sqlx::PgPool,
|
pub db_pool: sqlx::PgPool,
|
||||||
pub docker: bollard::Docker,
|
|
||||||
pub mqtt_client: rumqttc::AsyncClient,
|
pub mqtt_client: rumqttc::AsyncClient,
|
||||||
pub registry_url: String,
|
|
||||||
}
|
}
|
||||||
|
|
||||||
#[tokio::main]
|
#[tokio::main]
|
||||||
@@ -28,7 +28,7 @@ async fn main() -> Result<(), Box<dyn std::error::Error>> {
|
|||||||
.with(tracing_subscriber::fmt::layer())
|
.with(tracing_subscriber::fmt::layer())
|
||||||
.init();
|
.init();
|
||||||
|
|
||||||
tracing::info!("🏗️ Build Orchestrator starting...");
|
tracing::info!("🏗️ Build Orchestrator starting (NATIVE MODE)...");
|
||||||
|
|
||||||
// Conectar a la base de datos
|
// Conectar a la base de datos
|
||||||
let database_url = env::var("DATABASE_URL").expect("DATABASE_URL must be set");
|
let database_url = env::var("DATABASE_URL").expect("DATABASE_URL must be set");
|
||||||
@@ -38,21 +38,16 @@ async fn main() -> Result<(), Box<dyn std::error::Error>> {
|
|||||||
.await?;
|
.await?;
|
||||||
tracing::info!("Connected to database");
|
tracing::info!("Connected to database");
|
||||||
|
|
||||||
// Conectar al Docker daemon
|
|
||||||
let docker = bollard::Docker::connect_with_socket_defaults()
|
|
||||||
.expect("Failed to connect to Docker daemon");
|
|
||||||
let version = docker.version().await?;
|
|
||||||
tracing::info!("Connected to Docker: {:?}", version.version);
|
|
||||||
|
|
||||||
// Conectar al MQTT broker
|
// Conectar al MQTT broker
|
||||||
let mqtt_host = env::var("MQTT_HOST").unwrap_or_else(|_| "localhost".to_string());
|
let mqtt_host = env::var("MQTT_HOST").expect("MQTT_HOST must be set");
|
||||||
let mqtt_port: u16 = env::var("MQTT_PORT")
|
let mqtt_port: u16 = env::var("MQTT_PORT")
|
||||||
.unwrap_or_else(|_| "1883".to_string())
|
.expect("MQTT_PORT must be set")
|
||||||
.parse()
|
.parse()
|
||||||
.unwrap_or(1883);
|
.expect("MQTT_PORT must be a valid number");
|
||||||
|
|
||||||
|
let client_id = format!("omnioil-build-orchestrator-{}", uuid::Uuid::new_v4());
|
||||||
let mut mqtt_options =
|
let mut mqtt_options =
|
||||||
rumqttc::MqttOptions::new("omnioil-build-orchestrator", &mqtt_host, mqtt_port);
|
rumqttc::MqttOptions::new(client_id, &mqtt_host, mqtt_port);
|
||||||
mqtt_options.set_keep_alive(std::time::Duration::from_secs(30));
|
mqtt_options.set_keep_alive(std::time::Duration::from_secs(30));
|
||||||
|
|
||||||
if let (Ok(u), Ok(p)) = (env::var("MQTT_USER"), env::var("MQTT_PASSWORD")) {
|
if let (Ok(u), Ok(p)) = (env::var("MQTT_USER"), env::var("MQTT_PASSWORD")) {
|
||||||
@@ -61,30 +56,54 @@ async fn main() -> Result<(), Box<dyn std::error::Error>> {
|
|||||||
|
|
||||||
let (mqtt_client, mut eventloop) = rumqttc::AsyncClient::new(mqtt_options, 10);
|
let (mqtt_client, mut eventloop) = rumqttc::AsyncClient::new(mqtt_options, 10);
|
||||||
|
|
||||||
// Subscribe to Domain Events
|
// Subscribe to Domain Events and Deployment Requests
|
||||||
mqtt_client
|
mqtt_client
|
||||||
.subscribe("omnioil/events/project_created", rumqttc::QoS::AtLeastOnce)
|
.subscribe("omnioil/events/project_created", rumqttc::QoS::AtLeastOnce)
|
||||||
.await?;
|
.await?;
|
||||||
|
mqtt_client
|
||||||
|
.subscribe("omnioil/control/deploy/#", rumqttc::QoS::AtLeastOnce)
|
||||||
|
.await?;
|
||||||
|
|
||||||
|
// Spawn MQTT event loop
|
||||||
|
let state_worker = OrchestratorState {
|
||||||
|
db_pool: pool.clone(),
|
||||||
|
mqtt_client: mqtt_client.clone(),
|
||||||
|
};
|
||||||
|
|
||||||
// Spawn MQTT event loop (Handles Domain Events AND agent communication)
|
|
||||||
let db_pool = pool.clone();
|
|
||||||
tokio::spawn(async move {
|
tokio::spawn(async move {
|
||||||
loop {
|
loop {
|
||||||
match eventloop.poll().await {
|
match eventloop.poll().await {
|
||||||
Ok(item) => {
|
Ok(item) => {
|
||||||
if let rumqttc::Event::Incoming(rumqttc::Packet::Publish(publish)) = item {
|
if let rumqttc::Event::Incoming(rumqttc::Packet::Publish(publish)) = item {
|
||||||
if publish.topic == "omnioil/events/project_created" {
|
let topic = publish.topic.clone();
|
||||||
let payload = String::from_utf8_lossy(&publish.payload);
|
let payload = publish.payload.clone();
|
||||||
tracing::info!("📥 Recibido Domain Event (MQTT): {}", payload);
|
let state = state_worker.clone();
|
||||||
|
|
||||||
if let Ok(event) = serde_json::from_str::<shared_lib::mqtt_messages::ProjectCreatedEvent>(&payload) {
|
if topic == "omnioil/events/project_created" {
|
||||||
let pool_for_worker = db_pool.clone();
|
let payload_str = String::from_utf8_lossy(&payload);
|
||||||
|
tracing::info!("📥 Recibido Project Created Event: {}", payload_str);
|
||||||
|
if let Ok(event) = serde_json::from_str::<shared_lib::mqtt_messages::ProjectCreatedEvent>(&payload_str) {
|
||||||
tokio::spawn(async move {
|
tokio::spawn(async move {
|
||||||
if let Err(e) = installer_worker::handle_project_created(pool_for_worker, event).await {
|
if let Err(e) = installer_worker::handle_project_created(state.db_pool.clone(), state.mqtt_client.clone(), event).await {
|
||||||
tracing::error!("Worker de instalador falló: {}", e);
|
tracing::error!("Worker de instalador falló: {}", e);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
} else if topic.starts_with("omnioil/control/deploy/") {
|
||||||
|
let payload_str = String::from_utf8_lossy(&payload);
|
||||||
|
tracing::info!("📥 Recibido Deployment Request. Payload crudo: {}", payload_str);
|
||||||
|
|
||||||
|
if let Ok(config) = serde_json::from_str::<shared_lib::edge_models::ProjectConfig>(&payload_str) {
|
||||||
|
tokio::spawn(async move {
|
||||||
|
let deployment_id = Uuid::new_v4();
|
||||||
|
tracing::info!("🚀 Proyecto deserializado: {}. Activos encontrados: {}", config.project.name, config.assets.len());
|
||||||
|
if let Err(e) = publisher::execute_build_pipeline(&state, &config, deployment_id).await {
|
||||||
|
tracing::error!("Pipeline de build falló: {}", e);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
tracing::error!("❌ Fallo al deserializar ProjectConfig. Revisa la estructura del JSON.");
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -96,23 +115,8 @@ async fn main() -> Result<(), Box<dyn std::error::Error>> {
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
tracing::info!("Connected to MQTT broker at {}:{}", mqtt_host, mqtt_port);
|
tracing::info!("✅ Build Orchestrator ready. Waiting for messages...");
|
||||||
tracing::info!("Connected to MQTT broker at {}:{}", mqtt_host, mqtt_port);
|
|
||||||
|
|
||||||
let registry_url =
|
|
||||||
env::var("DOCKER_REGISTRY_URL").unwrap_or_else(|_| "localhost:5000".to_string());
|
|
||||||
|
|
||||||
let _state = OrchestratorState {
|
|
||||||
db_pool: pool,
|
|
||||||
docker,
|
|
||||||
mqtt_client,
|
|
||||||
registry_url,
|
|
||||||
};
|
|
||||||
|
|
||||||
tracing::info!("✅ Build Orchestrator ready. Waiting for build requests...");
|
|
||||||
|
|
||||||
// En producción, este servicio esperaría peticiones via API interna
|
|
||||||
// o MQTT. Por ahora, mantenemos el proceso vivo.
|
|
||||||
tokio::signal::ctrl_c().await?;
|
tokio::signal::ctrl_c().await?;
|
||||||
tracing::info!("Shutting down Build Orchestrator...");
|
tracing::info!("Shutting down Build Orchestrator...");
|
||||||
|
|
||||||
|
|||||||
@@ -1,9 +1,9 @@
|
|||||||
//! Event Publisher — Publica eventos de despliegue via MQTT y WebSocket.
|
//! Event Publisher — Publica eventos de despliegue via MQTT y WebSocket.
|
||||||
|
|
||||||
use rumqttc::{AsyncClient, QoS};
|
|
||||||
use shared_lib::mqtt_messages::{DeploySignal, PortMapping, VolumeMount, EnvVar};
|
|
||||||
use uuid::Uuid;
|
|
||||||
use chrono::Utc;
|
use chrono::Utc;
|
||||||
|
use rumqttc::{AsyncClient, QoS};
|
||||||
|
use shared_lib::mqtt_messages::{DeploySignal, EnvVar, PortMapping};
|
||||||
|
use uuid::Uuid;
|
||||||
|
|
||||||
#[allow(dead_code)]
|
#[allow(dead_code)]
|
||||||
/// Publica una señal de despliegue en el topic MQTT del proyecto.
|
/// Publica una señal de despliegue en el topic MQTT del proyecto.
|
||||||
@@ -13,37 +13,42 @@ pub async fn publish_deploy_signal(
|
|||||||
deployment_id: Uuid,
|
deployment_id: Uuid,
|
||||||
image_tag: &str,
|
image_tag: &str,
|
||||||
image_hash: &str,
|
image_hash: &str,
|
||||||
registry_url: &str,
|
internal_registry: &str, // Cambiado de registry_url
|
||||||
|
external_registry: &str, // Nuevo parámetro
|
||||||
|
flows_json: &str,
|
||||||
|
package_json: &str,
|
||||||
|
settings_js: &str,
|
||||||
|
devices: Option<Vec<shared_lib::edge_models::config::AssetConfig>>,
|
||||||
) -> Result<(), Box<dyn std::error::Error + Send + Sync>> {
|
) -> Result<(), Box<dyn std::error::Error + Send + Sync>> {
|
||||||
// ... (rest of function unchanged)
|
// Traducir el tag interno a externo para el agente
|
||||||
|
let translated_tag = if image_tag.starts_with(internal_registry) {
|
||||||
|
image_tag.replace(internal_registry, external_registry)
|
||||||
|
} else {
|
||||||
|
image_tag.to_string()
|
||||||
|
};
|
||||||
|
|
||||||
let signal = DeploySignal {
|
let signal = DeploySignal {
|
||||||
project_id,
|
project_id,
|
||||||
deployment_id,
|
deployment_id,
|
||||||
image_tag: image_tag.to_string(),
|
image_tag: translated_tag,
|
||||||
image_hash: image_hash.to_string(),
|
image_hash: image_hash.to_string(),
|
||||||
registry_url: registry_url.to_string(),
|
registry_url: external_registry.to_string(),
|
||||||
timestamp: Utc::now(),
|
timestamp: Utc::now(),
|
||||||
// Configuración por defecto (Node-RED) - Se puede extender dinámicamente
|
// Configuración por defecto (Node-RED) - Se puede extender dinámicamente
|
||||||
port_mappings: vec![
|
port_mappings: vec![PortMapping {
|
||||||
PortMapping {
|
|
||||||
host_port: 1880,
|
host_port: 1880,
|
||||||
container_port: 1880,
|
container_port: 1880,
|
||||||
protocol: Some("tcp".to_string()),
|
protocol: Some("tcp".to_string()),
|
||||||
},
|
}],
|
||||||
],
|
env_vars: vec![EnvVar {
|
||||||
volume_mounts: vec![
|
|
||||||
VolumeMount {
|
|
||||||
host_path: "/opt/omnioil/nodered-data".to_string(),
|
|
||||||
container_path: "/data".to_string(),
|
|
||||||
read_only: Some(false),
|
|
||||||
},
|
|
||||||
],
|
|
||||||
env_vars: vec![
|
|
||||||
EnvVar {
|
|
||||||
key: "TZ".to_string(),
|
key: "TZ".to_string(),
|
||||||
value: "America/Bogota".to_string(),
|
value: "America/Bogota".to_string(),
|
||||||
},
|
}],
|
||||||
],
|
volume_mounts: vec![],
|
||||||
|
flows_json: Some(flows_json.to_string()),
|
||||||
|
package_json: Some(package_json.to_string()),
|
||||||
|
settings_js: Some(settings_js.to_string()),
|
||||||
|
devices,
|
||||||
};
|
};
|
||||||
|
|
||||||
let topic = format!("omnioil/deploy/{}", project_id);
|
let topic = format!("omnioil/deploy/{}", project_id);
|
||||||
@@ -52,7 +57,7 @@ pub async fn publish_deploy_signal(
|
|||||||
tracing::info!("📡 Publishing deploy signal to topic: {}", topic);
|
tracing::info!("📡 Publishing deploy signal to topic: {}", topic);
|
||||||
|
|
||||||
mqtt_client
|
mqtt_client
|
||||||
.publish(&topic, QoS::AtLeastOnce, false, payload.as_bytes())
|
.publish(&topic, QoS::AtLeastOnce, true, payload.as_bytes())
|
||||||
.await
|
.await
|
||||||
.map_err(|e| format!("MQTT publish error: {}", e))?;
|
.map_err(|e| format!("MQTT publish error: {}", e))?;
|
||||||
|
|
||||||
@@ -93,45 +98,54 @@ pub async fn execute_build_pipeline(
|
|||||||
state: &super::OrchestratorState,
|
state: &super::OrchestratorState,
|
||||||
config: &shared_lib::edge_models::ProjectConfig,
|
config: &shared_lib::edge_models::ProjectConfig,
|
||||||
deployment_id: Uuid,
|
deployment_id: Uuid,
|
||||||
) -> Result<super::builder::BuildResult, Box<dyn std::error::Error + Send + Sync>> {
|
) -> Result<(), Box<dyn std::error::Error + Send + Sync>> {
|
||||||
let project_id = config.project.id;
|
let project_id = config.project.id;
|
||||||
|
|
||||||
// 1. Notificar inicio de build
|
// 1. Notificar inicio
|
||||||
publish_build_status(&state.mqtt_client, project_id, "BUILDING", "Starting build...").await?;
|
publish_build_status(
|
||||||
|
&state.mqtt_client,
|
||||||
|
project_id,
|
||||||
|
"INITIALIZING",
|
||||||
|
"Generando configuración nativa...",
|
||||||
|
)
|
||||||
|
.await?;
|
||||||
|
|
||||||
// 2. Build de la imagen
|
// 2. Generar configuración (Flows, Settings, Package) sin Docker
|
||||||
let build_result = super::builder::build_scada_image(
|
let (flows_json, package_json, settings_js, _) = super::builder::generate_config_only(config)?;
|
||||||
&state.docker,
|
|
||||||
config,
|
|
||||||
&state.registry_url,
|
|
||||||
deployment_id,
|
|
||||||
).await?;
|
|
||||||
|
|
||||||
// 3. Notificar build completado
|
// Hash basado en el contenido de los flujos para control de versiones (usando sha2)
|
||||||
publish_build_status(&state.mqtt_client, project_id, "PUSHING", "Pushing to registry...").await?;
|
use sha2::{Sha256, Digest};
|
||||||
|
let mut hasher = Sha256::new();
|
||||||
|
hasher.update(flows_json.as_bytes());
|
||||||
|
let config_hash = hex::encode(hasher.finalize());
|
||||||
|
|
||||||
// 4. Push al registry
|
tracing::info!("📦 Deploying project {} with {} assets", project_id, config.assets.len());
|
||||||
match super::builder::push_image(&state.docker, &build_result.image_tag).await {
|
|
||||||
Ok(_) => {
|
|
||||||
publish_build_status(&state.mqtt_client, project_id, "PUSHED", "Image pushed successfully").await?;
|
|
||||||
}
|
|
||||||
Err(e) => {
|
|
||||||
publish_build_status(&state.mqtt_client, project_id, "FAILED", &format!("Push failed: {}", e)).await?;
|
|
||||||
return Err(e);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// 5. Publicar señal de deploy
|
// 3. Publicar señal de deploy directamente al agente nativo
|
||||||
publish_deploy_signal(
|
publish_deploy_signal(
|
||||||
&state.mqtt_client,
|
&state.mqtt_client,
|
||||||
project_id,
|
project_id,
|
||||||
deployment_id,
|
deployment_id,
|
||||||
&build_result.image_tag,
|
"native", // image_tag ya no se usa, pero mantenemos compatibilidad de struct
|
||||||
&build_result.image_hash,
|
&config_hash, // usamos el hash de la config como versión
|
||||||
&state.registry_url,
|
"none", // registry_url ya no se usa
|
||||||
).await?;
|
"none", // external_registry ya no se usa
|
||||||
|
&flows_json,
|
||||||
|
&package_json,
|
||||||
|
&settings_js,
|
||||||
|
Some(config.assets.clone()),
|
||||||
|
)
|
||||||
|
.await?;
|
||||||
|
|
||||||
|
publish_build_status(
|
||||||
|
&state.mqtt_client,
|
||||||
|
project_id,
|
||||||
|
"READY",
|
||||||
|
"Despliegue nativo completado con éxito",
|
||||||
|
)
|
||||||
|
.await?;
|
||||||
|
|
||||||
tracing::info!("🚀 Build pipeline completed for project {}", project_id);
|
tracing::info!("🚀 Build pipeline completed for project {}", project_id);
|
||||||
|
|
||||||
Ok(build_result)
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -4,15 +4,15 @@ version = "0.1.0"
|
|||||||
edition = "2024"
|
edition = "2024"
|
||||||
|
|
||||||
[dependencies]
|
[dependencies]
|
||||||
shared-lib = { path = "../shared-lib" }
|
shared-lib = { workspace = true }
|
||||||
tokio = { version = "1.50.0", features = ["full"] }
|
tokio = { workspace = true }
|
||||||
sqlx = { version = "0.8.6", features = ["runtime-tokio-native-tls", "postgres", "uuid", "chrono", "json", "bigdecimal"] }
|
sqlx = { workspace = true }
|
||||||
serde = { version = "1.0.228", features = ["derive"] }
|
serde = { workspace = true }
|
||||||
serde_json = "1.0.149"
|
serde_json = { workspace = true }
|
||||||
chrono = "0.4.44"
|
chrono = { workspace = true }
|
||||||
uuid = { version = "1.23.0", features = ["v4"] }
|
uuid = { workspace = true }
|
||||||
dotenv = "0.15.0"
|
dotenvy = { workspace = true }
|
||||||
tracing = "0.1.44"
|
tracing = { workspace = true }
|
||||||
tracing-subscriber = "0.3.23"
|
tracing-subscriber = { workspace = true }
|
||||||
reqwest = { version = "0.13.2", features = ["json"] }
|
reqwest = { workspace = true }
|
||||||
redis = { version = "1.1.0", features = ["tokio-comp"] }
|
redis = { version = "1.1.0", features = ["tokio-comp"] }
|
||||||
|
|||||||
@@ -1,75 +1,65 @@
|
|||||||
use serde::Deserialize;
|
use chrono::{DateTime, Utc};
|
||||||
|
use reqwest::Client;
|
||||||
|
use serde::{Deserialize, Serialize};
|
||||||
|
use serde_json::Value;
|
||||||
use std::collections::HashMap;
|
use std::collections::HashMap;
|
||||||
use std::error::Error;
|
use uuid::Uuid;
|
||||||
use serde_json::{Value, json};
|
|
||||||
use chrono::{DateTime, Utc, TimeZone};
|
|
||||||
|
|
||||||
#[derive(Debug, Deserialize)]
|
#[derive(Debug, Serialize, Deserialize)]
|
||||||
pub struct TagReading {
|
pub struct TagReading {
|
||||||
pub tag_id: String,
|
pub tag_id: String, // Formato "asset_id:tag_name" o simplemente "tag_name"
|
||||||
pub value: f64,
|
pub value: Value,
|
||||||
pub quality: String,
|
pub timestamp: Option<DateTime<Utc>>,
|
||||||
pub timestamp: i64, // Unix timestamp
|
|
||||||
}
|
}
|
||||||
|
|
||||||
#[allow(dead_code)]
|
|
||||||
pub struct TelemetryFetcher {
|
pub struct TelemetryFetcher {
|
||||||
api_url: String,
|
client: Client,
|
||||||
api_key: String,
|
|
||||||
client: reqwest::Client,
|
|
||||||
}
|
}
|
||||||
|
|
||||||
impl TelemetryFetcher {
|
impl TelemetryFetcher {
|
||||||
pub fn new(api_url: String, api_key: String) -> Self {
|
pub fn new() -> Self {
|
||||||
Self {
|
Self {
|
||||||
api_url,
|
client: Client::new(),
|
||||||
api_key,
|
|
||||||
client: reqwest::Client::new(),
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
pub async fn fetch_telemetry(&self) -> Result<HashMap<String, (DateTime<Utc>, Value)>, Box<dyn Error>> {
|
/// Obtiene telemetría de una fuente externa específica para un Asset.
|
||||||
// Validación de configuración
|
/// Retorna un Mapa de TagName -> (Timestamp, Value)
|
||||||
if self.api_url.is_empty() {
|
pub async fn fetch_asset_telemetry(
|
||||||
|
&self,
|
||||||
|
api_url: &str,
|
||||||
|
api_key: &str,
|
||||||
|
) -> Result<HashMap<String, (DateTime<Utc>, Value)>, Box<dyn std::error::Error>> {
|
||||||
|
if api_url.is_empty() {
|
||||||
return Ok(HashMap::new());
|
return Ok(HashMap::new());
|
||||||
}
|
}
|
||||||
|
|
||||||
// Realizamos la petición real a la API configurada
|
let resp_res = self.client
|
||||||
let resp = self.client.get(&self.api_url)
|
.get(api_url)
|
||||||
.header("X-API-KEY", &self.api_key)
|
.header("X-API-KEY", api_key)
|
||||||
.send()
|
.send()
|
||||||
.await?
|
|
||||||
.json::<Vec<TagReading>>()
|
|
||||||
.await?;
|
.await?;
|
||||||
|
|
||||||
// Agrupar por Activo
|
let body = resp_res.text().await?;
|
||||||
// Asumimos convención: CODIGO_ACTIVO:VARIABLE (ej: POZO-X1:PRESION)
|
|
||||||
let mut grouped_data: HashMap<String, serde_json::Map<String, Value>> = HashMap::new();
|
let resp: Vec<TagReading> = match serde_json::from_str(&body) {
|
||||||
let mut asset_timestamps: HashMap<String, DateTime<Utc>> = HashMap::new();
|
Ok(data) => data,
|
||||||
|
Err(e) => {
|
||||||
|
tracing::error!("❌ Failed to decode JSON from {}. Error: {}. Body: {}", api_url, e, body);
|
||||||
|
return Err(e.into());
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// Procesar lecturas
|
||||||
|
let mut data_map: HashMap<String, (DateTime<Utc>, Value)> = HashMap::new();
|
||||||
|
let now = Utc::now();
|
||||||
|
|
||||||
for reading in resp {
|
for reading in resp {
|
||||||
let parts: Vec<&str> = reading.tag_id.split(':').collect();
|
// Si la API externa no manda timestamp, usamos 'ahora'
|
||||||
if parts.len() < 2 { continue; }
|
let ts = reading.timestamp.unwrap_or(now);
|
||||||
|
data_map.insert(reading.tag_id, (ts, reading.value));
|
||||||
let asset_code = parts[0].to_string();
|
|
||||||
let variable = parts[1].to_string();
|
|
||||||
|
|
||||||
let entry = grouped_data.entry(asset_code.clone()).or_insert_with(serde_json::Map::new);
|
|
||||||
entry.insert(variable, json!({
|
|
||||||
"value": reading.value,
|
|
||||||
"quality": reading.quality
|
|
||||||
}));
|
|
||||||
|
|
||||||
asset_timestamps.entry(asset_code).or_insert_with(|| Utc.timestamp_opt(reading.timestamp, 0).unwrap());
|
|
||||||
}
|
}
|
||||||
|
|
||||||
let mut result = HashMap::new();
|
Ok(data_map)
|
||||||
for (code, map) in grouped_data {
|
|
||||||
if let Some(ts) = asset_timestamps.get(&code) {
|
|
||||||
result.insert(code, (*ts, Value::Object(map)));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
Ok(result)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,18 +1,15 @@
|
|||||||
//! OmniOil Data Collector - Servicio de Ingesta Externa.
|
//! OmniOil Data Collector - Servicio de Ingesta Externa Multitenant.
|
||||||
//!
|
|
||||||
//! Servicio dedicado a la recolección periódica de datos desde plataformas
|
|
||||||
//! externas y su persistencia transaccional en TimescaleDB.
|
|
||||||
|
|
||||||
mod fetcher;
|
mod fetcher;
|
||||||
mod persistence;
|
mod persistence;
|
||||||
|
|
||||||
use crate::fetcher::TelemetryFetcher;
|
use crate::fetcher::TelemetryFetcher;
|
||||||
use dotenv::dotenv;
|
use dotenvy::dotenv;
|
||||||
use sqlx::postgres::PgPoolOptions;
|
use sqlx::postgres::PgPoolOptions;
|
||||||
use std::env;
|
use std::env;
|
||||||
use std::time::Duration;
|
use std::time::Duration;
|
||||||
use tokio::time::sleep;
|
use tokio::time::sleep;
|
||||||
use tracing::{error, info};
|
use tracing::{error, info, warn};
|
||||||
|
use shared_lib::models::EdgeAsset;
|
||||||
|
|
||||||
#[tokio::main]
|
#[tokio::main]
|
||||||
async fn main() -> Result<(), Box<dyn std::error::Error>> {
|
async fn main() -> Result<(), Box<dyn std::error::Error>> {
|
||||||
@@ -20,10 +17,8 @@ async fn main() -> Result<(), Box<dyn std::error::Error>> {
|
|||||||
tracing_subscriber::fmt::init();
|
tracing_subscriber::fmt::init();
|
||||||
|
|
||||||
let database_url = env::var("DATABASE_URL").expect("DATABASE_URL must be set");
|
let database_url = env::var("DATABASE_URL").expect("DATABASE_URL must be set");
|
||||||
let api_url = env::var("API_URL").unwrap_or_default();
|
|
||||||
let api_key = env::var("API_KEY").unwrap_or_default();
|
|
||||||
|
|
||||||
info!("🚀 Starting Data Collector...");
|
info!("🚀 Starting Multitenant Data Collector...");
|
||||||
|
|
||||||
// Conectar a la base de datos
|
// Conectar a la base de datos
|
||||||
let pool = PgPoolOptions::new()
|
let pool = PgPoolOptions::new()
|
||||||
@@ -32,24 +27,66 @@ async fn main() -> Result<(), Box<dyn std::error::Error>> {
|
|||||||
.await?;
|
.await?;
|
||||||
info!("✅ Connected to TimescaleDB.");
|
info!("✅ Connected to TimescaleDB.");
|
||||||
|
|
||||||
let fetcher = TelemetryFetcher::new(api_url, api_key);
|
let fetcher = TelemetryFetcher::new();
|
||||||
info!("🔄 Iteration interval: 10s. Waiting for telemetry data...");
|
info!("🔄 Starting orchestration loop (Interval: 15s)...");
|
||||||
|
|
||||||
// Bucle principal de orquestación
|
|
||||||
loop {
|
loop {
|
||||||
match fetcher.fetch_telemetry().await {
|
// 1. Buscar todos los Assets que tengan la integración activa
|
||||||
|
let active_assets = match sqlx::query_as::<_, EdgeAsset>(
|
||||||
|
"SELECT * FROM edge_assets WHERE is_collector_enabled = true"
|
||||||
|
).fetch_all(&pool).await {
|
||||||
|
Ok(assets) => assets,
|
||||||
|
Err(e) => {
|
||||||
|
error!("❌ Error querying active assets: {}", e);
|
||||||
|
sleep(Duration::from_secs(10)).await;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
if active_assets.is_empty() {
|
||||||
|
info!("ℹ️ No assets with external integration active. Sleeping 30s...");
|
||||||
|
sleep(Duration::from_secs(30)).await;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
info!("📡 Found {} assets to poll.", active_assets.len());
|
||||||
|
|
||||||
|
// 2. Procesar cada Asset
|
||||||
|
for asset in active_assets {
|
||||||
|
let url = match &asset.external_api_url {
|
||||||
|
Some(u) if !u.is_empty() => u,
|
||||||
|
_ => continue,
|
||||||
|
};
|
||||||
|
let key = asset.external_api_key.as_deref().unwrap_or("");
|
||||||
|
|
||||||
|
info!("🔍 Polling external API for asset: {} ({})", asset.name, asset.code);
|
||||||
|
|
||||||
|
match fetcher.fetch_asset_telemetry(url, key).await {
|
||||||
Ok(data_map) => {
|
Ok(data_map) => {
|
||||||
for (asset_code, (time, data)) in data_map {
|
if data_map.is_empty() {
|
||||||
if let Err(e) = persistence::ingest_telemetry_batch(&pool, &asset_code, time, data).await {
|
warn!("⚠️ No data returned for asset {}", asset.name);
|
||||||
error!("❌ Fatal error during ingestion for {}: {}", asset_code, e);
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 3. Persistir datos
|
||||||
|
for (tag_name, (time, value)) in data_map {
|
||||||
|
let mut payload = serde_json::Map::new();
|
||||||
|
payload.insert(tag_name, value);
|
||||||
|
let data_json = serde_json::Value::Object(payload);
|
||||||
|
|
||||||
|
if let Err(e) = persistence::ingest_telemetry_batch(&pool, &asset.code, time, data_json).await {
|
||||||
|
error!("❌ Error ingesting data for asset {}: {}", asset.name, e);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
info!("✅ Ingested telemetry for asset {}", asset.name);
|
||||||
}
|
}
|
||||||
Err(e) => {
|
Err(e) => {
|
||||||
error!("❌ External API communication error: {}", e);
|
error!("❌ API Error for asset {}: {}", asset.name, e);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
sleep(Duration::from_secs(10)).await;
|
// Intervalo entre rondas de polling
|
||||||
|
sleep(Duration::from_secs(15)).await;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,27 +1,27 @@
|
|||||||
[package]
|
[package]
|
||||||
name = "edge-agent"
|
name = "edge-agent"
|
||||||
version = "0.1.2"
|
version = "0.2.0"
|
||||||
edition = "2024"
|
edition = "2024"
|
||||||
|
|
||||||
[dependencies]
|
[dependencies]
|
||||||
shared-lib = { path = "../shared-lib" }
|
shared-lib = { workspace = true }
|
||||||
tokio = { version = "1.50.0", features = ["full"] }
|
tokio = { version = "1.50.0", features = ["rt-multi-thread", "macros", "sync", "time", "io-util", "net", "signal", "process", "fs"] }
|
||||||
bollard = "0.20.2"
|
rumqttc = { workspace = true, features = ["websocket", "use-rustls"] }
|
||||||
rumqttc = { version = "0.25.1", features = ["websocket", "use-rustls"] }
|
serde = { workspace = true }
|
||||||
serde = { version = "1.0.228", features = ["derive"] }
|
serde_json = { workspace = true }
|
||||||
serde_json = "1.0.149"
|
anyhow = { workspace = true }
|
||||||
anyhow = "1.0.102"
|
uuid = { workspace = true }
|
||||||
uuid = { version = "1.23.0", features = ["v4", "serde"] }
|
zstd = { workspace = true }
|
||||||
chrono = { version = "0.4.44", features = ["serde"] }
|
chrono = { workspace = true }
|
||||||
tracing = "0.1.44"
|
tracing = { workspace = true }
|
||||||
tracing-subscriber = { version = "0.3.23", features = ["env-filter"] }
|
tracing-subscriber = { workspace = true }
|
||||||
axum = { version = "0.8.8", features = ["macros"] }
|
tracing-appender = "0.2.3"
|
||||||
tower = { version = "0.5.3", features = ["util"] }
|
axum = { workspace = true }
|
||||||
tower-http = { version = "0.6.8", features = ["trace", "cors"] }
|
tower = { workspace = true }
|
||||||
|
tower-http = { workspace = true }
|
||||||
toml = "1.1.0"
|
toml = "1.1.0"
|
||||||
reqwest = { version = "0.13.2", features = ["json"], default-features = false }
|
reqwest = { workspace = true }
|
||||||
futures-util = "0.3.32"
|
futures-util = "0.3.32"
|
||||||
sysinfo = "0.38.4"
|
|
||||||
libc = "0.2.183"
|
libc = "0.2.183"
|
||||||
self-replace = "1.5.0"
|
self-replace = "1.5.0"
|
||||||
hostname = "0.4.2"
|
hostname = "0.4.2"
|
||||||
@@ -31,10 +31,13 @@ winit = "0.30.13"
|
|||||||
winres = "0.1.12"
|
winres = "0.1.12"
|
||||||
image = "0.25.10"
|
image = "0.25.10"
|
||||||
rusqlite = { version = "0.32.1", features = ["bundled"] }
|
rusqlite = { version = "0.32.1", features = ["bundled"] }
|
||||||
aes-gcm = "0.10.3"
|
aes-gcm = { workspace = true }
|
||||||
pbkdf2 = "0.12.2"
|
pbkdf2 = { workspace = true }
|
||||||
sha2 = "0.10.8"
|
sha2 = { workspace = true }
|
||||||
rand = "0.8.5"
|
rand = { workspace = true }
|
||||||
|
tokio-modbus = { workspace = true }
|
||||||
|
s7 = { workspace = true }
|
||||||
|
async-trait = "0.1.86"
|
||||||
|
|
||||||
[build-dependencies]
|
[build-dependencies]
|
||||||
winres = "0.1.12"
|
winres = "0.1.12"
|
||||||
|
|||||||
@@ -40,42 +40,39 @@ while true; do
|
|||||||
log "🚀 Detectado nuevo binario: $FILENAME"
|
log "🚀 Detectado nuevo binario: $FILENAME"
|
||||||
log "🔧 Preparando empaquetado para: $VERSION_NAME..."
|
log "🔧 Preparando empaquetado para: $VERSION_NAME..."
|
||||||
|
|
||||||
# 0. Extraer versión dinámica desde Cargo.toml
|
# 0. Intentar extraer versión del nombre del archivo (ej: edge-agent-v0.2.0-...)
|
||||||
VERSION=$(grep -m 1 "^version =" Cargo.toml | cut -d '"' -f 2)
|
# Si falla, cae a Cargo.toml
|
||||||
|
VERSION=$(echo "$FILENAME" | grep -oP 'v\K[0-9.]+' || echo "")
|
||||||
|
|
||||||
if [ -z "$VERSION" ]; then
|
if [ -z "$VERSION" ]; then
|
||||||
VERSION="0.1.0"
|
log "⚠️ No se detectó versión en el nombre del archivo, buscando en Cargo.toml..."
|
||||||
log "⚠️ No se pudo detectar versión en Cargo.toml, usando fallback: $VERSION"
|
VERSION=$(grep -m 1 "^version =" Cargo.toml | cut -d '"' -f 2)
|
||||||
|
fi
|
||||||
|
|
||||||
|
if [ -z "$VERSION" ]; then
|
||||||
|
VERSION="0.2.0"
|
||||||
|
log "⚠️ No se pudo detectar versión, usando fallback: $VERSION"
|
||||||
else
|
else
|
||||||
log "📌 Versión detectada: $VERSION"
|
log "📌 Versión detectada: $VERSION"
|
||||||
fi
|
fi
|
||||||
|
|
||||||
# Aplicar versión a main.wxs dinámicamente (solo a la etiqueta Product)
|
# Crear una copia temporal del archivo WXS para no destruir el original
|
||||||
# Usamos un espacio antes de Version para no tocar InstallerVersion
|
TEMP_WXS="wix/main_temp.wxs"
|
||||||
sed -i "s/ Version='[0-9.]*'/ Version='$VERSION'/" wix/main.wxs
|
cp wix/main.wxs "$TEMP_WXS"
|
||||||
sed -i "s/{{VERSION}}/$VERSION/g" wix/main.wxs
|
|
||||||
|
# Aplicar versión a la copia temporal
|
||||||
|
sed -i "s/ Version='[0-9.]*'/ Version='$VERSION'/" "$TEMP_WXS"
|
||||||
|
sed -i "s/{{VERSION}}/$VERSION/g" "$TEMP_WXS"
|
||||||
|
|
||||||
# 1. Asegurar carpeta y mover el binario personalizado
|
# 1. Asegurar carpeta y mover el binario personalizado
|
||||||
mkdir -p "target/x86_64-pc-windows-msvc/release/"
|
mkdir -p "target/x86_64-pc-windows-msvc/release/"
|
||||||
cp -f "$EXE" "target/x86_64-pc-windows-msvc/release/edge-agent.exe"
|
cp -f "$EXE" "target/x86_64-pc-windows-msvc/release/edge-agent.exe"
|
||||||
|
|
||||||
# El Tray (edge-tray.exe) ya debería estar en la carpeta target gracias al Dockerfile.
|
|
||||||
# Solo nos aseguramos de que el icono esté accesible para wixl
|
|
||||||
cp -f icon.ico target/x86_64-pc-windows-msvc/release/icon.ico || true
|
|
||||||
|
|
||||||
# Crear un agent.toml base si no existe (clímax para evitar fallo de servicio)
|
|
||||||
if [ ! -f "agent.toml" ]; then
|
|
||||||
cat <<EOF > agent.toml
|
|
||||||
[agent]
|
|
||||||
project_id = "00000000-0000-0000-0000-000000000000"
|
|
||||||
[mqtt]
|
|
||||||
host = "localhost"
|
|
||||||
[docker]
|
|
||||||
[server]
|
|
||||||
EOF
|
|
||||||
fi
|
|
||||||
|
|
||||||
# 2. Ejecutar WiXl para generar el MSI
|
# 2. Ejecutar WiXl para generar el MSI
|
||||||
wixl -v -o "$OUTPUT_DIR/$MSI_NAME" "wix/main.wxs"
|
wixl -v -o "$OUTPUT_DIR/$MSI_NAME" "$TEMP_WXS"
|
||||||
|
|
||||||
|
# Limpiar temporal
|
||||||
|
rm "$TEMP_WXS"
|
||||||
|
|
||||||
if [ $? -eq 0 ]; then
|
if [ $? -eq 0 ]; then
|
||||||
# 3. Parchar el Summary Information Stream para que Windows lo acepte
|
# 3. Parchar el Summary Information Stream para que Windows lo acepte
|
||||||
|
|||||||
@@ -2,8 +2,7 @@
|
|||||||
//! Soporta archivos locales (TOML), configuración embebida (Baked)
|
//! Soporta archivos locales (TOML), configuración embebida (Baked)
|
||||||
//! y inyección dinámica (Overlay) mediante lectura de footer de archivo.
|
//! y inyección dinámica (Overlay) mediante lectura de footer de archivo.
|
||||||
|
|
||||||
use std::path::{Path, PathBuf};
|
use std::path::Path;
|
||||||
use std::io::{Read, Seek, SeekFrom};
|
|
||||||
use anyhow::{Result, Context};
|
use anyhow::{Result, Context};
|
||||||
use super::models::AgentConfig;
|
use super::models::AgentConfig;
|
||||||
|
|
||||||
|
|||||||
@@ -16,6 +16,7 @@ pub struct AgentConfig {
|
|||||||
pub struct AgentSettings {
|
pub struct AgentSettings {
|
||||||
pub project_id: String,
|
pub project_id: String,
|
||||||
pub hostname: Option<String>,
|
pub hostname: Option<String>,
|
||||||
|
pub master_secret: Option<String>,
|
||||||
pub health_check_interval_secs: Option<u64>,
|
pub health_check_interval_secs: Option<u64>,
|
||||||
pub max_restart_retries: Option<u32>,
|
pub max_restart_retries: Option<u32>,
|
||||||
}
|
}
|
||||||
@@ -39,6 +40,7 @@ pub struct DockerSettings {
|
|||||||
pub registry_url: Option<String>,
|
pub registry_url: Option<String>,
|
||||||
pub nodered_host_port: Option<u16>,
|
pub nodered_host_port: Option<u16>,
|
||||||
pub data_volume_path: Option<String>,
|
pub data_volume_path: Option<String>,
|
||||||
|
pub network: Option<String>,
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Debug, Deserialize, Clone)]
|
#[derive(Debug, Deserialize, Clone)]
|
||||||
|
|||||||
163
services/edge-agent/src/data_collector.rs
Normal file
163
services/edge-agent/src/data_collector.rs
Normal file
@@ -0,0 +1,163 @@
|
|||||||
|
use crate::drivers::{modbus_tcp::ModbusTcpDriver, s7_comm::S7CommDriver, ProtocolDriver};
|
||||||
|
use crate::persistence_manager::StoreAndForward;
|
||||||
|
use rumqttc::{AsyncClient, QoS};
|
||||||
|
use serde_json::{json, Value};
|
||||||
|
use std::collections::HashMap;
|
||||||
|
use std::sync::Arc;
|
||||||
|
use tokio::sync::Mutex;
|
||||||
|
use tokio::time::{interval, Duration};
|
||||||
|
use uuid::Uuid;
|
||||||
|
use shared_lib::edge_models::config::AssetConfig;
|
||||||
|
|
||||||
|
pub struct DataCollectorManager {
|
||||||
|
mqtt_client: AsyncClient,
|
||||||
|
project_id: Uuid,
|
||||||
|
storage: Arc<StoreAndForward>,
|
||||||
|
handles: Arc<Mutex<Vec<tokio::task::JoinHandle<()>>>>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl DataCollectorManager {
|
||||||
|
pub fn new(mqtt_client: AsyncClient, project_id: Uuid, storage: Arc<StoreAndForward>) -> Self {
|
||||||
|
Self {
|
||||||
|
mqtt_client,
|
||||||
|
project_id,
|
||||||
|
storage,
|
||||||
|
handles: Arc::new(Mutex::new(Vec::new())),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn update_config(&self, assets: Vec<AssetConfig>, log_reporter: Arc<crate::log_reporter::LogReporter>) {
|
||||||
|
let mut handles = self.handles.lock().await;
|
||||||
|
|
||||||
|
let msg = format!("🔄 Processing configuration for {} assets...", assets.len());
|
||||||
|
tracing::info!("{}", msg);
|
||||||
|
let _ = log_reporter.info("DEPLOY", &msg).await;
|
||||||
|
|
||||||
|
// Stop old tasks
|
||||||
|
let old_count = handles.len();
|
||||||
|
for handle in handles.drain(..) {
|
||||||
|
handle.abort();
|
||||||
|
}
|
||||||
|
if old_count > 0 {
|
||||||
|
tracing::info!("🛑 Stopped {} previous collection tasks", old_count);
|
||||||
|
}
|
||||||
|
|
||||||
|
let telemetry_topic = format!("omnioil/telemetry/{}", self.project_id);
|
||||||
|
|
||||||
|
for asset_cfg in assets {
|
||||||
|
for device_cfg in asset_cfg.devices {
|
||||||
|
let mqtt = self.mqtt_client.clone();
|
||||||
|
let topic = telemetry_topic.clone();
|
||||||
|
let storage_clone = self.storage.clone();
|
||||||
|
let project_id = self.project_id;
|
||||||
|
let device = device_cfg.device;
|
||||||
|
let reporter = log_reporter.clone();
|
||||||
|
|
||||||
|
// Map variables
|
||||||
|
let mut variables = HashMap::new();
|
||||||
|
for var in device_cfg.variables {
|
||||||
|
variables.insert(var.alias, var.address);
|
||||||
|
}
|
||||||
|
|
||||||
|
let var_count = variables.len();
|
||||||
|
tracing::info!("📡 Starting collector for device '{}' ({} variables)", device.name, var_count);
|
||||||
|
|
||||||
|
let handle = tokio::spawn(async move {
|
||||||
|
let mut driver: Box<dyn ProtocolDriver> = match device.protocol {
|
||||||
|
shared_lib::edge_models::devices::DeviceProtocol::ModbusTcp => {
|
||||||
|
let unit_id = device.protocol_config.get("unit_id").and_then(|v| v.as_u64()).unwrap_or(1) as u8;
|
||||||
|
tracing::debug!("🔧 Initializing Modbus driver for {} at {}:{}", device.name, device.host, device.port);
|
||||||
|
Box::new(ModbusTcpDriver::new(&device.host, device.port as u16, unit_id))
|
||||||
|
},
|
||||||
|
shared_lib::edge_models::devices::DeviceProtocol::S7 => {
|
||||||
|
let rack = device.protocol_config.get("rack").and_then(|v| v.as_u64()).unwrap_or(0) as u16;
|
||||||
|
let slot = device.protocol_config.get("slot").and_then(|v| v.as_u64()).unwrap_or(1) as u16;
|
||||||
|
tracing::debug!("🔧 Initializing S7 driver for {} at {}:{}", device.name, device.host, device.port);
|
||||||
|
Box::new(S7CommDriver::new(&device.host, rack, slot))
|
||||||
|
},
|
||||||
|
_ => {
|
||||||
|
let msg = format!("Unsupported protocol for device {}: {:?}", device.name, device.protocol);
|
||||||
|
tracing::error!("{}", msg);
|
||||||
|
let _ = reporter.error("COLLECTOR", &msg).await;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
let mut timer = interval(Duration::from_secs(5)); // Default 5s polling
|
||||||
|
let mut connected_notified = false;
|
||||||
|
|
||||||
|
loop {
|
||||||
|
timer.tick().await;
|
||||||
|
|
||||||
|
if !driver.is_alive() {
|
||||||
|
connected_notified = false;
|
||||||
|
let msg = format!("🔌 Attempting to connect to {} at {}:{}...", device.name, device.host, device.port);
|
||||||
|
let _ = reporter.info("COLLECTOR", &msg).await;
|
||||||
|
|
||||||
|
if let Err(e) = driver.connect().await {
|
||||||
|
let msg = format!("❌ Connection failed for {}: {}", device.name, e);
|
||||||
|
tracing::warn!("{}", msg);
|
||||||
|
let _ = reporter.error("COLLECTOR", &msg).await;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if !connected_notified {
|
||||||
|
let msg = format!("✅ Successfully connected to device '{}' at {}:{}", device.name, device.host, device.port);
|
||||||
|
tracing::info!("{}", msg);
|
||||||
|
let _ = reporter.info("COLLECTOR", &msg).await;
|
||||||
|
connected_notified = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
let msg = format!("📡 Reading variables for {}...", device.name);
|
||||||
|
let _ = reporter.info("COLLECTOR", &msg).await;
|
||||||
|
|
||||||
|
match driver.read_variables(&variables).await {
|
||||||
|
Ok(values) => {
|
||||||
|
if !values.is_empty() {
|
||||||
|
let msg = format!("📥 Read successful for {}: {} values obtained", device.name, values.len());
|
||||||
|
let _ = reporter.info("COLLECTOR", &msg).await;
|
||||||
|
|
||||||
|
let payload = json!({
|
||||||
|
"project_id": project_id,
|
||||||
|
"device_name": device.name,
|
||||||
|
"values": values,
|
||||||
|
"timestamp": chrono::Utc::now().to_rfc3339(),
|
||||||
|
});
|
||||||
|
|
||||||
|
let payload_str = payload.to_string();
|
||||||
|
let payload_bytes = payload_str.as_bytes().to_vec();
|
||||||
|
|
||||||
|
let msg = format!("📤 Publishing telemetry for {} to MQTT topic: {}", device.name, topic);
|
||||||
|
let _ = reporter.info("MQTT", &msg).await;
|
||||||
|
|
||||||
|
if let Err(e) = mqtt.publish(&topic, QoS::AtMostOnce, false, payload_bytes).await {
|
||||||
|
let msg = format!("⚠️ MQTT publish failed for {}: {}. Buffering locally.", device.name, e);
|
||||||
|
let _ = reporter.error("MQTT", &msg).await;
|
||||||
|
let _ = storage_clone.save(&topic, &payload_str);
|
||||||
|
} else {
|
||||||
|
let _ = reporter.info("MQTT", &format!("✅ Data sent for {}", device.name)).await;
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
let _ = reporter.warn("COLLECTOR", &format!("⚠️ No values returned for device {}", device.name)).await;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Err(e) => {
|
||||||
|
let msg = format!("❌ Error reading from device {}: {}", device.name, e);
|
||||||
|
tracing::error!("{}", msg);
|
||||||
|
let _ = reporter.error("COLLECTOR", &msg).await;
|
||||||
|
let _ = driver.disconnect().await;
|
||||||
|
connected_notified = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
handles.push(handle);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
let msg = format!("Data collector operational with {} active drivers", handles.len());
|
||||||
|
tracing::info!("✅ {}", msg);
|
||||||
|
let _ = log_reporter.info("DEPLOY", &msg).await;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -10,12 +10,11 @@ use std::net::SocketAddr;
|
|||||||
use std::sync::atomic::{AtomicI64, Ordering};
|
use std::sync::atomic::{AtomicI64, Ordering};
|
||||||
use std::sync::Arc;
|
use std::sync::Arc;
|
||||||
use tokio::time::{interval, Duration};
|
use tokio::time::{interval, Duration};
|
||||||
use uuid::Uuid;
|
|
||||||
|
|
||||||
/// Estado compartido para el gateway de datos.
|
/// Estado compartido para el gateway de datos.
|
||||||
struct GatewayState {
|
struct GatewayState {
|
||||||
mqtt_client: AsyncClient,
|
mqtt_client: AsyncClient,
|
||||||
project_id: Uuid,
|
telemetry_topic: String,
|
||||||
storage: Arc<StoreAndForward>,
|
storage: Arc<StoreAndForward>,
|
||||||
last_heartbeat: Arc<AtomicI64>,
|
last_heartbeat: Arc<AtomicI64>,
|
||||||
}
|
}
|
||||||
@@ -24,14 +23,14 @@ struct GatewayState {
|
|||||||
pub async fn start_data_gateway(
|
pub async fn start_data_gateway(
|
||||||
port: u16,
|
port: u16,
|
||||||
mqtt_client: AsyncClient,
|
mqtt_client: AsyncClient,
|
||||||
project_id: Uuid,
|
project_id: uuid::Uuid,
|
||||||
storage: Arc<StoreAndForward>,
|
storage: Arc<StoreAndForward>,
|
||||||
) -> anyhow::Result<()> {
|
) -> anyhow::Result<()> {
|
||||||
let last_heartbeat = Arc::new(AtomicI64::new(chrono::Utc::now().timestamp()));
|
let last_heartbeat = Arc::new(AtomicI64::new(chrono::Utc::now().timestamp()));
|
||||||
|
|
||||||
let state = Arc::new(GatewayState {
|
let state = Arc::new(GatewayState {
|
||||||
mqtt_client: mqtt_client.clone(),
|
mqtt_client: mqtt_client.clone(),
|
||||||
project_id,
|
telemetry_topic: format!("omnioil/telemetry/{}", project_id),
|
||||||
storage: storage.clone(),
|
storage: storage.clone(),
|
||||||
last_heartbeat: last_heartbeat.clone(),
|
last_heartbeat: last_heartbeat.clone(),
|
||||||
});
|
});
|
||||||
@@ -69,7 +68,7 @@ async fn ingest_data(
|
|||||||
State(state): State<Arc<GatewayState>>,
|
State(state): State<Arc<GatewayState>>,
|
||||||
Json(mut payload): Json<Value>,
|
Json(mut payload): Json<Value>,
|
||||||
) -> Json<Value> {
|
) -> Json<Value> {
|
||||||
let topic = format!("omnioil/telemetry/{}", state.project_id);
|
let topic = &state.telemetry_topic;
|
||||||
|
|
||||||
// Asegurar que el payload tenga un timestamp de captura (Capture Time)
|
// Asegurar que el payload tenga un timestamp de captura (Capture Time)
|
||||||
// Si Node-RED no lo envió, lo generamos en el momento justo de recibirlo en el borde.
|
// Si Node-RED no lo envió, lo generamos en el momento justo de recibirlo en el borde.
|
||||||
@@ -88,20 +87,46 @@ async fn ingest_data(
|
|||||||
Err(e) => return Json(json!({ "status": "error", "message": e.to_string() })),
|
Err(e) => return Json(json!({ "status": "error", "message": e.to_string() })),
|
||||||
};
|
};
|
||||||
|
|
||||||
// Intentar publicar a MQTT
|
let data_bytes = data_str.as_bytes();
|
||||||
match state
|
|
||||||
.mqtt_client
|
// Si el payload es grande (> 512 bytes), comprimimos
|
||||||
.publish(&topic, QoS::AtLeastOnce, false, data_str.as_bytes())
|
let final_payload = if data_bytes.len() > 512 {
|
||||||
.await
|
match shared_lib::compression::compress(data_bytes) {
|
||||||
{
|
Ok(compressed) => {
|
||||||
Ok(_) => {
|
tracing::debug!("Compression: {} -> {} bytes", data_bytes.len(), compressed.len());
|
||||||
tracing::debug!("📡 Data published to MQTT: {}", topic);
|
compressed
|
||||||
|
},
|
||||||
|
Err(e) => {
|
||||||
|
tracing::error!("Compression error: {}", e);
|
||||||
|
data_bytes.to_vec()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
data_bytes.to_vec()
|
||||||
|
};
|
||||||
|
|
||||||
|
// Intentar publicar a MQTT con un timeout corto para no colgar a Node-RED
|
||||||
|
let publish_future = state.mqtt_client.publish(topic, QoS::AtLeastOnce, false, final_payload);
|
||||||
|
|
||||||
|
match tokio::time::timeout(Duration::from_secs(2), publish_future).await {
|
||||||
|
Ok(Ok(_)) => {
|
||||||
|
tracing::debug!("📡 Data published to MQTT");
|
||||||
Json(json!({ "status": "success", "target": "mqtt" }))
|
Json(json!({ "status": "success", "target": "mqtt" }))
|
||||||
}
|
}
|
||||||
Err(e) => {
|
Ok(Err(e)) => {
|
||||||
// Si falla MQTT (desconexión), guardamos en SQLite local
|
// Error inmediato de MQTT
|
||||||
tracing::warn!("⚠️ MQTT offline. Saving to local storage: {}", e);
|
tracing::warn!("⚠️ MQTT error. Saving to local storage: {}", e);
|
||||||
if let Err(err) = state.storage.save(&topic, &data_str) {
|
if let Err(err) = state.storage.save(topic, &data_str) {
|
||||||
|
tracing::error!("❌ Fatal: Failed to save to local storage: {}", err);
|
||||||
|
Json(json!({ "status": "error", "message": "Storage failure" }))
|
||||||
|
} else {
|
||||||
|
Json(json!({ "status": "buffered", "target": "sqlite" }))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Err(_) => {
|
||||||
|
// Timeout (Buffer lleno o red colgada)
|
||||||
|
tracing::warn!("⏳ MQTT Timeout. Saving to local storage to unblock.");
|
||||||
|
if let Err(err) = state.storage.save(topic, &data_str) {
|
||||||
tracing::error!("❌ Fatal: Failed to save to local storage: {}", err);
|
tracing::error!("❌ Fatal: Failed to save to local storage: {}", err);
|
||||||
Json(json!({ "status": "error", "message": "Storage failure" }))
|
Json(json!({ "status": "error", "message": "Storage failure" }))
|
||||||
} else {
|
} else {
|
||||||
|
|||||||
@@ -1,320 +0,0 @@
|
|||||||
//! Docker Manager — Gestión del ciclo de vida de contenedores SCADA.
|
|
||||||
//! Usa bollard para inspect/stop y CLI de Docker para crear/iniciar contenedores
|
|
||||||
//! (máxima compatibilidad con Docker Desktop en Windows).
|
|
||||||
|
|
||||||
use bollard::Docker;
|
|
||||||
use bollard::query_parameters::{InspectContainerOptions, StopContainerOptions, RemoveContainerOptions};
|
|
||||||
use shared_lib::mqtt_messages::{DeploySignal, ContainerStatus};
|
|
||||||
use anyhow::{Result, Context, anyhow};
|
|
||||||
|
|
||||||
/// Estado del contenedor gestionado.
|
|
||||||
#[derive(Debug, Clone)]
|
|
||||||
pub struct ContainerState {
|
|
||||||
pub container_id: Option<String>,
|
|
||||||
pub current_image: Option<String>,
|
|
||||||
pub previous_image: Option<String>,
|
|
||||||
pub status: ContainerStatus,
|
|
||||||
pub restart_count: u32,
|
|
||||||
}
|
|
||||||
|
|
||||||
impl Default for ContainerState {
|
|
||||||
fn default() -> Self {
|
|
||||||
Self {
|
|
||||||
container_id: None,
|
|
||||||
current_image: None,
|
|
||||||
previous_image: None,
|
|
||||||
status: ContainerStatus::NotFound,
|
|
||||||
restart_count: 0,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Conecta al docker daemon (solo para inspect/stop).
|
|
||||||
pub fn connect_docker(socket_path: Option<&str>) -> Result<Docker> {
|
|
||||||
match socket_path {
|
|
||||||
Some(path) => Docker::connect_with_local(path, 120, bollard::API_DEFAULT_VERSION)
|
|
||||||
.context("Failed to connect with specified socket"),
|
|
||||||
Option::None => Docker::connect_with_socket_defaults()
|
|
||||||
.context("Failed to connect with default socket"),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Pull de una imagen usando el CLI de Docker (100% compatible con Windows).
|
|
||||||
pub async fn pull_image(
|
|
||||||
_docker: &Docker,
|
|
||||||
image_tag: &str,
|
|
||||||
) -> Result<()> {
|
|
||||||
tracing::info!("⬇️ Pulling image: {}", image_tag);
|
|
||||||
|
|
||||||
let output = tokio::process::Command::new("docker")
|
|
||||||
.args(["pull", image_tag])
|
|
||||||
.output()
|
|
||||||
.await
|
|
||||||
.context("Failed to execute 'docker pull'")?;
|
|
||||||
|
|
||||||
if !output.status.success() {
|
|
||||||
let stderr = String::from_utf8_lossy(&output.stderr);
|
|
||||||
return Err(anyhow!("docker pull failed for {}: {}", image_tag, stderr));
|
|
||||||
}
|
|
||||||
|
|
||||||
let stdout = String::from_utf8_lossy(&output.stdout);
|
|
||||||
for line in stdout.lines() {
|
|
||||||
tracing::debug!("Pull: {}", line);
|
|
||||||
}
|
|
||||||
|
|
||||||
tracing::info!("✅ Image pulled successfully: {}", image_tag);
|
|
||||||
Ok(())
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Detiene y remueve el contenedor SCADA.
|
|
||||||
pub async fn stop_container(
|
|
||||||
docker: &Docker,
|
|
||||||
container_name: &str,
|
|
||||||
) -> Result<()> {
|
|
||||||
tracing::info!("⏹️ Stopping container: {}", container_name);
|
|
||||||
|
|
||||||
match docker.stop_container(
|
|
||||||
container_name,
|
|
||||||
Some(StopContainerOptions { t: Some(30), signal: None }),
|
|
||||||
).await {
|
|
||||||
Ok(_) => {
|
|
||||||
tracing::info!("✅ Container stopped: {}", container_name);
|
|
||||||
}
|
|
||||||
Err(bollard::errors::Error::DockerResponseServerError { status_code: 304, .. }) => {
|
|
||||||
tracing::info!("Container already stopped: {}", container_name);
|
|
||||||
}
|
|
||||||
Err(bollard::errors::Error::DockerResponseServerError { status_code: 404, .. }) => {
|
|
||||||
tracing::info!("Container not found (skipping stop): {}", container_name);
|
|
||||||
}
|
|
||||||
Err(e) => {
|
|
||||||
return Err(anyhow!("Failed to stop container {}: {}", container_name, e));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
let _ = docker.remove_container(
|
|
||||||
container_name,
|
|
||||||
Some(RemoveContainerOptions { force: true, ..Default::default() }),
|
|
||||||
).await;
|
|
||||||
|
|
||||||
Ok(())
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Crea e inicia un contenedor usando el CLI de Docker.
|
|
||||||
/// Esto evita el bug de "expected value at line 1 column 1" de Bollard en Windows.
|
|
||||||
pub async fn start_container(
|
|
||||||
_docker: &Docker,
|
|
||||||
container_name: &str,
|
|
||||||
signal: &DeploySignal,
|
|
||||||
data_volume_path: &str,
|
|
||||||
) -> Result<String> {
|
|
||||||
tracing::info!("▶️ Starting container: {} with image: {}", container_name, signal.image_tag);
|
|
||||||
|
|
||||||
// Remover contenedor existente si quedo alguno
|
|
||||||
let _ = tokio::process::Command::new("docker")
|
|
||||||
.args(["rm", "-f", container_name])
|
|
||||||
.output()
|
|
||||||
.await;
|
|
||||||
|
|
||||||
let mut args: Vec<String> = vec![
|
|
||||||
"run".to_string(),
|
|
||||||
"-d".to_string(),
|
|
||||||
"--name".to_string(),
|
|
||||||
container_name.to_string(),
|
|
||||||
"--restart".to_string(),
|
|
||||||
"unless-stopped".to_string(),
|
|
||||||
"--add-host".to_string(),
|
|
||||||
"host.docker.internal:host-gateway".to_string(),
|
|
||||||
];
|
|
||||||
|
|
||||||
// Port mappings
|
|
||||||
for pm in &signal.port_mappings {
|
|
||||||
let protocol = pm.protocol.as_deref().unwrap_or("tcp");
|
|
||||||
args.push("-p".to_string());
|
|
||||||
args.push(format!("{}:{}/{}", pm.host_port, pm.container_port, protocol));
|
|
||||||
}
|
|
||||||
|
|
||||||
// Volumen principal de datos
|
|
||||||
let host_path = data_volume_path.replace("\\", "/");
|
|
||||||
args.push("-v".to_string());
|
|
||||||
args.push(format!("{}:/data", host_path));
|
|
||||||
|
|
||||||
// Volumenes adicionales del signal
|
|
||||||
for vm in &signal.volume_mounts {
|
|
||||||
let hp = vm.host_path.replace("\\", "/");
|
|
||||||
let cp = vm.container_path.replace("\\", "/");
|
|
||||||
let suffix = if vm.read_only.unwrap_or(false) { ":ro" } else { "" };
|
|
||||||
args.push("-v".to_string());
|
|
||||||
args.push(format!("{}:{}{}", hp, cp, suffix));
|
|
||||||
}
|
|
||||||
|
|
||||||
// Environment variables
|
|
||||||
for ev in &signal.env_vars {
|
|
||||||
args.push("-e".to_string());
|
|
||||||
args.push(format!("{}={}", ev.key, ev.value));
|
|
||||||
}
|
|
||||||
args.push("-e".to_string());
|
|
||||||
args.push("NODE_RED_ENABLE_PROJECTS=false".to_string());
|
|
||||||
|
|
||||||
// Labels
|
|
||||||
args.push("--label".to_string());
|
|
||||||
args.push("managed-by=omnioil-edge-agent".to_string());
|
|
||||||
args.push("--label".to_string());
|
|
||||||
args.push(format!("project-id={}", signal.project_id));
|
|
||||||
args.push("--label".to_string());
|
|
||||||
args.push(format!("deployment-id={}", signal.deployment_id));
|
|
||||||
|
|
||||||
// Imagen
|
|
||||||
args.push(signal.image_tag.clone());
|
|
||||||
|
|
||||||
let output = tokio::process::Command::new("docker")
|
|
||||||
.args(&args)
|
|
||||||
.output()
|
|
||||||
.await
|
|
||||||
.context("Failed to execute 'docker run'")?;
|
|
||||||
|
|
||||||
if !output.status.success() {
|
|
||||||
let stderr = String::from_utf8_lossy(&output.stderr);
|
|
||||||
return Err(anyhow!("docker run failed: {}", stderr.trim()));
|
|
||||||
}
|
|
||||||
|
|
||||||
let container_id = String::from_utf8_lossy(&output.stdout).trim().to_string();
|
|
||||||
Ok(container_id)
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Inspecciona el estado actual del contenedor.
|
|
||||||
pub async fn inspect_container(
|
|
||||||
docker: &Docker,
|
|
||||||
container_name: &str,
|
|
||||||
) -> Result<ContainerStatus> {
|
|
||||||
match docker.inspect_container(container_name, None::<InspectContainerOptions>).await {
|
|
||||||
Ok(info) => {
|
|
||||||
let state = info.state.as_ref();
|
|
||||||
let status = state.and_then(|s| s.status.as_ref());
|
|
||||||
let health = state.and_then(|s| s.health.as_ref())
|
|
||||||
.and_then(|h| h.status.as_ref());
|
|
||||||
|
|
||||||
let container_status = match status {
|
|
||||||
Some(bollard::models::ContainerStateStatusEnum::RUNNING) => {
|
|
||||||
match health {
|
|
||||||
Some(bollard::models::HealthStatusEnum::UNHEALTHY) => ContainerStatus::Unhealthy,
|
|
||||||
_ => ContainerStatus::Running,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
Some(bollard::models::ContainerStateStatusEnum::RESTARTING) => ContainerStatus::Restarting,
|
|
||||||
Some(bollard::models::ContainerStateStatusEnum::EXITED) |
|
|
||||||
Some(bollard::models::ContainerStateStatusEnum::DEAD) => ContainerStatus::Stopped,
|
|
||||||
_ => ContainerStatus::Stopped,
|
|
||||||
};
|
|
||||||
|
|
||||||
Ok(container_status)
|
|
||||||
}
|
|
||||||
Err(bollard::errors::Error::DockerResponseServerError { status_code: 404, .. }) => {
|
|
||||||
Ok(ContainerStatus::NotFound)
|
|
||||||
}
|
|
||||||
Err(e) => {
|
|
||||||
Err(anyhow!("Failed to inspect container {}: {}", container_name, e))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Obtiene el uptime del contenedor en segundos.
|
|
||||||
pub async fn get_container_uptime(
|
|
||||||
docker: &Docker,
|
|
||||||
container_name: &str,
|
|
||||||
) -> Option<u64> {
|
|
||||||
docker.inspect_container(container_name, None::<InspectContainerOptions>).await
|
|
||||||
.ok()
|
|
||||||
.and_then(|info| {
|
|
||||||
info.state.as_ref()
|
|
||||||
.and_then(|s| s.started_at.as_ref())
|
|
||||||
.and_then(|started| {
|
|
||||||
chrono::DateTime::parse_from_rfc3339(started).ok()
|
|
||||||
})
|
|
||||||
.map(|started| {
|
|
||||||
let now = chrono::Utc::now();
|
|
||||||
(now - started.with_timezone(&chrono::Utc)).num_seconds().max(0) as u64
|
|
||||||
})
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Ejecuta el ciclo completo de despliegue.
|
|
||||||
pub async fn deploy(
|
|
||||||
docker: &Docker,
|
|
||||||
container_name: &str,
|
|
||||||
signal: &DeploySignal,
|
|
||||||
data_volume_path: &str,
|
|
||||||
state: &mut ContainerState,
|
|
||||||
) -> Result<()> {
|
|
||||||
tracing::info!("🚀 Starting deployment for project: {}", signal.project_id);
|
|
||||||
|
|
||||||
state.previous_image = state.current_image.clone();
|
|
||||||
|
|
||||||
state.status = ContainerStatus::Pulling;
|
|
||||||
pull_image(docker, &signal.image_tag).await?;
|
|
||||||
|
|
||||||
stop_container(docker, container_name).await?;
|
|
||||||
|
|
||||||
let container_id = start_container(docker, container_name, signal, data_volume_path).await?;
|
|
||||||
|
|
||||||
state.container_id = Some(container_id);
|
|
||||||
state.current_image = Some(signal.image_tag.clone());
|
|
||||||
state.status = ContainerStatus::Running;
|
|
||||||
state.restart_count = 0;
|
|
||||||
|
|
||||||
// Limpieza de imágenes para ahorrar espacio (Requisito del usuario)
|
|
||||||
let _ = cleanup_images().await;
|
|
||||||
|
|
||||||
tracing::info!("✅ Deployment completed for project: {}", signal.project_id);
|
|
||||||
Ok(())
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Elimina imágenes huérfanas o antiguas para ahorrar espacio.
|
|
||||||
pub async fn cleanup_images() -> Result<()> {
|
|
||||||
tracing::info!("🧹 Limpiando imágenes de Docker no utilizadas...");
|
|
||||||
let output = tokio::process::Command::new("docker")
|
|
||||||
.args(["image", "prune", "-f"])
|
|
||||||
.output()
|
|
||||||
.await
|
|
||||||
.context("Error al ejecutar docker image prune")?;
|
|
||||||
|
|
||||||
if output.status.success() {
|
|
||||||
tracing::info!("✅ Limpieza de imágenes completada.");
|
|
||||||
}
|
|
||||||
Ok(())
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Rollback al contenedor anterior.
|
|
||||||
pub async fn rollback(
|
|
||||||
docker: &Docker,
|
|
||||||
container_name: &str,
|
|
||||||
state: &mut ContainerState,
|
|
||||||
original_signal: &DeploySignal,
|
|
||||||
data_volume_path: &str,
|
|
||||||
) -> Result<()> {
|
|
||||||
let previous_image = match &state.previous_image {
|
|
||||||
Some(img) => img.clone(),
|
|
||||||
None => {
|
|
||||||
return Err(anyhow!("No previous image for rollback"));
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
tracing::warn!("⏪ Rolling back to previous image: {}", previous_image);
|
|
||||||
|
|
||||||
stop_container(docker, container_name).await?;
|
|
||||||
|
|
||||||
let rollback_signal = DeploySignal {
|
|
||||||
image_tag: previous_image.clone(),
|
|
||||||
image_hash: "rollback".to_string(),
|
|
||||||
..original_signal.clone()
|
|
||||||
};
|
|
||||||
|
|
||||||
let container_id = start_container(docker, container_name, &rollback_signal, data_volume_path).await?;
|
|
||||||
|
|
||||||
state.container_id = Some(container_id);
|
|
||||||
state.current_image = Some(previous_image);
|
|
||||||
state.status = ContainerStatus::Running;
|
|
||||||
state.restart_count = 0;
|
|
||||||
|
|
||||||
tracing::info!("✅ Rollback completed");
|
|
||||||
Ok(())
|
|
||||||
}
|
|
||||||
35
services/edge-agent/src/drivers/ethernet_ip.rs
Normal file
35
services/edge-agent/src/drivers/ethernet_ip.rs
Normal file
@@ -0,0 +1,35 @@
|
|||||||
|
use super::ProtocolDriver;
|
||||||
|
use async_trait::async_trait;
|
||||||
|
use serde_json::Value;
|
||||||
|
use std::collections::HashMap;
|
||||||
|
|
||||||
|
pub struct EtherNetIpDriver {
|
||||||
|
_host: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl EtherNetIpDriver {
|
||||||
|
pub fn new(host: &str) -> Self {
|
||||||
|
Self {
|
||||||
|
_host: host.to_string(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[async_trait]
|
||||||
|
impl ProtocolDriver for EtherNetIpDriver {
|
||||||
|
async fn connect(&mut self) -> anyhow::Result<()> {
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn disconnect(&mut self) -> anyhow::Result<()> {
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn read_variables(&mut self, _variables: &HashMap<String, String>) -> anyhow::Result<HashMap<String, Value>> {
|
||||||
|
Ok(HashMap::new())
|
||||||
|
}
|
||||||
|
|
||||||
|
fn is_alive(&self) -> bool {
|
||||||
|
false
|
||||||
|
}
|
||||||
|
}
|
||||||
23
services/edge-agent/src/drivers/mod.rs
Normal file
23
services/edge-agent/src/drivers/mod.rs
Normal file
@@ -0,0 +1,23 @@
|
|||||||
|
use async_trait::async_trait;
|
||||||
|
use serde_json::Value;
|
||||||
|
use std::collections::HashMap;
|
||||||
|
|
||||||
|
#[async_trait]
|
||||||
|
pub trait ProtocolDriver: Send + Sync {
|
||||||
|
/// Conecta al dispositivo.
|
||||||
|
async fn connect(&mut self) -> anyhow::Result<()>;
|
||||||
|
|
||||||
|
/// Desconecta del dispositivo.
|
||||||
|
async fn disconnect(&mut self) -> anyhow::Result<()>;
|
||||||
|
|
||||||
|
/// Lee un conjunto de variables.
|
||||||
|
/// El map de entrada contiene "alias" -> "dirección/parámetros".
|
||||||
|
async fn read_variables(&mut self, variables: &HashMap<String, String>) -> anyhow::Result<HashMap<String, Value>>;
|
||||||
|
|
||||||
|
/// Verifica si el dispositivo está en línea.
|
||||||
|
fn is_alive(&self) -> bool;
|
||||||
|
}
|
||||||
|
|
||||||
|
pub mod modbus_tcp;
|
||||||
|
pub mod s7_comm;
|
||||||
|
pub mod ethernet_ip;
|
||||||
91
services/edge-agent/src/drivers/modbus_tcp.rs
Normal file
91
services/edge-agent/src/drivers/modbus_tcp.rs
Normal file
@@ -0,0 +1,91 @@
|
|||||||
|
use super::ProtocolDriver;
|
||||||
|
use async_trait::async_trait;
|
||||||
|
use serde_json::{json, Value};
|
||||||
|
use std::collections::HashMap;
|
||||||
|
use std::net::SocketAddr;
|
||||||
|
use std::sync::Arc;
|
||||||
|
use tokio_modbus::client::Context;
|
||||||
|
use tokio_modbus::prelude::*;
|
||||||
|
use tokio::sync::Mutex;
|
||||||
|
|
||||||
|
pub struct ModbusTcpDriver {
|
||||||
|
host: String,
|
||||||
|
port: u16,
|
||||||
|
unit_id: u8,
|
||||||
|
ctx: Option<Arc<Mutex<Context>>>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl ModbusTcpDriver {
|
||||||
|
pub fn new(host: &str, port: u16, unit_id: u8) -> Self {
|
||||||
|
Self {
|
||||||
|
host: host.to_string(),
|
||||||
|
port,
|
||||||
|
unit_id,
|
||||||
|
ctx: None,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[async_trait]
|
||||||
|
impl ProtocolDriver for ModbusTcpDriver {
|
||||||
|
async fn connect(&mut self) -> anyhow::Result<()> {
|
||||||
|
let socket_addr: SocketAddr = format!("{}:{}", self.host, self.port).parse()?;
|
||||||
|
let ctx = tcp::connect_slave(socket_addr, Slave(self.unit_id)).await?;
|
||||||
|
self.ctx = Some(Arc::new(Mutex::new(ctx)));
|
||||||
|
tracing::info!("✅ Connected to Modbus TCP device at {}:{}", self.host, self.port);
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn disconnect(&mut self) -> anyhow::Result<()> {
|
||||||
|
self.ctx = None;
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn read_variables(&mut self, variables: &HashMap<String, String>) -> anyhow::Result<HashMap<String, Value>> {
|
||||||
|
let ctx_mutex = self.ctx.as_ref().ok_or_else(|| anyhow::anyhow!("Not connected"))?;
|
||||||
|
let mut ctx = ctx_mutex.lock().await;
|
||||||
|
let mut results = HashMap::new();
|
||||||
|
|
||||||
|
for (alias, address) in variables {
|
||||||
|
let (area, addr_str) = if address.contains(':') {
|
||||||
|
let parts: Vec<&str> = address.split(':').collect();
|
||||||
|
(parts[0], parts[1])
|
||||||
|
} else {
|
||||||
|
("HR", address.as_str())
|
||||||
|
};
|
||||||
|
|
||||||
|
let addr: u16 = addr_str.parse().unwrap_or(0);
|
||||||
|
match area {
|
||||||
|
"HR" => {
|
||||||
|
match ctx.read_holding_registers(addr, 1).await {
|
||||||
|
Ok(Ok(registers)) => {
|
||||||
|
if let Some(v) = registers.first() {
|
||||||
|
results.insert(alias.clone(), json!(v));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Ok(Err(e)) => tracing::warn!("Modbus HR Exception: {} for addr {}", e, addr),
|
||||||
|
Err(e) => tracing::warn!("Modbus HR Transport error: {} for addr {}", e, addr),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
"IR" => {
|
||||||
|
match ctx.read_input_registers(addr, 1).await {
|
||||||
|
Ok(Ok(registers)) => {
|
||||||
|
if let Some(v) = registers.first() {
|
||||||
|
results.insert(alias.clone(), json!(v));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Ok(Err(e)) => tracing::warn!("Modbus IR Exception: {} for addr {}", e, addr),
|
||||||
|
Err(e) => tracing::warn!("Modbus IR Transport error: {} for addr {}", e, addr),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
_ => tracing::warn!("Unsupported Modbus area: {}", area),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Ok(results)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn is_alive(&self) -> bool {
|
||||||
|
self.ctx.is_some()
|
||||||
|
}
|
||||||
|
}
|
||||||
73
services/edge-agent/src/drivers/s7_comm.rs
Normal file
73
services/edge-agent/src/drivers/s7_comm.rs
Normal file
@@ -0,0 +1,73 @@
|
|||||||
|
use super::ProtocolDriver;
|
||||||
|
use async_trait::async_trait;
|
||||||
|
use serde_json::{json, Value};
|
||||||
|
use std::collections::HashMap;
|
||||||
|
use s7::client::Client;
|
||||||
|
use s7::tcp;
|
||||||
|
use s7::transport::Connection;
|
||||||
|
|
||||||
|
use std::net::IpAddr;
|
||||||
|
|
||||||
|
pub struct S7CommDriver {
|
||||||
|
host: String,
|
||||||
|
rack: u16,
|
||||||
|
slot: u16,
|
||||||
|
client: Option<Client<tcp::Transport>>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl S7CommDriver {
|
||||||
|
pub fn new(host: &str, rack: u16, slot: u16) -> Self {
|
||||||
|
Self {
|
||||||
|
host: host.to_string(),
|
||||||
|
rack,
|
||||||
|
slot,
|
||||||
|
client: None,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[async_trait]
|
||||||
|
impl ProtocolDriver for S7CommDriver {
|
||||||
|
async fn connect(&mut self) -> anyhow::Result<()> {
|
||||||
|
let addr: IpAddr = self.host.parse()?;
|
||||||
|
let options = tcp::Options::new(addr, self.rack, self.slot, Connection::PG);
|
||||||
|
let conn = tcp::Transport::connect(options)?;
|
||||||
|
let client = Client::new(conn)?;
|
||||||
|
self.client = Some(client);
|
||||||
|
tracing::info!("✅ Connected to S7 device at {}", self.host);
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn disconnect(&mut self) -> anyhow::Result<()> {
|
||||||
|
self.client = None;
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn read_variables(&mut self, variables: &HashMap<String, String>) -> anyhow::Result<HashMap<String, Value>> {
|
||||||
|
let client = self.client.as_mut().ok_or_else(|| anyhow::anyhow!("Not connected"))?;
|
||||||
|
let mut results = HashMap::new();
|
||||||
|
|
||||||
|
for (alias, address) in variables {
|
||||||
|
if address.starts_with("DB") {
|
||||||
|
let parts: Vec<&str> = address[2..].split('.').collect();
|
||||||
|
if parts.len() == 2 {
|
||||||
|
let db_number: i32 = parts[0].parse().unwrap_or(1);
|
||||||
|
if parts[1].starts_with("DBW") {
|
||||||
|
let offset: i32 = parts[1][3..].parse().unwrap_or(0);
|
||||||
|
let mut buffer = vec![0u8; 2];
|
||||||
|
if client.ag_read(db_number, offset, 2, &mut buffer).is_ok() {
|
||||||
|
let val = u16::from_be_bytes([buffer[0], buffer[1]]);
|
||||||
|
results.insert(alias.clone(), json!(val));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Ok(results)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn is_alive(&self) -> bool {
|
||||||
|
self.client.is_some()
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,176 +1,71 @@
|
|||||||
//! Health Checker — Monitoreo periódico del contenedor SCADA con auto-rollback.
|
//! Health Checker — Monitoreo periódico del Agente Nativo (sin Docker).
|
||||||
|
|
||||||
use bollard::Docker;
|
use shared_lib::mqtt_messages::{HealthReport, AgentStatus};
|
||||||
use shared_lib::mqtt_messages::{HealthReport, ContainerStatus};
|
|
||||||
use rumqttc::{AsyncClient, QoS};
|
use rumqttc::{AsyncClient, QoS};
|
||||||
use tokio::time::interval;
|
use tokio::time::interval;
|
||||||
use uuid::Uuid;
|
use uuid::Uuid;
|
||||||
use chrono::Utc;
|
use chrono::Utc;
|
||||||
|
use std::sync::Arc;
|
||||||
|
use tokio::sync::RwLock;
|
||||||
|
|
||||||
use super::docker_manager::{self, ContainerState};
|
|
||||||
use super::config::AgentConfig;
|
use super::config::AgentConfig;
|
||||||
|
|
||||||
/// Inicia el bucle de health check.
|
/// Versión del agente como string estático.
|
||||||
/// Verifica periódicamente el estado del contenedor y:
|
const AGENT_VERSION: &str = env!("CARGO_PKG_VERSION");
|
||||||
/// - Publica HealthReport via MQTT
|
|
||||||
/// - Si unhealthy: intenta reinicio
|
|
||||||
/// - Si falla N veces: ejecuta rollback
|
|
||||||
pub async fn start_health_checker(
|
pub async fn start_health_checker(
|
||||||
docker: Docker,
|
|
||||||
mqtt_client: AsyncClient,
|
mqtt_client: AsyncClient,
|
||||||
config: AgentConfig,
|
config: AgentConfig,
|
||||||
container_state: std::sync::Arc<tokio::sync::RwLock<ContainerState>>,
|
_log_reporter: Arc<super::log_reporter::LogReporter>,
|
||||||
last_deploy_signal: std::sync::Arc<tokio::sync::RwLock<Option<shared_lib::mqtt_messages::DeploySignal>>>,
|
|
||||||
log_reporter: std::sync::Arc<super::log_reporter::LogReporter>,
|
|
||||||
) {
|
) {
|
||||||
let check_interval = config.health_check_interval();
|
let check_interval = config.health_check_interval();
|
||||||
let max_retries = config.max_retries();
|
|
||||||
let container_name = config.container_name();
|
|
||||||
let project_id = config.agent.project_id.clone();
|
let project_id = config.agent.project_id.clone();
|
||||||
let hostname = config.hostname();
|
let hostname = config.hostname();
|
||||||
let data_volume = config.docker.data_volume_path.clone()
|
|
||||||
.unwrap_or_else(|| "/opt/omnioil/nodered-data".to_string());
|
// Pre-computar valores
|
||||||
|
let project_uuid = Uuid::parse_str(&project_id).unwrap_or_default();
|
||||||
|
let health_topic = format!("omnioil/health/{}", project_id);
|
||||||
|
let start_time = Utc::now();
|
||||||
|
|
||||||
let mut timer = interval(check_interval);
|
let mut timer = interval(check_interval);
|
||||||
let mut consecutive_failures: u32 = 0;
|
let mut json_buf = Vec::with_capacity(512);
|
||||||
|
|
||||||
tracing::info!("🏥 Health checker started (interval: {:?}, max retries: {})",
|
tracing::info!("🏥 Native Health checker started (interval: {:?})", check_interval);
|
||||||
check_interval, max_retries);
|
|
||||||
|
|
||||||
loop {
|
loop {
|
||||||
timer.tick().await;
|
timer.tick().await;
|
||||||
|
|
||||||
// Inspeccionar estado del contenedor
|
let uptime = Utc::now().signed_duration_since(start_time).num_seconds() as u64;
|
||||||
let status = match docker_manager::inspect_container(&docker, &container_name).await {
|
|
||||||
Ok(status) => status,
|
|
||||||
Err(e) => {
|
|
||||||
log_reporter.error("HEALTH", &format!("Failed to inspect: {}", e)).await;
|
|
||||||
ContainerStatus::Error
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
let uptime = docker_manager::get_container_uptime(&docker, &container_name).await;
|
// Construir reporte nativo
|
||||||
|
|
||||||
// Actualizar estado
|
|
||||||
{
|
|
||||||
let mut state = container_state.write().await;
|
|
||||||
state.status = status.clone();
|
|
||||||
}
|
|
||||||
|
|
||||||
let state = container_state.read().await;
|
|
||||||
|
|
||||||
// Publicar health report
|
|
||||||
let report = HealthReport {
|
let report = HealthReport {
|
||||||
project_id: Uuid::parse_str(&project_id).unwrap_or_default(),
|
project_id: project_uuid,
|
||||||
agent_hostname: hostname.clone(),
|
agent_hostname: hostname.clone(),
|
||||||
agent_version: Some(env!("CARGO_PKG_VERSION").to_string()),
|
agent_version: Some(AGENT_VERSION.to_string()),
|
||||||
container_status: status.clone(),
|
agent_status: AgentStatus::Online,
|
||||||
container_id: state.container_id.clone(),
|
container_status: None, // Deprecated
|
||||||
image_tag: state.current_image.clone().unwrap_or_default(),
|
container_id: None, // Deprecated
|
||||||
uptime_seconds: uptime,
|
image_tag: None, // Deprecated
|
||||||
cpu_usage_percent: None, // TODO: implementar via Docker stats
|
uptime_seconds: Some(uptime),
|
||||||
memory_usage_mb: None, // TODO: implementar via Docker stats
|
cpu_usage_percent: None,
|
||||||
restart_count: state.restart_count,
|
memory_usage_mb: None,
|
||||||
|
restart_count: 0,
|
||||||
last_error: None,
|
last_error: None,
|
||||||
timestamp: Utc::now(),
|
timestamp: Utc::now(),
|
||||||
};
|
};
|
||||||
drop(state);
|
|
||||||
|
|
||||||
// Publicar via MQTT
|
// Publicar via MQTT
|
||||||
let health_topic = format!("omnioil/health/{}", project_id);
|
json_buf.clear();
|
||||||
if let Ok(payload) = serde_json::to_string(&report) {
|
if serde_json::to_writer(&mut json_buf, &report).is_ok() {
|
||||||
let _ = mqtt_client
|
let mqtt = mqtt_client.clone();
|
||||||
.publish(&health_topic, QoS::AtMostOnce, false, payload.as_bytes())
|
let topic = health_topic.clone();
|
||||||
|
let payload = json_buf.clone();
|
||||||
|
tokio::spawn(async move {
|
||||||
|
let _ = mqtt
|
||||||
|
.publish(&topic, QoS::AtMostOnce, false, payload)
|
||||||
.await;
|
.await;
|
||||||
}
|
|
||||||
|
|
||||||
// Lógica de recuperación
|
|
||||||
match status {
|
|
||||||
ContainerStatus::Running => {
|
|
||||||
if consecutive_failures > 0 {
|
|
||||||
tracing::info!("✅ Container recovered after {} failures", consecutive_failures);
|
|
||||||
consecutive_failures = 0;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
ContainerStatus::Unhealthy | ContainerStatus::Stopped | ContainerStatus::Error => {
|
|
||||||
consecutive_failures += 1;
|
|
||||||
log_reporter.warn("HEALTH", &format!(
|
|
||||||
"Container unhealthy/stopped ({}/{}). Status: {:?}",
|
|
||||||
consecutive_failures, max_retries, status
|
|
||||||
)).await;
|
|
||||||
|
|
||||||
if consecutive_failures <= max_retries {
|
|
||||||
// Intentar reinicio
|
|
||||||
tracing::info!("🔄 Attempting restart ({}/{})", consecutive_failures, max_retries);
|
|
||||||
|
|
||||||
// Restart: stop + start con la misma imagen
|
|
||||||
let signal = last_deploy_signal.read().await;
|
|
||||||
if let Some(ref deploy_signal) = *signal {
|
|
||||||
let mut state = container_state.write().await;
|
|
||||||
if let Err(e) = docker_manager::deploy(
|
|
||||||
&docker,
|
|
||||||
&container_name,
|
|
||||||
deploy_signal,
|
|
||||||
&data_volume,
|
|
||||||
&mut state,
|
|
||||||
).await {
|
|
||||||
tracing::error!("Restart failed: {}", e);
|
|
||||||
} else {
|
|
||||||
state.restart_count += 1;
|
|
||||||
tracing::info!("✅ Container restarted (attempt {})", consecutive_failures);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
// Máximo de reintentos alcanzado → Rollback
|
|
||||||
tracing::error!(
|
|
||||||
"❌ Max retries ({}) exceeded. Initiating ROLLBACK!",
|
|
||||||
max_retries
|
|
||||||
);
|
|
||||||
|
|
||||||
let signal = last_deploy_signal.read().await;
|
|
||||||
if let Some(ref deploy_signal) = *signal {
|
|
||||||
let mut state = container_state.write().await;
|
|
||||||
match docker_manager::rollback(
|
|
||||||
&docker,
|
|
||||||
&container_name,
|
|
||||||
&mut state,
|
|
||||||
deploy_signal,
|
|
||||||
&data_volume,
|
|
||||||
).await {
|
|
||||||
Ok(_) => {
|
|
||||||
tracing::info!("✅ Rollback completed successfully");
|
|
||||||
consecutive_failures = 0;
|
|
||||||
|
|
||||||
// Notificar rollback via MQTT
|
|
||||||
let rollback_event = serde_json::json!({
|
|
||||||
"project_id": project_id,
|
|
||||||
"event": "ROLLBACK",
|
|
||||||
"previous_image": state.current_image,
|
|
||||||
"timestamp": Utc::now()
|
|
||||||
});
|
});
|
||||||
let log_topic = format!("telemetry/{}/logs", project_id);
|
tracing::debug!("📡 Native health report sent via MQTT for project: {}", project_id);
|
||||||
let _ = mqtt_client.publish(
|
|
||||||
&log_topic, QoS::AtLeastOnce, false,
|
|
||||||
serde_json::to_string(&rollback_event).unwrap_or_default().as_bytes(),
|
|
||||||
).await;
|
|
||||||
}
|
|
||||||
Err(e) => {
|
|
||||||
tracing::error!("❌ Rollback failed: {}. Manual intervention required!", e);
|
|
||||||
// Reset counter to avoid infinite rollback loop
|
|
||||||
consecutive_failures = 0;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
ContainerStatus::NotFound => {
|
|
||||||
// Contenedor no encontrado — probablemente primer inicio
|
|
||||||
tracing::debug!("Container not found (waiting for first deployment)");
|
|
||||||
}
|
|
||||||
ContainerStatus::Pulling | ContainerStatus::Restarting => {
|
|
||||||
// Operación en curso, no hacer nada
|
|
||||||
tracing::debug!("Container in transitional state: {:?}", status);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,8 +1,8 @@
|
|||||||
//! Log Reporter — Envía logs centralizados al servidor principal.
|
//! Log Reporter — Envía logs centralizados al servidor principal.
|
||||||
|
//! Pre-computa el topic MQTT una vez para evitar allocaciones repetidas.
|
||||||
|
|
||||||
use reqwest::Client;
|
|
||||||
use shared_lib::mqtt_messages::AgentLogEntry;
|
|
||||||
use rumqttc::{AsyncClient, QoS};
|
use rumqttc::{AsyncClient, QoS};
|
||||||
|
use shared_lib::mqtt_messages::AgentLogEntry;
|
||||||
use uuid::Uuid;
|
use uuid::Uuid;
|
||||||
use chrono::Utc;
|
use chrono::Utc;
|
||||||
|
|
||||||
@@ -11,8 +11,8 @@ pub struct LogReporter {
|
|||||||
project_id: Uuid,
|
project_id: Uuid,
|
||||||
hostname: String,
|
hostname: String,
|
||||||
mqtt_client: AsyncClient,
|
mqtt_client: AsyncClient,
|
||||||
http_client: Option<Client>,
|
/// Pre-computed MQTT topic (evita format!() en cada log call)
|
||||||
api_url: Option<String>,
|
log_topic: String,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl LogReporter {
|
impl LogReporter {
|
||||||
@@ -20,22 +20,16 @@ impl LogReporter {
|
|||||||
project_id: Uuid,
|
project_id: Uuid,
|
||||||
hostname: String,
|
hostname: String,
|
||||||
mqtt_client: AsyncClient,
|
mqtt_client: AsyncClient,
|
||||||
api_url: Option<String>,
|
_api_url: Option<String>,
|
||||||
_api_token: Option<String>,
|
_api_token: Option<String>,
|
||||||
) -> Self {
|
) -> Self {
|
||||||
let http_client = api_url.as_ref().and_then(|_| {
|
let log_topic = format!("omnioil/logs/{}", project_id);
|
||||||
Client::builder()
|
|
||||||
.timeout(std::time::Duration::from_secs(10))
|
|
||||||
.build()
|
|
||||||
.ok()
|
|
||||||
});
|
|
||||||
|
|
||||||
Self {
|
Self {
|
||||||
project_id,
|
project_id,
|
||||||
hostname,
|
hostname,
|
||||||
mqtt_client,
|
mqtt_client,
|
||||||
http_client,
|
log_topic,
|
||||||
api_url,
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -50,11 +44,14 @@ impl LogReporter {
|
|||||||
timestamp: Utc::now(),
|
timestamp: Utc::now(),
|
||||||
};
|
};
|
||||||
|
|
||||||
let topic = format!("omnioil/logs/{}", self.project_id);
|
|
||||||
if let Ok(payload) = serde_json::to_string(&entry) {
|
if let Ok(payload) = serde_json::to_string(&entry) {
|
||||||
let _ = self.mqtt_client
|
let mqtt = self.mqtt_client.clone();
|
||||||
.publish(&topic, QoS::AtMostOnce, false, payload.as_bytes())
|
let topic = self.log_topic.clone();
|
||||||
|
tokio::spawn(async move {
|
||||||
|
let _ = mqtt
|
||||||
|
.publish(&topic, QoS::AtLeastOnce, false, payload.as_bytes())
|
||||||
.await;
|
.await;
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
match level {
|
match level {
|
||||||
|
|||||||
@@ -1,10 +1,9 @@
|
|||||||
//! OmniOil Edge Agent
|
//! OmniOil Edge Agent v0.2.0 (Native)
|
||||||
//!
|
//!
|
||||||
//! Binario ligero que corre en dispositivos de borde (Edge Devices).
|
//! Binario ligero que corre en dispositivos de borde (Edge Devices) de forma nativa.
|
||||||
|
|
||||||
mod config;
|
mod config;
|
||||||
mod data_gateway;
|
mod data_gateway;
|
||||||
mod docker_manager;
|
|
||||||
mod health_checker;
|
mod health_checker;
|
||||||
mod ipc_protocol;
|
mod ipc_protocol;
|
||||||
mod ipc_server;
|
mod ipc_server;
|
||||||
@@ -15,54 +14,73 @@ mod service_manager;
|
|||||||
mod system_validator;
|
mod system_validator;
|
||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
mod tests;
|
mod tests;
|
||||||
|
mod drivers;
|
||||||
|
mod data_collector;
|
||||||
mod updater;
|
mod updater;
|
||||||
|
|
||||||
use anyhow::{Context, Result};
|
use anyhow::{Context, Result};
|
||||||
use config::AgentConfig;
|
use config::AgentConfig;
|
||||||
use docker_manager::ContainerState;
|
|
||||||
use log_reporter::LogReporter;
|
use log_reporter::LogReporter;
|
||||||
use mqtt_listener::AgentCommand;
|
use mqtt_listener::AgentCommand;
|
||||||
|
use std::path::Path;
|
||||||
use std::sync::Arc;
|
use std::sync::Arc;
|
||||||
use tokio::sync::{RwLock, mpsc};
|
use tokio::sync::mpsc;
|
||||||
use tracing_subscriber::{layer::SubscriberExt, util::SubscriberInitExt};
|
use tracing_subscriber::{Layer, layer::SubscriberExt, util::SubscriberInitExt};
|
||||||
use uuid::Uuid;
|
use uuid::Uuid;
|
||||||
|
|
||||||
const DEFAULT_CONFIG_PATH: &str = "agent.toml";
|
const DEFAULT_CONFIG_PATH: &str = "agent.toml";
|
||||||
const VERSION: &str = env!("CARGO_PKG_VERSION");
|
const VERSION: &str = env!("CARGO_PKG_VERSION");
|
||||||
|
|
||||||
#[tokio::main]
|
fn main() -> Result<()> {
|
||||||
async fn main() -> Result<()> {
|
|
||||||
// Inicializar tracing
|
// Inicializar tracing
|
||||||
|
let exe_path = std::env::current_exe().unwrap_or_default();
|
||||||
|
let base_path = exe_path.parent().unwrap_or(std::path::Path::new("."));
|
||||||
|
let log_dir = base_path.join("logs");
|
||||||
|
|
||||||
|
if let Err(e) = std::fs::create_dir_all(&log_dir) {
|
||||||
|
eprintln!("❌ Error creando directorio de logs: {}", e);
|
||||||
|
}
|
||||||
|
|
||||||
|
let file_appender = tracing_appender::rolling::daily(&log_dir, "edge-agent.log");
|
||||||
|
let (non_blocking, _guard) = tracing_appender::non_blocking(file_appender);
|
||||||
|
|
||||||
|
let console_layer = tracing_subscriber::fmt::layer().with_writer(std::io::stdout);
|
||||||
|
let file_layer = tracing_subscriber::fmt::layer()
|
||||||
|
.with_ansi(false)
|
||||||
|
.with_writer(non_blocking);
|
||||||
|
|
||||||
tracing_subscriber::registry()
|
tracing_subscriber::registry()
|
||||||
.with(
|
.with(
|
||||||
tracing_subscriber::EnvFilter::try_from_default_env()
|
tracing_subscriber::EnvFilter::try_from_default_env()
|
||||||
.unwrap_or_else(|_| "info,edge_agent=debug".into()),
|
.unwrap_or_else(|_| "debug,edge_agent=debug".into()),
|
||||||
)
|
)
|
||||||
.with(tracing_subscriber::fmt::layer())
|
.with(console_layer)
|
||||||
|
.with(file_layer.with_filter(tracing_subscriber::filter::LevelFilter::INFO))
|
||||||
.init();
|
.init();
|
||||||
|
|
||||||
|
tracing::info!("🚀 Starting OmniOil Edge Agent v{}", VERSION);
|
||||||
|
|
||||||
let args: Vec<String> = std::env::args().collect();
|
let args: Vec<String> = std::env::args().collect();
|
||||||
|
|
||||||
// Command: --generate-config
|
|
||||||
if args.contains(&"--generate-config".to_string()) {
|
if args.contains(&"--generate-config".to_string()) {
|
||||||
println!("{}", config::generate_example_config());
|
println!("{}", config::generate_example_config());
|
||||||
return Ok(());
|
return Ok(());
|
||||||
}
|
}
|
||||||
|
|
||||||
// Command: --check
|
|
||||||
if args.contains(&"--check".to_string()) {
|
if args.contains(&"--check".to_string()) {
|
||||||
|
let rt = tokio::runtime::Runtime::new()?;
|
||||||
|
rt.block_on(async {
|
||||||
let report = system_validator::validate_system().await;
|
let report = system_validator::validate_system().await;
|
||||||
system_validator::print_report(&report);
|
system_validator::print_report(&report);
|
||||||
|
});
|
||||||
return Ok(());
|
return Ok(());
|
||||||
}
|
}
|
||||||
|
|
||||||
// Command: --install
|
|
||||||
if args.contains(&"--install".to_string()) {
|
if args.contains(&"--install".to_string()) {
|
||||||
service_manager::install_service()?;
|
service_manager::install_service()?;
|
||||||
return Ok(());
|
return Ok(());
|
||||||
}
|
}
|
||||||
|
|
||||||
// Command: --uninstall
|
|
||||||
if args.contains(&"--uninstall".to_string()) {
|
if args.contains(&"--uninstall".to_string()) {
|
||||||
service_manager::uninstall_service()?;
|
service_manager::uninstall_service()?;
|
||||||
return Ok(());
|
return Ok(());
|
||||||
@@ -70,111 +88,64 @@ async fn main() -> Result<()> {
|
|||||||
|
|
||||||
#[cfg(windows)]
|
#[cfg(windows)]
|
||||||
if args.len() == 1 {
|
if args.len() == 1 {
|
||||||
// En Windows, si no hay argumentos, intentamos correr como servicio
|
|
||||||
if service_manager::run_service().is_ok() {
|
if service_manager::run_service().is_ok() {
|
||||||
return Ok(());
|
return Ok(());
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Default mode: Run as agent with config
|
|
||||||
let config_path = args
|
let config_path = args
|
||||||
.get(1)
|
.get(1)
|
||||||
.cloned()
|
.cloned()
|
||||||
.unwrap_or_else(|| DEFAULT_CONFIG_PATH.to_string());
|
.unwrap_or_else(|| DEFAULT_CONFIG_PATH.to_string());
|
||||||
|
|
||||||
let config = AgentConfig::load_default(std::path::Path::new(&config_path))
|
let config = AgentConfig::load_default(std::path::Path::new(&config_path))
|
||||||
.with_context(|| "Failed to load agent configuration (baked or external)")?;
|
.with_context(|| "Failed to load agent configuration")?;
|
||||||
|
|
||||||
run_agent(config).await
|
let rt = tokio::runtime::Runtime::new()?;
|
||||||
|
if let Err(e) = rt.block_on(run_agent(config)) {
|
||||||
|
tracing::error!("Fatal error: {}", e);
|
||||||
|
return Err(e);
|
||||||
|
}
|
||||||
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
pub async fn run_agent(config: AgentConfig) -> Result<()> {
|
pub async fn run_agent(config: AgentConfig) -> Result<()> {
|
||||||
let project_id = Uuid::parse_str(&config.agent.project_id)
|
let project_id = Uuid::parse_str(&config.agent.project_id)
|
||||||
.with_context(|| format!("Invalid project_id: {}", config.agent.project_id))?;
|
.with_context(|| format!("Invalid project_id: {}", config.agent.project_id))?;
|
||||||
|
|
||||||
tracing::info!("📋 Configuration loaded for project: {}", project_id);
|
// Validar sistema (Ligero)
|
||||||
|
|
||||||
// Validar sistema
|
|
||||||
let report = system_validator::validate_system().await;
|
let report = system_validator::validate_system().await;
|
||||||
if !report.docker_installed || !report.wsl_installed {
|
|
||||||
tracing::info!("⚠️ Requisitos faltantes detectados. Iniciando reparación automática...");
|
|
||||||
if let Err(e) = system_validator::fix_system(&report).await {
|
|
||||||
tracing::error!("❌ No se pudo reparar el sistema automáticamente: {}", e);
|
|
||||||
// En Windows, si falla la virt, mostramos error crítico
|
|
||||||
if cfg!(windows) && !report.virt_enabled {
|
|
||||||
eprintln!("\nCRITICAL ERROR: La virtualización está desactivada en la BIOS.");
|
|
||||||
eprintln!("Por favor habilítala para que Docker pueda funcionar.\n");
|
|
||||||
}
|
|
||||||
return Err(e);
|
|
||||||
}
|
|
||||||
// Re-validar después de la reparación
|
|
||||||
let new_report = system_validator::validate_system().await;
|
|
||||||
if !new_report.docker_installed {
|
|
||||||
return Err(anyhow::anyhow!(
|
|
||||||
"Instalación de Docker fallida o requiere reinicio."
|
|
||||||
));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if !report.errors.is_empty() {
|
if !report.errors.is_empty() {
|
||||||
for err in &report.errors {
|
for err in &report.errors {
|
||||||
tracing::warn!("Chequeo de sistema: {}", err);
|
tracing::warn!("Sistema: {}", err);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Estado compartido
|
// Configuración MQTT con reintentos
|
||||||
let container_state = Arc::new(RwLock::new(ContainerState::default()));
|
|
||||||
let last_deploy_signal = Arc::new(RwLock::new(None::<shared_lib::mqtt_messages::DeploySignal>));
|
|
||||||
|
|
||||||
// Canal para comandos
|
|
||||||
let (cmd_tx, mut cmd_rx) = mpsc::channel::<AgentCommand>(16);
|
|
||||||
|
|
||||||
// Conectar al Docker daemon con reintentos (importante para arranque tras reinicio del PC)
|
|
||||||
let mut docker_client = None;
|
|
||||||
let mut retries = 0;
|
|
||||||
const MAX_RETRIES: u32 = 60; // 10 minutos aprox (10s * 60)
|
|
||||||
|
|
||||||
while retries < MAX_RETRIES {
|
|
||||||
let dc = docker_manager::connect_docker(config.docker.socket_path.as_deref());
|
|
||||||
match dc {
|
|
||||||
Ok(client) => {
|
|
||||||
if client.version().await.is_ok() {
|
|
||||||
docker_client = Some(client);
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
// Si el cliente conecta pero el daemon no responde, intentamos iniciar
|
|
||||||
let _ = system_validator::try_start_docker_daemon().await;
|
|
||||||
}
|
|
||||||
Err(_) => {
|
|
||||||
tracing::warn!("⏳ Esperando a Docker (intento {}/{})...", retries + 1, MAX_RETRIES);
|
|
||||||
let _ = system_validator::try_start_docker_daemon().await;
|
|
||||||
tokio::time::sleep(std::time::Duration::from_secs(10)).await;
|
|
||||||
retries += 1;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
let docker = docker_client.context("No se pudo conectar a Docker tras múltiples intentos. Verifica que Docker Desktop esté configurado para iniciar con Windows.")?;
|
|
||||||
|
|
||||||
// Conectar MQTT con reintentos
|
|
||||||
let mut mqtt_result = None;
|
let mut mqtt_result = None;
|
||||||
retries = 0;
|
let mut retries = 0;
|
||||||
|
const MAX_RETRIES: u32 = 60;
|
||||||
|
|
||||||
while retries < MAX_RETRIES {
|
while retries < MAX_RETRIES {
|
||||||
match mqtt_listener::start_mqtt_listener(&config, cmd_tx.clone()).await {
|
match mqtt_listener::setup_mqtt_client(&config).await {
|
||||||
Ok(res) => {
|
Ok(res) => {
|
||||||
mqtt_result = Some(res);
|
mqtt_result = Some(res);
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
Err(e) => {
|
Err(e) => {
|
||||||
tracing::warn!("⏳ Esperando red/MQTT (intento {}/{}): {}", retries + 1, MAX_RETRIES, e);
|
tracing::warn!("⏳ Esperando red/MQTT ({}): {}", retries + 1, e);
|
||||||
tokio::time::sleep(std::time::Duration::from_secs(10)).await;
|
tokio::time::sleep(std::time::Duration::from_secs(10)).await;
|
||||||
retries += 1;
|
retries += 1;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
let (mqtt_client, _mqtt_handle) = mqtt_result.context("No se pudo conectar al servidor MQTT tras múltiples intentos.")?;
|
let (mqtt_client, mqtt_eventloop) =
|
||||||
|
mqtt_result.context("No se pudo conectar a MQTT.")?;
|
||||||
|
|
||||||
// Iniciar Log Reporter
|
// Iniciar Log Reporter
|
||||||
let (api_url, api_token) = config.server.as_ref()
|
let (api_url, api_token) = config
|
||||||
|
.server
|
||||||
|
.as_ref()
|
||||||
.map(|s| (s.api_url.clone(), s.api_token.clone()))
|
.map(|s| (s.api_url.clone(), s.api_token.clone()))
|
||||||
.unwrap_or((None, None));
|
.unwrap_or((None, None));
|
||||||
|
|
||||||
@@ -186,100 +157,83 @@ pub async fn run_agent(config: AgentConfig) -> Result<()> {
|
|||||||
api_token,
|
api_token,
|
||||||
));
|
));
|
||||||
|
|
||||||
log_reporter
|
// Canal para comandos
|
||||||
.info("SYSTEM", &format!("Edge Agent v{} started", VERSION))
|
let (cmd_tx, mut cmd_rx) = mpsc::channel::<AgentCommand>(16);
|
||||||
.await;
|
|
||||||
|
|
||||||
// Inicializar Persistencia Local (Store & Forward)
|
// Iniciar el bucle de eventos MQTT
|
||||||
let db_path = config.docker.data_volume_path.clone().unwrap_or_else(|| "C:/ProgramData/OmniOil/data".to_string());
|
let _mqtt_handle = mqtt_listener::start_mqtt_event_loop(
|
||||||
let db_file = std::path::Path::new(&db_path).join("agent_storage.db");
|
&config,
|
||||||
let storage = Arc::new(persistence_manager::StoreAndForward::new(db_file.to_str().unwrap())?);
|
mqtt_client.clone(),
|
||||||
|
mqtt_eventloop,
|
||||||
|
cmd_tx.clone(),
|
||||||
|
log_reporter.clone(),
|
||||||
|
);
|
||||||
|
|
||||||
|
log_reporter.info("SYSTEM", &format!("Edge Agent v{} (Native) started", VERSION)).await;
|
||||||
|
|
||||||
|
// Persistencia Local (Store & Forward)
|
||||||
|
let db_path = if cfg!(windows) { "C:/ProgramData/OmniOil/data" } else { "/var/lib/omnioil" };
|
||||||
|
let db_file = std::path::Path::new(db_path).join("agent_storage.db");
|
||||||
|
|
||||||
|
// Crear directorio de datos si no existe
|
||||||
|
if let Some(parent) = db_file.parent() {
|
||||||
|
let _ = std::fs::create_dir_all(parent);
|
||||||
|
}
|
||||||
|
|
||||||
|
let storage = Arc::new(persistence_manager::StoreAndForward::new(
|
||||||
|
db_file.to_str().unwrap(),
|
||||||
|
config.agent.master_secret.as_deref(),
|
||||||
|
)?);
|
||||||
|
|
||||||
// Canal para IPC (Tray Icon)
|
// Canal para IPC (Tray Icon)
|
||||||
let (ipc_tx, _) = tokio::sync::broadcast::channel::<ipc_protocol::IpcMessage>(32);
|
let (ipc_tx, _) = tokio::sync::broadcast::channel::<ipc_protocol::IpcMessage>(32);
|
||||||
let ipc_rx = ipc_tx.subscribe();
|
let ipc_rx = ipc_tx.subscribe();
|
||||||
|
|
||||||
// Iniciar servidor IPC
|
|
||||||
tokio::spawn(async move {
|
tokio::spawn(async move {
|
||||||
if let Err(e) = ipc_server::start_ipc_server(ipc_rx).await {
|
let _ = ipc_server::start_ipc_server(ipc_rx).await;
|
||||||
tracing::error!("❌ Error en servidor IPC: {}", e);
|
|
||||||
}
|
|
||||||
});
|
});
|
||||||
|
|
||||||
// Enviar primer estado
|
// Iniciar Health Checker Nativo
|
||||||
let _ = ipc_tx.send(ipc_protocol::IpcMessage::StatusUpdate {
|
|
||||||
status: "Iniciando...".to_string(),
|
|
||||||
connected_mqtt: false,
|
|
||||||
});
|
|
||||||
|
|
||||||
// Iniciar Health Checker
|
|
||||||
let health_docker = docker.clone();
|
|
||||||
let health_mqtt = mqtt_client.clone();
|
let health_mqtt = mqtt_client.clone();
|
||||||
let health_config = config.clone();
|
let health_config = config.clone();
|
||||||
let health_state = container_state.clone();
|
|
||||||
let health_signal = last_deploy_signal.clone();
|
|
||||||
let health_reporter = log_reporter.clone();
|
let health_reporter = log_reporter.clone();
|
||||||
tokio::spawn(async move {
|
tokio::spawn(async move {
|
||||||
health_checker::start_health_checker(
|
health_checker::start_health_checker(
|
||||||
health_docker,
|
|
||||||
health_mqtt,
|
health_mqtt,
|
||||||
health_config,
|
health_config,
|
||||||
health_state,
|
|
||||||
health_signal,
|
|
||||||
health_reporter,
|
health_reporter,
|
||||||
)
|
)
|
||||||
.await;
|
.await;
|
||||||
});
|
});
|
||||||
|
|
||||||
// Iniciar Updater
|
// Iniciar Data Gateway
|
||||||
let updater_config = config.clone();
|
|
||||||
tokio::spawn(async move {
|
|
||||||
if let Err(e) = updater::run_updater(updater_config).await {
|
|
||||||
tracing::error!("Updater error: {}", e);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
// Iniciar Data Gateway (Intake local para Node-RED)
|
|
||||||
let gateway_mqtt = mqtt_client.clone();
|
let gateway_mqtt = mqtt_client.clone();
|
||||||
let gateway_project_id = project_id;
|
|
||||||
let gateway_storage = storage.clone();
|
let gateway_storage = storage.clone();
|
||||||
// Usamos el puerto 8081 por defecto si no esta configurado
|
|
||||||
let gateway_port = 8081;
|
|
||||||
tokio::spawn(async move {
|
tokio::spawn(async move {
|
||||||
if let Err(e) =
|
let _ = data_gateway::start_data_gateway(8081, gateway_mqtt, project_id, gateway_storage).await;
|
||||||
data_gateway::start_data_gateway(gateway_port, gateway_mqtt, gateway_project_id, gateway_storage).await
|
|
||||||
{
|
|
||||||
tracing::error!("Data Gateway error: {}", e);
|
|
||||||
}
|
|
||||||
});
|
});
|
||||||
|
|
||||||
let data_volume = config
|
// Iniciar Manager de Colector de Datos (Fase 2)
|
||||||
.docker
|
let collector_manager = Arc::new(data_collector::DataCollectorManager::new(
|
||||||
.data_volume_path
|
mqtt_client.clone(),
|
||||||
.clone()
|
project_id,
|
||||||
.unwrap_or_else(|| "nodered-data".to_string());
|
storage.clone(),
|
||||||
let container_name = config.container_name();
|
));
|
||||||
|
|
||||||
loop {
|
loop {
|
||||||
tokio::select! {
|
tokio::select! {
|
||||||
Some(cmd) = cmd_rx.recv() => {
|
Some(cmd) = cmd_rx.recv() => {
|
||||||
match cmd {
|
match cmd {
|
||||||
AgentCommand::Deploy(signal) => {
|
AgentCommand::Deploy(signal) => {
|
||||||
let _ = ipc_tx.send(ipc_protocol::IpcMessage::StatusUpdate {
|
log_reporter.info("DEPLOY", &format!("Config update received: {}. Full Signal: {:?}", signal.deployment_id, signal)).await;
|
||||||
status: format!("Desplegando... {}", signal.image_tag),
|
|
||||||
connected_mqtt: true,
|
// Actualizar configuración del colector si hay dispositivos definidos
|
||||||
|
if let Some(devices) = signal.devices {
|
||||||
|
let manager = collector_manager.clone();
|
||||||
|
let reporter = log_reporter.clone();
|
||||||
|
tokio::spawn(async move {
|
||||||
|
manager.update_config(devices, reporter).await;
|
||||||
});
|
});
|
||||||
log_reporter.log_deploy(&signal.image_tag, "RECEIVED").await;
|
|
||||||
{
|
|
||||||
let mut sig = last_deploy_signal.write().await;
|
|
||||||
*sig = Some(signal.clone());
|
|
||||||
}
|
|
||||||
let mut state = container_state.write().await;
|
|
||||||
if let Err(e) = docker_manager::deploy(&docker, &container_name, &signal, &data_volume, &mut state).await {
|
|
||||||
log_reporter.error("DEPLOY", &format!("Deployment failed: {}", e)).await;
|
|
||||||
log_reporter.log_deploy(&signal.image_tag, "FAILED").await;
|
|
||||||
} else {
|
|
||||||
log_reporter.log_deploy(&signal.image_tag, "COMPLETED").await;
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
AgentCommand::Reconnected => {
|
AgentCommand::Reconnected => {
|
||||||
@@ -287,7 +241,6 @@ pub async fn run_agent(config: AgentConfig) -> Result<()> {
|
|||||||
status: "Conectado".to_string(),
|
status: "Conectado".to_string(),
|
||||||
connected_mqtt: true,
|
connected_mqtt: true,
|
||||||
});
|
});
|
||||||
log_reporter.info("MQTT", "Reconnected").await;
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -4,7 +4,10 @@ use rumqttc::{AsyncClient, MqttOptions, EventLoop, Event, Packet, QoS};
|
|||||||
use shared_lib::mqtt_messages::DeploySignal;
|
use shared_lib::mqtt_messages::DeploySignal;
|
||||||
use tokio::sync::mpsc;
|
use tokio::sync::mpsc;
|
||||||
use std::time::Duration;
|
use std::time::Duration;
|
||||||
use anyhow::{Result, Context};
|
use anyhow::Result;
|
||||||
|
|
||||||
|
use std::sync::Arc;
|
||||||
|
use crate::log_reporter::LogReporter;
|
||||||
|
|
||||||
/// Canal de comandos del listener hacia el manager principal.
|
/// Canal de comandos del listener hacia el manager principal.
|
||||||
pub enum AgentCommand {
|
pub enum AgentCommand {
|
||||||
@@ -12,72 +15,76 @@ pub enum AgentCommand {
|
|||||||
Reconnected,
|
Reconnected,
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Inicia el cliente MQTT y escucha señales de despliegue.
|
/// Configura el cliente MQTT pero no inicia el bucle aún.
|
||||||
pub async fn start_mqtt_listener(
|
pub async fn setup_mqtt_client(
|
||||||
config: &super::config::AgentConfig,
|
config: &super::config::AgentConfig,
|
||||||
tx: mpsc::Sender<AgentCommand>,
|
) -> Result<(AsyncClient, EventLoop)> {
|
||||||
) -> Result<(AsyncClient, tokio::task::JoinHandle<()>)> {
|
|
||||||
let project_id = &config.agent.project_id;
|
let project_id = &config.agent.project_id;
|
||||||
let client_id = config.mqtt.client_id.clone()
|
let client_id = config.mqtt.client_id.clone()
|
||||||
.unwrap_or_else(|| format!("omnioil-agent-{}", &project_id[..8]));
|
.unwrap_or_else(|| format!("omnioil-agent-{}", &project_id[..8]));
|
||||||
|
|
||||||
let mqtt_port = config.mqtt.port.unwrap_or(1883);
|
let mqtt_port = config.mqtt.port.unwrap_or(1883);
|
||||||
|
let host = &config.mqtt.host;
|
||||||
|
let use_ws = config.mqtt.use_ws.unwrap_or(false);
|
||||||
|
let use_tls = mqtt_port == 443 || config.mqtt.use_tls.unwrap_or(false);
|
||||||
|
|
||||||
|
let final_host = if use_ws {
|
||||||
|
let scheme = if use_tls { "wss" } else { "ws" };
|
||||||
|
format!("{}://{}:{}/mqtt", scheme, host, mqtt_port)
|
||||||
|
} else {
|
||||||
|
host.to_string()
|
||||||
|
};
|
||||||
|
|
||||||
|
let mut mqtt_options = MqttOptions::new(&client_id, final_host, mqtt_port);
|
||||||
|
|
||||||
|
// Configurar el transporte (TCP, TLS, WS o WSS) según la versión 0.25 de rumqttc
|
||||||
|
if use_ws {
|
||||||
|
if use_tls {
|
||||||
|
tracing::info!("🌐 MQTT via WSS (Secure WebSockets): {}:{}", host, mqtt_port);
|
||||||
|
mqtt_options.set_transport(rumqttc::Transport::wss_with_default_config());
|
||||||
|
} else {
|
||||||
|
tracing::info!("🌐 MQTT via WS (WebSockets): {}:{}", host, mqtt_port);
|
||||||
|
mqtt_options.set_transport(rumqttc::Transport::ws());
|
||||||
|
}
|
||||||
|
} else if use_tls {
|
||||||
|
tracing::info!("🔒 MQTT via TLS (TCP): {}:{}", host, mqtt_port);
|
||||||
|
mqtt_options.set_transport(rumqttc::Transport::tls_with_default_config());
|
||||||
|
} else {
|
||||||
|
tracing::info!("🔌 MQTT via TCP (Plain): {}:{}", host, mqtt_port);
|
||||||
|
}
|
||||||
|
|
||||||
let mut mqtt_options = MqttOptions::new(
|
|
||||||
&client_id,
|
|
||||||
&config.mqtt.host,
|
|
||||||
mqtt_port,
|
|
||||||
);
|
|
||||||
mqtt_options.set_keep_alive(Duration::from_secs(30));
|
mqtt_options.set_keep_alive(Duration::from_secs(30));
|
||||||
mqtt_options.set_clean_session(true);
|
mqtt_options.set_clean_session(true);
|
||||||
|
|
||||||
if let (Some(u), Some(p)) = (&config.mqtt.username, &config.mqtt.password) {
|
if let (Some(u), Some(p)) = (&config.mqtt.username, &config.mqtt.password) {
|
||||||
|
tracing::info!("🔑 MQTT Auth: User='{}'", u);
|
||||||
mqtt_options.set_credentials(u, p);
|
mqtt_options.set_credentials(u, p);
|
||||||
}
|
}
|
||||||
|
|
||||||
// --- CONFIGURACIÓN LAST WILL (Última Voluntad) ---
|
let (client, eventloop) = AsyncClient::new(mqtt_options, 32);
|
||||||
// Si el agente pierde conexión contra el broker de forma abrupta,
|
Ok((client, eventloop))
|
||||||
// el Broker publicará este mensaje automáticamente.
|
}
|
||||||
let lwt_topic = format!("omnioil/health/{}", project_id);
|
|
||||||
let lwt_payload = serde_json::json!({
|
|
||||||
"project_id": project_id,
|
|
||||||
"agent_hostname": config.hostname(),
|
|
||||||
"container_status": "Offline",
|
|
||||||
"timestamp": chrono::Utc::now(),
|
|
||||||
"last_error": "MQTT Connection lost (LWT)"
|
|
||||||
});
|
|
||||||
|
|
||||||
mqtt_options.set_last_will(rumqttc::LastWill::new(
|
|
||||||
lwt_topic,
|
|
||||||
lwt_payload.to_string(),
|
|
||||||
rumqttc::QoS::AtLeastOnce,
|
|
||||||
true // Retenido! Para que el dashboard sepa el estado aunque refresque
|
|
||||||
));
|
|
||||||
|
|
||||||
// Configurar transporte WSS si el puerto es 443 o se solicita explícitamente
|
/// Inicia el bucle de escucha de señales de despliegue.
|
||||||
if config.mqtt.use_ws.unwrap_or(false) || mqtt_port == 443 {
|
pub fn start_mqtt_event_loop(
|
||||||
tracing::info!("🌐 Using WebSockets (WSS) for MQTT connection");
|
config: &super::config::AgentConfig,
|
||||||
mqtt_options.set_transport(rumqttc::Transport::Wss(rumqttc::TlsConfiguration::default().into()));
|
client: AsyncClient,
|
||||||
}
|
eventloop: EventLoop,
|
||||||
|
tx: mpsc::Sender<AgentCommand>,
|
||||||
let (client, eventloop) = AsyncClient::new(mqtt_options, 10);
|
log_reporter: Arc<LogReporter>,
|
||||||
|
) -> tokio::task::JoinHandle<()> {
|
||||||
// Suscribirse al topic de deploy para este proyecto
|
let project_id = &config.agent.project_id;
|
||||||
let deploy_topic = format!("omnioil/deploy/{}", project_id);
|
let deploy_topic = format!("omnioil/deploy/{}", project_id);
|
||||||
client.subscribe(&deploy_topic, QoS::AtLeastOnce).await
|
tokio::spawn(mqtt_event_loop(client, eventloop, tx, deploy_topic, log_reporter))
|
||||||
.with_context(|| format!("Failed to subscribe to {}", deploy_topic))?;
|
|
||||||
|
|
||||||
tracing::info!("📡 Subscribed to MQTT topic: {}", deploy_topic);
|
|
||||||
|
|
||||||
let handle = tokio::spawn(mqtt_event_loop(eventloop, tx, deploy_topic));
|
|
||||||
|
|
||||||
Ok((client, handle))
|
|
||||||
}
|
}
|
||||||
|
|
||||||
async fn mqtt_event_loop(
|
async fn mqtt_event_loop(
|
||||||
|
client: AsyncClient,
|
||||||
mut eventloop: EventLoop,
|
mut eventloop: EventLoop,
|
||||||
tx: mpsc::Sender<AgentCommand>,
|
tx: mpsc::Sender<AgentCommand>,
|
||||||
deploy_topic: String,
|
deploy_topic: String,
|
||||||
|
log_reporter: Arc<LogReporter>,
|
||||||
) {
|
) {
|
||||||
loop {
|
loop {
|
||||||
match eventloop.poll().await {
|
match eventloop.poll().await {
|
||||||
@@ -85,30 +92,32 @@ async fn mqtt_event_loop(
|
|||||||
let topic = &publish.topic;
|
let topic = &publish.topic;
|
||||||
let payload = &publish.payload;
|
let payload = &publish.payload;
|
||||||
|
|
||||||
|
tracing::debug!("📥 MQTT Incoming [TOPIC: {}] [LEN: {}]", topic, payload.len());
|
||||||
|
|
||||||
if topic == &deploy_topic {
|
if topic == &deploy_topic {
|
||||||
match serde_json::from_slice::<DeploySignal>(payload) {
|
match serde_json::from_slice::<DeploySignal>(payload) {
|
||||||
Ok(signal) => {
|
Ok(signal) => {
|
||||||
tracing::info!(
|
log_reporter.info("DEPLOY", &format!("Deploy signal received! Image: {} Hash: {}", signal.image_tag, signal.image_hash)).await;
|
||||||
"🚀 Deploy signal received! Image: {} Hash: {}",
|
|
||||||
signal.image_tag,
|
|
||||||
signal.image_hash
|
|
||||||
);
|
|
||||||
if let Err(e) = tx.send(AgentCommand::Deploy(signal)).await {
|
if let Err(e) = tx.send(AgentCommand::Deploy(signal)).await {
|
||||||
tracing::error!("Failed to send deploy command: {}", e);
|
log_reporter.error("SYSTEM", &format!("Failed to send deploy command to main task: {}", e)).await;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
Err(e) => {
|
Err(e) => {
|
||||||
tracing::error!(
|
log_reporter.error("DEPLOY", &format!("Failed to parse deploy signal JSON: {} | Topic: {}", e, topic)).await;
|
||||||
"Failed to parse deploy signal: {} | Payload: {:?}",
|
|
||||||
e,
|
|
||||||
String::from_utf8_lossy(payload)
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
Ok(Event::Incoming(Packet::ConnAck(_))) => {
|
Ok(Event::Incoming(Packet::ConnAck(_))) => {
|
||||||
tracing::info!("✅ MQTT connected/reconnected");
|
log_reporter.info("MQTT", &format!("Subscribing to: {}", deploy_topic)).await;
|
||||||
|
|
||||||
|
// Suscribirse explícitamente en cada conexión ya que usamos clean_session = true
|
||||||
|
if let Err(e) = client.subscribe(&deploy_topic, QoS::AtLeastOnce).await {
|
||||||
|
log_reporter.error("MQTT", &format!("Failed to re-subscribe to {}: {}", deploy_topic, e)).await;
|
||||||
|
} else {
|
||||||
|
log_reporter.info("MQTT", &format!("Subscribed to MQTT topic: {}", deploy_topic)).await;
|
||||||
|
}
|
||||||
|
|
||||||
let _ = tx.send(AgentCommand::Reconnected).await;
|
let _ = tx.send(AgentCommand::Reconnected).await;
|
||||||
}
|
}
|
||||||
Ok(_) => {}
|
Ok(_) => {}
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
//! Persistence Manager — Almacenamiento local (Store & Forward) para telemetría.
|
//! Persistence Manager — Almacenamiento local (Store & Forward) para telemetría.
|
||||||
//! Permite que el agente guarde datos en SQLite de forma ENCRIPTADA.
|
//! Permite que el agente guarde datos en SQLite de forma ENCRIPTADA.
|
||||||
|
//! Usa una conexión persistente con Mutex para evitar overhead de apertura/cierre.
|
||||||
|
|
||||||
use aes_gcm::{
|
use aes_gcm::{
|
||||||
Aes256Gcm, Nonce,
|
Aes256Gcm, Nonce,
|
||||||
@@ -12,17 +13,19 @@ use rand::RngCore;
|
|||||||
use rumqttc::{AsyncClient, QoS};
|
use rumqttc::{AsyncClient, QoS};
|
||||||
use rusqlite::{Connection, params};
|
use rusqlite::{Connection, params};
|
||||||
use sha2::Sha256;
|
use sha2::Sha256;
|
||||||
|
use std::sync::Mutex;
|
||||||
|
|
||||||
const SALT: &[u8; 15] = b"omnioil_db_salt";
|
const SALT: &[u8; 15] = b"omnioil_db_salt";
|
||||||
|
|
||||||
pub struct StoreAndForward {
|
pub struct StoreAndForward {
|
||||||
db_path: String,
|
conn: Mutex<Connection>,
|
||||||
cipher: Aes256Gcm,
|
cipher: Aes256Gcm,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl StoreAndForward {
|
impl StoreAndForward {
|
||||||
/// Inicializa la base de datos y prepara el cifrado AES-256
|
/// Inicializa la base de datos y prepara el cifrado AES-256.
|
||||||
pub fn new(db_path: &str) -> Result<Self> {
|
/// Mantiene una conexión persistente con WAL mode para rendimiento óptimo.
|
||||||
|
pub fn new(db_path: &str, config_secret: Option<&str>) -> Result<Self> {
|
||||||
// 1. Asegurar carpeta
|
// 1. Asegurar carpeta
|
||||||
if let Some(parent) = std::path::Path::new(db_path).parent() {
|
if let Some(parent) = std::path::Path::new(db_path).parent() {
|
||||||
if !parent.exists() {
|
if !parent.exists() {
|
||||||
@@ -31,16 +34,31 @@ impl StoreAndForward {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// 2. Preparar Cifrado
|
// 2. Preparar Cifrado
|
||||||
let master_secret = std::env::var("OMNIOIL_MASTER_SECRET")
|
// Prioridad: 1. ENV OMNIOIL_MASTER_SECRET, 2. Config master_secret, 3. Fallback (Project ID as salt)
|
||||||
.unwrap_or_else(|_| "dev_master_secret_omnioil_2026".to_string());
|
let master_secret = std::env::var("OMNIOIL_MASTER_SECRET").ok()
|
||||||
|
.or_else(|| config_secret.map(|s| s.to_string()))
|
||||||
|
.unwrap_or_else(|| "fallback_unsafe_secret_change_me".to_string());
|
||||||
|
|
||||||
|
if master_secret == "fallback_unsafe_secret_change_me" {
|
||||||
|
tracing::warn!("⚠️ Usando llave de cifrado por defecto. La base de datos local no es segura.");
|
||||||
|
}
|
||||||
|
|
||||||
let mut key = [0u8; 32];
|
let mut key = [0u8; 32];
|
||||||
pbkdf2_hmac::<Sha256>(master_secret.as_bytes(), SALT, 1000, &mut key);
|
pbkdf2_hmac::<Sha256>(master_secret.as_bytes(), SALT, 1000, &mut key);
|
||||||
let cipher =
|
let cipher =
|
||||||
Aes256Gcm::new_from_slice(&key).map_err(|e| anyhow::anyhow!("Cipher Error: {}", e))?;
|
Aes256Gcm::new_from_slice(&key).map_err(|e| anyhow::anyhow!("Cipher Error: {}", e))?;
|
||||||
|
|
||||||
// 3. Abrir DB
|
// 3. Abrir DB con conexión persistente
|
||||||
let conn = Connection::open(db_path).context("Failed to open SQLite DB")?;
|
let conn = Connection::open(db_path).context("Failed to open SQLite DB")?;
|
||||||
|
|
||||||
|
// Optimizaciones SQLite: WAL mode + pragmas de rendimiento
|
||||||
|
conn.execute_batch(
|
||||||
|
"PRAGMA journal_mode = WAL;
|
||||||
|
PRAGMA synchronous = NORMAL;
|
||||||
|
PRAGMA cache_size = -2000;
|
||||||
|
PRAGMA busy_timeout = 5000;"
|
||||||
|
)?;
|
||||||
|
|
||||||
conn.execute(
|
conn.execute(
|
||||||
"CREATE TABLE IF NOT EXISTS telemetry_queue (
|
"CREATE TABLE IF NOT EXISTS telemetry_queue (
|
||||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
@@ -51,13 +69,14 @@ impl StoreAndForward {
|
|||||||
)",
|
)",
|
||||||
[],
|
[],
|
||||||
)?;
|
)?;
|
||||||
|
|
||||||
Ok(Self {
|
Ok(Self {
|
||||||
db_path: db_path.to_string(),
|
conn: Mutex::new(conn),
|
||||||
cipher,
|
cipher,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Guarda un payload ENCRIPTADO
|
/// Guarda un payload ENCRIPTADO usando la conexión persistente.
|
||||||
pub fn save(&self, topic: &str, payload: &str) -> Result<()> {
|
pub fn save(&self, topic: &str, payload: &str) -> Result<()> {
|
||||||
let mut nonce_bytes = [0u8; 12];
|
let mut nonce_bytes = [0u8; 12];
|
||||||
rand::thread_rng().fill_bytes(&mut nonce_bytes);
|
rand::thread_rng().fill_bytes(&mut nonce_bytes);
|
||||||
@@ -68,7 +87,7 @@ impl StoreAndForward {
|
|||||||
.encrypt(nonce, payload.as_bytes())
|
.encrypt(nonce, payload.as_bytes())
|
||||||
.map_err(|e| anyhow::anyhow!("Encryption failed: {}", e))?;
|
.map_err(|e| anyhow::anyhow!("Encryption failed: {}", e))?;
|
||||||
|
|
||||||
let conn = Connection::open(&self.db_path)?;
|
let conn = self.conn.lock().map_err(|e| anyhow::anyhow!("Lock poisoned: {}", e))?;
|
||||||
conn.execute(
|
conn.execute(
|
||||||
"INSERT INTO telemetry_queue (topic, payload, nonce, created_at) VALUES (?1, ?2, ?3, ?4)",
|
"INSERT INTO telemetry_queue (topic, payload, nonce, created_at) VALUES (?1, ?2, ?3, ?4)",
|
||||||
params![topic, ciphertext, nonce_bytes.to_vec(), Utc::now().to_rfc3339()],
|
params![topic, ciphertext, nonce_bytes.to_vec(), Utc::now().to_rfc3339()],
|
||||||
@@ -76,10 +95,10 @@ impl StoreAndForward {
|
|||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Desencripta y envía los datos
|
/// Desencripta y envía los datos usando la conexión persistente.
|
||||||
pub async fn process_queue(&self, mqtt_client: &AsyncClient) -> Result<usize> {
|
pub async fn process_queue(&self, mqtt_client: &AsyncClient) -> Result<usize> {
|
||||||
let rows = {
|
let rows = {
|
||||||
let conn = Connection::open(&self.db_path)?;
|
let conn = self.conn.lock().map_err(|e| anyhow::anyhow!("Lock poisoned: {}", e))?;
|
||||||
let mut stmt = conn.prepare(
|
let mut stmt = conn.prepare(
|
||||||
"SELECT id, topic, payload, nonce FROM telemetry_queue ORDER BY id ASC LIMIT 50",
|
"SELECT id, topic, payload, nonce FROM telemetry_queue ORDER BY id ASC LIMIT 50",
|
||||||
)?;
|
)?;
|
||||||
@@ -94,6 +113,8 @@ impl StoreAndForward {
|
|||||||
};
|
};
|
||||||
|
|
||||||
let mut sent_count = 0;
|
let mut sent_count = 0;
|
||||||
|
let mut ids_to_delete = Vec::new();
|
||||||
|
|
||||||
for (id, topic, ciphertext, nonce_bytes) in rows {
|
for (id, topic, ciphertext, nonce_bytes) in rows {
|
||||||
let nonce = Nonce::from_slice(&nonce_bytes);
|
let nonce = Nonce::from_slice(&nonce_bytes);
|
||||||
if let Ok(plaintext_bytes) = self.cipher.decrypt(nonce, ciphertext.as_slice()) {
|
if let Ok(plaintext_bytes) = self.cipher.decrypt(nonce, ciphertext.as_slice()) {
|
||||||
@@ -103,8 +124,7 @@ impl StoreAndForward {
|
|||||||
.await
|
.await
|
||||||
{
|
{
|
||||||
Ok(_) => {
|
Ok(_) => {
|
||||||
let _ = Connection::open(&self.db_path)?
|
ids_to_delete.push(id);
|
||||||
.execute("DELETE FROM telemetry_queue WHERE id = ?1", params![id]);
|
|
||||||
sent_count += 1;
|
sent_count += 1;
|
||||||
}
|
}
|
||||||
Err(_) => break,
|
Err(_) => break,
|
||||||
@@ -112,10 +132,22 @@ impl StoreAndForward {
|
|||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
// Si no se puede desencriptar (llave cambiada), eliminamos para no bloquear la cola
|
// Si no se puede desencriptar (llave cambiada), eliminamos para no bloquear la cola
|
||||||
let _ = Connection::open(&self.db_path)?
|
ids_to_delete.push(id);
|
||||||
.execute("DELETE FROM telemetry_queue WHERE id = ?1", params![id]);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Batch delete all sent messages in a single transaction
|
||||||
|
if !ids_to_delete.is_empty() {
|
||||||
|
let conn = self.conn.lock().map_err(|e| anyhow::anyhow!("Lock poisoned: {}", e))?;
|
||||||
|
let placeholders: Vec<String> = ids_to_delete.iter().map(|_| "?".to_string()).collect();
|
||||||
|
let query = format!("DELETE FROM telemetry_queue WHERE id IN ({})", placeholders.join(","));
|
||||||
|
let params: Vec<Box<dyn rusqlite::types::ToSql>> = ids_to_delete.iter()
|
||||||
|
.map(|id| Box::new(*id) as Box<dyn rusqlite::types::ToSql>)
|
||||||
|
.collect();
|
||||||
|
let param_refs: Vec<&dyn rusqlite::types::ToSql> = params.iter().map(|p| p.as_ref()).collect();
|
||||||
|
conn.execute(&query, param_refs.as_slice())?;
|
||||||
|
}
|
||||||
|
|
||||||
Ok(sent_count)
|
Ok(sent_count)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -31,7 +31,7 @@ pub fn run_service() -> Result<()> {
|
|||||||
#[cfg(windows)]
|
#[cfg(windows)]
|
||||||
fn service_main(_args: Vec<OsString>) {
|
fn service_main(_args: Vec<OsString>) {
|
||||||
if let Err(e) = service_handler() {
|
if let Err(e) = service_handler() {
|
||||||
eprintln!("Service error: {}", e);
|
tracing::error!("Service critical error: {}", e);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -68,7 +68,8 @@ fn service_handler() -> Result<()> {
|
|||||||
let exe_path = std::env::current_exe().unwrap();
|
let exe_path = std::env::current_exe().unwrap();
|
||||||
let config_path = exe_path.with_file_name("agent.toml");
|
let config_path = exe_path.with_file_name("agent.toml");
|
||||||
|
|
||||||
if let Ok(config) = crate::config::AgentConfig::load_default(&config_path) {
|
match crate::config::AgentConfig::load_default(&config_path) {
|
||||||
|
Ok(config) => {
|
||||||
tokio::select! {
|
tokio::select! {
|
||||||
res = crate::run_agent(config) => {
|
res = crate::run_agent(config) => {
|
||||||
if let Err(e) = res {
|
if let Err(e) = res {
|
||||||
@@ -79,8 +80,12 @@ fn service_handler() -> Result<()> {
|
|||||||
tracing::info!("Service stopping via SCM signal");
|
tracing::info!("Service stopping via SCM signal");
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
} else {
|
},
|
||||||
shutdown_rx.recv().await;
|
Err(e) => {
|
||||||
|
tracing::error!("Failed to load configuration at {:?}: {}", config_path, e);
|
||||||
|
// Esperar un poco antes de salir para que el log se escriba
|
||||||
|
tokio::time::sleep(std::time::Duration::from_secs(1)).await;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -1,17 +1,12 @@
|
|||||||
//! System Validator — Verifica que el entorno de ejecución sea compatible y ofrece reparaciones automáticas.
|
//! System Validator — Verifica que el entorno de ejecución sea compatible y ofrece reparaciones automáticas.
|
||||||
|
//! Usa llamadas directas al OS en lugar de la crate pesada `sysinfo`.
|
||||||
|
|
||||||
use anyhow::{Context, Result, anyhow};
|
use anyhow::{Context, Result, anyhow};
|
||||||
use bollard::Docker;
|
|
||||||
use std::io::Write;
|
use std::io::Write;
|
||||||
use std::process::Command;
|
use std::process::Command;
|
||||||
use sysinfo::Disks;
|
|
||||||
|
|
||||||
#[derive(Debug, Clone, serde::Serialize)]
|
#[derive(Debug, Clone, serde::Serialize)]
|
||||||
pub struct ValidationReport {
|
pub struct ValidationReport {
|
||||||
pub docker_installed: bool,
|
|
||||||
pub docker_running: bool,
|
|
||||||
pub wsl_installed: bool,
|
|
||||||
pub virt_enabled: bool,
|
|
||||||
pub is_admin: bool,
|
pub is_admin: bool,
|
||||||
pub free_disk_space_gb: u64,
|
pub free_disk_space_gb: u64,
|
||||||
pub errors: Vec<String>,
|
pub errors: Vec<String>,
|
||||||
@@ -21,51 +16,6 @@ pub async fn validate_system() -> ValidationReport {
|
|||||||
let mut errors = Vec::new();
|
let mut errors = Vec::new();
|
||||||
let is_admin = check_admin_privileges();
|
let is_admin = check_admin_privileges();
|
||||||
|
|
||||||
let docker_installed = check_command_exists("docker");
|
|
||||||
let mut docker_running = false;
|
|
||||||
|
|
||||||
if docker_installed {
|
|
||||||
match Docker::connect_with_socket_defaults() {
|
|
||||||
Ok(docker) => {
|
|
||||||
if docker.version().await.is_ok() {
|
|
||||||
docker_running = true;
|
|
||||||
} else {
|
|
||||||
errors.push(
|
|
||||||
"Docker is installed but the daemon is not running or responsive."
|
|
||||||
.to_string(),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
Err(_) => {
|
|
||||||
errors.push("Failed to connect to Docker daemon.".to_string());
|
|
||||||
}
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
errors.push("Docker is not installed or not in PATH.".to_string());
|
|
||||||
}
|
|
||||||
|
|
||||||
let wsl_installed = if cfg!(windows) {
|
|
||||||
check_command_exists("wsl")
|
|
||||||
} else {
|
|
||||||
true // Assume true for Linux
|
|
||||||
};
|
|
||||||
|
|
||||||
let virt_enabled = if cfg!(windows) {
|
|
||||||
check_virt_enabled().unwrap_or(false)
|
|
||||||
} else {
|
|
||||||
true // Assume true for Linux (or check /proc/cpuinfo)
|
|
||||||
};
|
|
||||||
|
|
||||||
if cfg!(windows) && !virt_enabled {
|
|
||||||
errors.push(
|
|
||||||
"Virtualization is not enabled in BIOS. Docker cannot run without it.".to_string(),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
if cfg!(windows) && !wsl_installed && virt_enabled {
|
|
||||||
errors.push("WSL 2 is required for Docker on Windows but was not found.".to_string());
|
|
||||||
}
|
|
||||||
|
|
||||||
let free_disk_space_gb = get_free_disk_space();
|
let free_disk_space_gb = get_free_disk_space();
|
||||||
if free_disk_space_gb < 2 {
|
if free_disk_space_gb < 2 {
|
||||||
errors.push(format!(
|
errors.push(format!(
|
||||||
@@ -75,10 +25,6 @@ pub async fn validate_system() -> ValidationReport {
|
|||||||
}
|
}
|
||||||
|
|
||||||
ValidationReport {
|
ValidationReport {
|
||||||
docker_installed,
|
|
||||||
docker_running,
|
|
||||||
wsl_installed,
|
|
||||||
virt_enabled,
|
|
||||||
is_admin,
|
is_admin,
|
||||||
free_disk_space_gb,
|
free_disk_space_gb,
|
||||||
errors,
|
errors,
|
||||||
@@ -92,34 +38,7 @@ pub async fn fix_system(report: &ValidationReport) -> Result<()> {
|
|||||||
));
|
));
|
||||||
}
|
}
|
||||||
|
|
||||||
if cfg!(windows) {
|
// Ya no instalamos Docker ni WSL automáticamente
|
||||||
if !report.virt_enabled {
|
|
||||||
return Err(anyhow!(
|
|
||||||
"ERROR: La virtualización está desactivada en la BIOS. Por favor actívala antes de continuar."
|
|
||||||
));
|
|
||||||
}
|
|
||||||
|
|
||||||
if !report.wsl_installed {
|
|
||||||
println!("🚀 Instalando WSL 2 y dependencias... (Esto puede tardar unos minutos)");
|
|
||||||
let out = Command::new("wsl")
|
|
||||||
.args(["--install", "--no-launch"])
|
|
||||||
.output()
|
|
||||||
.context("Error al ejecutar wsl --install")?;
|
|
||||||
|
|
||||||
if !out.status.success() {
|
|
||||||
let err = String::from_utf8_lossy(&out.stderr);
|
|
||||||
return Err(anyhow!("Falló la instalación de WSL: {}", err));
|
|
||||||
}
|
|
||||||
println!(
|
|
||||||
"✅ WSL instalado. Se recomienda reiniciar para que los cambios surtan efecto."
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
if !report.docker_installed {
|
|
||||||
install_docker_windows().await?;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -230,20 +149,69 @@ pub(crate) fn check_virt_enabled() -> Result<bool> {
|
|||||||
.output()?;
|
.output()?;
|
||||||
|
|
||||||
let s = String::from_utf8_lossy(&out.stdout).trim().to_lowercase();
|
let s = String::from_utf8_lossy(&out.stdout).trim().to_lowercase();
|
||||||
return Ok(s == "true");
|
Ok(s == "true")
|
||||||
}
|
}
|
||||||
|
#[cfg(not(windows))]
|
||||||
|
{
|
||||||
Ok(true)
|
Ok(true)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Obtiene el espacio libre en disco usando llamadas directas al OS.
|
||||||
|
/// Reemplaza la dependencia pesada `sysinfo` (~500KB) con llamadas nativas.
|
||||||
pub(crate) fn get_free_disk_space() -> u64 {
|
pub(crate) fn get_free_disk_space() -> u64 {
|
||||||
let disks = Disks::new_with_refreshed_list();
|
#[cfg(windows)]
|
||||||
let mut total_free = 0;
|
{
|
||||||
|
// Obtener espacio libre del disco donde está el ejecutable
|
||||||
|
let path = std::env::current_exe()
|
||||||
|
.unwrap_or_else(|_| std::path::PathBuf::from("C:\\"));
|
||||||
|
let drive = path.to_str()
|
||||||
|
.and_then(|s| s.get(..3))
|
||||||
|
.unwrap_or("C:\\");
|
||||||
|
|
||||||
for disk in &disks {
|
// Usar PowerShell para obtener espacio libre
|
||||||
total_free += disk.available_space();
|
if let Ok(output) = Command::new("powershell")
|
||||||
|
.args(["-Command", &format!(
|
||||||
|
"(Get-PSDrive -Name '{}').Free",
|
||||||
|
drive.chars().next().unwrap_or('C')
|
||||||
|
)])
|
||||||
|
.output()
|
||||||
|
{
|
||||||
|
if let Ok(s) = String::from_utf8(output.stdout) {
|
||||||
|
if let Ok(bytes) = s.trim().parse::<u64>() {
|
||||||
|
return bytes / (1024 * 1024 * 1024);
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
total_free / (1024 * 1024 * 1024) // Convert to GB
|
// Fallback: wmic
|
||||||
|
if let Ok(output) = Command::new("wmic")
|
||||||
|
.args(["logicaldisk", "get", "freespace"])
|
||||||
|
.output()
|
||||||
|
{
|
||||||
|
let s = String::from_utf8_lossy(&output.stdout);
|
||||||
|
let total: u64 = s.lines()
|
||||||
|
.filter_map(|line| line.trim().parse::<u64>().ok())
|
||||||
|
.sum();
|
||||||
|
return total / (1024 * 1024 * 1024);
|
||||||
|
}
|
||||||
|
|
||||||
|
0
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(unix)]
|
||||||
|
{
|
||||||
|
// Usar statvfs en Unix
|
||||||
|
use std::ffi::CString;
|
||||||
|
let path = CString::new("/").unwrap();
|
||||||
|
unsafe {
|
||||||
|
let mut stat: libc::statvfs = std::mem::zeroed();
|
||||||
|
if libc::statvfs(path.as_ptr(), &mut stat) == 0 {
|
||||||
|
return (stat.f_bavail as u64 * stat.f_frsize as u64) / (1024 * 1024 * 1024);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
0
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn print_report(report: &ValidationReport) {
|
pub fn print_report(report: &ValidationReport) {
|
||||||
@@ -252,10 +220,6 @@ pub fn print_report(report: &ValidationReport) {
|
|||||||
|
|
||||||
let check = |val: bool| if val { "✅ Sí" } else { "❌ No" };
|
let check = |val: bool| if val { "✅ Sí" } else { "❌ No" };
|
||||||
|
|
||||||
println!("Docker Instalado: {}", check(report.docker_installed));
|
|
||||||
println!("Docker Corriendo: {}", check(report.docker_running));
|
|
||||||
println!("WSL Instalado: {}", check(report.wsl_installed));
|
|
||||||
println!("Virt. en BIOS: {}", check(report.virt_enabled));
|
|
||||||
println!("Priv. Administrador: {}", check(report.is_admin));
|
println!("Priv. Administrador: {}", check(report.is_admin));
|
||||||
println!("Espacio Libre: {} GB", report.free_disk_space_gb);
|
println!("Espacio Libre: {} GB", report.free_disk_space_gb);
|
||||||
|
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
//! Unit Tests for Edge Agent
|
//! Unit Tests for Edge Agent
|
||||||
|
|
||||||
use crate::config::AgentConfig;
|
use crate::config::AgentConfig;
|
||||||
use shared_lib::mqtt_messages::{DeploySignal, PortMapping, VolumeMount, EnvVar};
|
use shared_lib::mqtt_messages::{DeploySignal, PortMapping};
|
||||||
use uuid::Uuid;
|
use uuid::Uuid;
|
||||||
use chrono::Utc;
|
use chrono::Utc;
|
||||||
use crate::system_validator::{ValidationReport, check_command_exists};
|
use crate::system_validator::{ValidationReport, check_command_exists};
|
||||||
@@ -42,6 +42,9 @@ fn test_deploy_signal_creation() {
|
|||||||
}],
|
}],
|
||||||
volume_mounts: vec![],
|
volume_mounts: vec![],
|
||||||
env_vars: vec![],
|
env_vars: vec![],
|
||||||
|
flows_json: None,
|
||||||
|
package_json: None,
|
||||||
|
settings_js: None,
|
||||||
};
|
};
|
||||||
assert_eq!(signal.image_tag, "nginx:latest");
|
assert_eq!(signal.image_tag, "nginx:latest");
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -5,7 +5,7 @@
|
|||||||
UpgradeCode='6F9619FF-8B49-4D6C-8D4C-9B6C24B55FCD'
|
UpgradeCode='6F9619FF-8B49-4D6C-8D4C-9B6C24B55FCD'
|
||||||
Language='1033'
|
Language='1033'
|
||||||
Codepage='1252'
|
Codepage='1252'
|
||||||
Version='0.1.1'
|
Version='0.2.0'
|
||||||
Manufacturer='OmniOil'>
|
Manufacturer='OmniOil'>
|
||||||
|
|
||||||
<Package Id='*'
|
<Package Id='*'
|
||||||
@@ -29,7 +29,6 @@
|
|||||||
|
|
||||||
<Component Id='MainExecutable' Guid='B278D874-45B5-4CBA-A840-3F02AB62F9FD'>
|
<Component Id='MainExecutable' Guid='B278D874-45B5-4CBA-A840-3F02AB62F9FD'>
|
||||||
<File Id='EdgeAgentEXE' Source='target/x86_64-pc-windows-msvc/release/edge-agent.exe' KeyPath='yes' />
|
<File Id='EdgeAgentEXE' Source='target/x86_64-pc-windows-msvc/release/edge-agent.exe' KeyPath='yes' />
|
||||||
<File Id='AgentConfigTOML' Source='agent.toml' />
|
|
||||||
|
|
||||||
<!-- Servicio de Windows -->
|
<!-- Servicio de Windows -->
|
||||||
<ServiceInstall Id="ServiceInstaller"
|
<ServiceInstall Id="ServiceInstaller"
|
||||||
|
|||||||
@@ -4,15 +4,15 @@ version = "0.1.0"
|
|||||||
edition = "2024"
|
edition = "2024"
|
||||||
|
|
||||||
[dependencies]
|
[dependencies]
|
||||||
shared-lib = { path = "../shared-lib" }
|
shared-lib = { workspace = true }
|
||||||
tokio = { version = "1.50.0", features = ["full"] }
|
tokio = { workspace = true }
|
||||||
sqlx = { version = "0.8.6", features = ["runtime-tokio-rustls", "postgres", "uuid", "chrono", "json", "bigdecimal"] }
|
sqlx = { workspace = true }
|
||||||
serde = { version = "1.0.228", features = ["derive"] }
|
serde = { workspace = true }
|
||||||
serde_json = "1.0.149"
|
serde_json = { workspace = true }
|
||||||
chrono = "0.4.44"
|
chrono = { workspace = true }
|
||||||
uuid = { version = "1.23.0", features = ["v4"] }
|
uuid = { workspace = true }
|
||||||
dotenv = "0.15.0"
|
dotenvy = { workspace = true }
|
||||||
tracing = "0.1.44"
|
tracing = { workspace = true }
|
||||||
tracing-subscriber = "0.3.23"
|
tracing-subscriber = { workspace = true }
|
||||||
redis = { version = "1.1.0", features = ["tokio-comp"] }
|
redis = { version = "1.1.0", features = ["tokio-comp"] }
|
||||||
anyhow = "1.0.102"
|
anyhow = { workspace = true }
|
||||||
|
|||||||
@@ -6,7 +6,7 @@
|
|||||||
mod dispatcher;
|
mod dispatcher;
|
||||||
|
|
||||||
use anyhow::Result;
|
use anyhow::Result;
|
||||||
use dotenv::dotenv;
|
use dotenvy::dotenv;
|
||||||
use sqlx::postgres::PgPoolOptions;
|
use sqlx::postgres::PgPoolOptions;
|
||||||
use std::env;
|
use std::env;
|
||||||
use std::time::Duration;
|
use std::time::Duration;
|
||||||
|
|||||||
@@ -4,13 +4,13 @@ version = "0.1.0"
|
|||||||
edition = "2024"
|
edition = "2024"
|
||||||
|
|
||||||
[dependencies]
|
[dependencies]
|
||||||
shared-lib = { path = "../shared-lib" }
|
shared-lib = { workspace = true }
|
||||||
serde = { version = "1.0.228", features = ["derive"] }
|
serde = { workspace = true }
|
||||||
serde_json = "1.0.149"
|
serde_json = { workspace = true }
|
||||||
uuid = { version = "1.23.0", features = ["v4"] }
|
uuid = { workspace = true }
|
||||||
chrono = { version = "0.4.44", features = ["serde"] }
|
chrono = { workspace = true }
|
||||||
tracing = "0.1.44"
|
tracing = { workspace = true }
|
||||||
tokio = "1.50.0"
|
tokio = { workspace = true }
|
||||||
|
|
||||||
[dev-dependencies]
|
[dev-dependencies]
|
||||||
tokio = { version = "1.50.0", features = ["full"] }
|
tokio = { version = "1.50.0", features = ["full"] }
|
||||||
|
|||||||
@@ -1,28 +1,10 @@
|
|||||||
//! Flow Builder — Construye el archivo flows.json completo para Node-RED
|
//! Flow Builder — Construye el archivo flows.json completo para Node-RED
|
||||||
//! a partir de la configuración de un proyecto.
|
//! a partir de la configuración de un proyecto.
|
||||||
|
|
||||||
use crate::nodes::{alarm, common, modbus, mqtt_out, opcua};
|
use crate::nodes::{agent_out, common, modbus, opcua};
|
||||||
use serde_json::{Value, json};
|
use serde_json::{Value, json};
|
||||||
use shared_lib::edge_models::{DeviceConfig, DeviceProtocol, ProjectConfig};
|
use shared_lib::edge_models::{DeviceConfig, DeviceProtocol, ProjectConfig};
|
||||||
|
|
||||||
/// Configuración del broker MQTT al que Node-RED publicará telemetría.
|
|
||||||
#[derive(Debug, Clone)]
|
|
||||||
pub struct MqttBrokerConfig {
|
|
||||||
pub host: String,
|
|
||||||
pub port: u16,
|
|
||||||
pub client_id_prefix: String,
|
|
||||||
}
|
|
||||||
|
|
||||||
impl Default for MqttBrokerConfig {
|
|
||||||
fn default() -> Self {
|
|
||||||
Self {
|
|
||||||
host: "mqtt-broker".to_string(),
|
|
||||||
port: 1883,
|
|
||||||
client_id_prefix: "omnioil-scada".to_string(),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Resultado de la generación del flujo.
|
/// Resultado de la generación del flujo.
|
||||||
pub struct FlowResult {
|
pub struct FlowResult {
|
||||||
/// JSON del archivo flows.json completo
|
/// JSON del archivo flows.json completo
|
||||||
@@ -32,34 +14,22 @@ pub struct FlowResult {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// Genera el archivo flows.json completo para un proyecto dado.
|
/// Genera el archivo flows.json completo para un proyecto dado.
|
||||||
|
/// Ahora mucho más sencillo, enviando datos al Edge Agent vía HTTP local.
|
||||||
pub fn generate_flows(
|
pub fn generate_flows(
|
||||||
config: &ProjectConfig,
|
config: &ProjectConfig,
|
||||||
mqtt_config: &MqttBrokerConfig,
|
|
||||||
) -> Result<FlowResult, Box<dyn std::error::Error + Send + Sync>> {
|
) -> Result<FlowResult, Box<dyn std::error::Error + Send + Sync>> {
|
||||||
let mut all_nodes: Vec<Value> = Vec::new();
|
let mut all_nodes: Vec<Value> = Vec::new();
|
||||||
let mut required_packages: Vec<String> = Vec::new();
|
let mut required_packages: Vec<String> = Vec::new();
|
||||||
|
|
||||||
let project_id = config.project.id.to_string();
|
let _project_id = config.project.id.to_string();
|
||||||
let project_name = &config.project.name;
|
let project_name = &config.project.name;
|
||||||
|
|
||||||
// =========================================================================
|
|
||||||
// Nodo de configuración MQTT Broker (global, compartido por todos los tabs)
|
|
||||||
// =========================================================================
|
|
||||||
let mqtt_client_id = format!("{}-{}", mqtt_config.client_id_prefix, &project_id[..8]);
|
|
||||||
let (mqtt_broker_id, mqtt_broker_node) =
|
|
||||||
mqtt_out::mqtt_broker_config_node(&mqtt_config.host, mqtt_config.port, &mqtt_client_id);
|
|
||||||
all_nodes.push(mqtt_broker_node);
|
|
||||||
|
|
||||||
// =========================================================================
|
|
||||||
// Iterar sobre cada Asset del proyecto
|
// Iterar sobre cada Asset del proyecto
|
||||||
// =========================================================================
|
|
||||||
for asset_config in &config.assets {
|
for asset_config in &config.assets {
|
||||||
let asset = &asset_config.asset;
|
let asset = &asset_config.asset;
|
||||||
|
|
||||||
// =========================================================================
|
|
||||||
// Iterar sobre cada dispositivo del activo
|
// Iterar sobre cada dispositivo del activo
|
||||||
// =========================================================================
|
for device_config in &asset_config.devices {
|
||||||
for (_device_idx, device_config) in asset_config.devices.iter().enumerate() {
|
|
||||||
let device = &device_config.device;
|
let device = &device_config.device;
|
||||||
let variables = &device_config.variables;
|
let variables = &device_config.variables;
|
||||||
|
|
||||||
@@ -67,46 +37,52 @@ pub fn generate_flows(
|
|||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Crear un Tab (pestaña) por dispositivo, incluyendo info del Asset
|
// 1. Crear un Tab (pestaña) por dispositivo
|
||||||
let tab_id = common::generate_node_id();
|
let tab_id = common::generate_node_id();
|
||||||
let tab_label = format!(
|
let tab_label = format!("{} | {}", asset.name, device.name);
|
||||||
"{} | {} - {}:{}",
|
|
||||||
asset.name, device.name, device.host, device.port
|
|
||||||
);
|
|
||||||
all_nodes.push(common::tab_node(&tab_id, &tab_label));
|
all_nodes.push(common::tab_node(&tab_id, &tab_label));
|
||||||
|
|
||||||
// Comment informativo
|
// 2. Nodo de Transmisión HTTP (Agent Gateway)
|
||||||
all_nodes.push(common::comment_node(
|
// Centralizamos todo el envío aquí
|
||||||
|
let (http_out_id, http_out_node) = agent_out::agent_http_out_node(
|
||||||
&tab_id,
|
&tab_id,
|
||||||
&format!("📡 {} [{}] | Prot: {:?}", device.name, asset.code, device.protocol),
|
"📤 Enviar al Agente (HTTP)",
|
||||||
&format!("Generado por OmniOil\nActivo: {} ({})\nDispositivo: {}\nHost: {}:{}\nVariables: {}",
|
1000,
|
||||||
asset.name, asset.code, device.name, device.host, device.port, variables.len()),
|
200
|
||||||
50,
|
);
|
||||||
30,
|
all_nodes.push(http_out_node);
|
||||||
));
|
|
||||||
|
|
||||||
// =====================================================================
|
// 3. Agregador de Telemetría
|
||||||
// Generar nodos según el protocolo del dispositivo
|
// Centralizamos todo el envío aquí con IDs reales del proyecto y activo
|
||||||
// =====================================================================
|
let (aggregator_id, aggregator_node) = agent_out::telemetry_aggregator_node(
|
||||||
|
&config.project.id.to_string(),
|
||||||
|
project_name,
|
||||||
|
&asset.id.to_string(),
|
||||||
|
&asset.name,
|
||||||
|
&device.name,
|
||||||
|
&tab_id,
|
||||||
|
800,
|
||||||
|
200,
|
||||||
|
vec![vec![http_out_id]],
|
||||||
|
);
|
||||||
|
all_nodes.push(aggregator_node);
|
||||||
|
|
||||||
|
// 4. Generar nodos según el protocolo
|
||||||
match device.protocol {
|
match device.protocol {
|
||||||
DeviceProtocol::ModbusTcp | DeviceProtocol::ModbusRtu => {
|
DeviceProtocol::ModbusTcp => {
|
||||||
generate_modbus_flow(
|
generate_modbus_nodes(
|
||||||
device_config,
|
device_config,
|
||||||
&tab_id,
|
&tab_id,
|
||||||
&project_id,
|
&aggregator_id,
|
||||||
project_name,
|
|
||||||
&mqtt_broker_id,
|
|
||||||
&mut all_nodes,
|
&mut all_nodes,
|
||||||
&mut required_packages,
|
&mut required_packages,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
DeviceProtocol::OpcUa => {
|
DeviceProtocol::OpcUa => {
|
||||||
generate_opcua_flow(
|
generate_opcua_nodes(
|
||||||
device_config,
|
device_config,
|
||||||
&tab_id,
|
&tab_id,
|
||||||
&project_id,
|
&aggregator_id,
|
||||||
project_name,
|
|
||||||
&mqtt_broker_id,
|
|
||||||
&mut all_nodes,
|
&mut all_nodes,
|
||||||
&mut required_packages,
|
&mut required_packages,
|
||||||
);
|
);
|
||||||
@@ -114,22 +90,18 @@ pub fn generate_flows(
|
|||||||
_ => {
|
_ => {
|
||||||
all_nodes.push(common::comment_node(
|
all_nodes.push(common::comment_node(
|
||||||
&tab_id,
|
&tab_id,
|
||||||
&format!("⚠️ Protocolo {:?} no soportado aún", device.protocol),
|
&format!("⚠️ Protocolo {:?} no soportado", device.protocol),
|
||||||
"Este protocolo será implementado en futuras versiones.",
|
"Implementación pendiente.",
|
||||||
200,
|
200,
|
||||||
100,
|
100,
|
||||||
));
|
));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Nodo de alarma dashboard
|
|
||||||
let y_alarm_dashboard = 80 + (variables.len() as u32 * 80) + 80;
|
|
||||||
let (_, alarm_dashboard) = alarm::alarm_dashboard_node(&tab_id, 600, y_alarm_dashboard);
|
|
||||||
all_nodes.push(alarm_dashboard);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Deduplicate npm packages
|
// Asegurar dependencias mínimas
|
||||||
|
required_packages.push("node-red-dashboard".to_string());
|
||||||
required_packages.sort();
|
required_packages.sort();
|
||||||
required_packages.dedup();
|
required_packages.dedup();
|
||||||
|
|
||||||
@@ -141,249 +113,95 @@ pub fn generate_flows(
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Genera el flujo completo para un dispositivo Modbus TCP/RTU.
|
/// Genera los nodos de Modbus de forma simplificada.
|
||||||
fn generate_modbus_flow(
|
fn generate_modbus_nodes(
|
||||||
device_config: &DeviceConfig,
|
device_config: &DeviceConfig,
|
||||||
tab_id: &str,
|
tab_id: &str,
|
||||||
project_id: &str,
|
aggregator_id: &str,
|
||||||
project_name: &str,
|
|
||||||
mqtt_broker_id: &str,
|
|
||||||
all_nodes: &mut Vec<Value>,
|
all_nodes: &mut Vec<Value>,
|
||||||
required_packages: &mut Vec<String>,
|
required_packages: &mut Vec<String>,
|
||||||
) {
|
) {
|
||||||
let device = &device_config.device;
|
let device = &device_config.device;
|
||||||
let variables = &device_config.variables;
|
let variables = &device_config.variables;
|
||||||
|
|
||||||
// Agregar dependencia npm
|
|
||||||
required_packages.push("node-red-contrib-modbus".to_string());
|
required_packages.push("node-red-contrib-modbus".to_string());
|
||||||
|
|
||||||
// Crear nodo de configuración Modbus Client
|
// Cliente compartido
|
||||||
let (modbus_client_id, modbus_client_node) = modbus::modbus_client_node(device);
|
let (client_id, client_node) = modbus::modbus_client_node(device);
|
||||||
all_nodes.push(modbus_client_node);
|
all_nodes.push(client_node);
|
||||||
|
|
||||||
// Telemetry topic
|
let unit_id = device.protocol_config
|
||||||
let telemetry_topic = format!("omnioil/telemetry/{}", project_id);
|
.get("unit_id")
|
||||||
let alarm_topic = format!("omnioil/alarms/{}", project_id);
|
.and_then(|v| v.as_u64())
|
||||||
|
.unwrap_or(1) as u8;
|
||||||
|
|
||||||
// MQTT Out para telemetría
|
for (idx, var) in variables.iter().enumerate() {
|
||||||
let y_mqtt_telemetry = 80 + (variables.len() as u32 * 80) + 20;
|
if !var.is_enabled { continue; }
|
||||||
let (mqtt_telemetry_id, mqtt_telemetry_node) = mqtt_out::mqtt_out_node(
|
|
||||||
mqtt_broker_id,
|
|
||||||
tab_id,
|
|
||||||
&telemetry_topic,
|
|
||||||
"📤 Telemetry MQTT",
|
|
||||||
900,
|
|
||||||
y_mqtt_telemetry,
|
|
||||||
);
|
|
||||||
all_nodes.push(mqtt_telemetry_node);
|
|
||||||
|
|
||||||
// MQTT Out para alarmas
|
let y = 100 + (idx as u32 * 60);
|
||||||
let (mqtt_alarm_id, mqtt_alarm_node) = mqtt_out::mqtt_out_node(
|
|
||||||
mqtt_broker_id,
|
|
||||||
tab_id,
|
|
||||||
&alarm_topic,
|
|
||||||
"🚨 Alarm MQTT",
|
|
||||||
900,
|
|
||||||
y_mqtt_telemetry + 60,
|
|
||||||
);
|
|
||||||
all_nodes.push(mqtt_alarm_node);
|
|
||||||
|
|
||||||
// Telemetry Aggregator
|
// Chain: Read -> Decoder -> Aggregator
|
||||||
let aliases: Vec<String> = variables
|
|
||||||
.iter()
|
|
||||||
.filter(|v| v.is_enabled)
|
|
||||||
.map(|v| v.alias.clone())
|
|
||||||
.collect();
|
|
||||||
let (aggregator_id, aggregator_node) = mqtt_out::telemetry_aggregator_node(
|
|
||||||
&aliases,
|
|
||||||
project_name,
|
|
||||||
&device.name,
|
|
||||||
tab_id,
|
|
||||||
700,
|
|
||||||
y_mqtt_telemetry,
|
|
||||||
vec![vec![mqtt_telemetry_id]],
|
|
||||||
);
|
|
||||||
all_nodes.push(aggregator_node);
|
|
||||||
|
|
||||||
// Generar un nodo de lectura + decoder + alarm por cada variable
|
|
||||||
for (var_idx, variable) in variables.iter().enumerate() {
|
|
||||||
if !variable.is_enabled {
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
|
|
||||||
let y_pos = 80 + (var_idx as u32 * 80);
|
|
||||||
|
|
||||||
// Determinar wires del decoder:
|
|
||||||
// - Si tiene alarma habilitada: decoder → alarm_check → [aggregator, mqtt_alarm]
|
|
||||||
// - Si no tiene alarma: decoder → aggregator directamente
|
|
||||||
let decoder_id = common::generate_node_id();
|
let decoder_id = common::generate_node_id();
|
||||||
|
let (_, decoder_node) = modbus::modbus_decoder_node(
|
||||||
if variable.alarm_enabled && (variable.alarm_min.is_some() || variable.alarm_max.is_some())
|
var,
|
||||||
{
|
|
||||||
// Con alarma
|
|
||||||
let (alarm_id, alarm_node) = alarm::alarm_check_node(
|
|
||||||
variable,
|
|
||||||
project_id,
|
|
||||||
tab_id,
|
tab_id,
|
||||||
500,
|
500,
|
||||||
y_pos,
|
y,
|
||||||
vec![
|
vec![vec![aggregator_id.to_string()]]
|
||||||
vec![aggregator_id.clone()], // Output 0: pass-through → aggregator
|
|
||||||
vec![mqtt_alarm_id.clone()], // Output 1: alarm → MQTT alarm
|
|
||||||
],
|
|
||||||
);
|
|
||||||
all_nodes.push(alarm_node);
|
|
||||||
|
|
||||||
// Decoder → Alarm
|
|
||||||
let (_, decoder_node) =
|
|
||||||
modbus::modbus_decoder_node(variable, tab_id, 350, y_pos, vec![vec![alarm_id]]);
|
|
||||||
// Override ID
|
|
||||||
let mut decoder_node = decoder_node;
|
|
||||||
decoder_node["id"] = json!(decoder_id);
|
|
||||||
all_nodes.push(decoder_node);
|
|
||||||
} else {
|
|
||||||
// Sin alarma: decoder → aggregator
|
|
||||||
let (_, decoder_node) = modbus::modbus_decoder_node(
|
|
||||||
variable,
|
|
||||||
tab_id,
|
|
||||||
350,
|
|
||||||
y_pos,
|
|
||||||
vec![vec![aggregator_id.clone()]],
|
|
||||||
);
|
);
|
||||||
let mut decoder_node = decoder_node;
|
let mut decoder_node = decoder_node;
|
||||||
decoder_node["id"] = json!(decoder_id);
|
decoder_node["id"] = json!(decoder_id);
|
||||||
all_nodes.push(decoder_node);
|
all_nodes.push(decoder_node);
|
||||||
}
|
|
||||||
|
|
||||||
// Modbus Read → Decoder
|
|
||||||
let (_, read_node) = modbus::modbus_read_node(
|
let (_, read_node) = modbus::modbus_read_node(
|
||||||
variable,
|
var,
|
||||||
&modbus_client_id,
|
&client_id,
|
||||||
tab_id,
|
tab_id,
|
||||||
200,
|
unit_id,
|
||||||
y_pos,
|
250,
|
||||||
vec![vec![decoder_id]],
|
y,
|
||||||
|
vec![vec![decoder_id]]
|
||||||
);
|
);
|
||||||
all_nodes.push(read_node);
|
all_nodes.push(read_node);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Genera el flujo completo para un dispositivo OPC UA.
|
/// Genera los nodos de OPC UA de forma simplificada.
|
||||||
fn generate_opcua_flow(
|
fn generate_opcua_nodes(
|
||||||
device_config: &DeviceConfig,
|
device_config: &DeviceConfig,
|
||||||
tab_id: &str,
|
tab_id: &str,
|
||||||
project_id: &str,
|
aggregator_id: &str,
|
||||||
project_name: &str,
|
|
||||||
mqtt_broker_id: &str,
|
|
||||||
all_nodes: &mut Vec<Value>,
|
all_nodes: &mut Vec<Value>,
|
||||||
required_packages: &mut Vec<String>,
|
required_packages: &mut Vec<String>,
|
||||||
) {
|
) {
|
||||||
let device = &device_config.device;
|
let device = &device_config.device;
|
||||||
let variables = &device_config.variables;
|
let variables = &device_config.variables;
|
||||||
|
|
||||||
// Agregar dependencia npm
|
|
||||||
required_packages.push("node-red-contrib-opcua".to_string());
|
required_packages.push("node-red-contrib-opcua".to_string());
|
||||||
|
|
||||||
// Crear nodo de configuración OPC UA Endpoint
|
|
||||||
let (endpoint_id, endpoint_node) = opcua::opcua_endpoint_node(device);
|
let (endpoint_id, endpoint_node) = opcua::opcua_endpoint_node(device);
|
||||||
all_nodes.push(endpoint_node);
|
all_nodes.push(endpoint_node);
|
||||||
|
|
||||||
// Telemetry & Alarm MQTT nodes
|
for (idx, var) in variables.iter().enumerate() {
|
||||||
let telemetry_topic = format!("omnioil/telemetry/{}", project_id);
|
if !var.is_enabled { continue; }
|
||||||
let alarm_topic = format!("omnioil/alarms/{}", project_id);
|
let y = 100 + (idx as u32 * 60);
|
||||||
|
|
||||||
let y_mqtt = 80 + (variables.len() as u32 * 80) + 20;
|
let chain = opcua::opcua_read_chain(
|
||||||
|
var,
|
||||||
let (mqtt_telemetry_id, mqtt_telemetry_node) = mqtt_out::mqtt_out_node(
|
|
||||||
mqtt_broker_id,
|
|
||||||
tab_id,
|
|
||||||
&telemetry_topic,
|
|
||||||
"📤 Telemetry MQTT",
|
|
||||||
900,
|
|
||||||
y_mqtt,
|
|
||||||
);
|
|
||||||
all_nodes.push(mqtt_telemetry_node);
|
|
||||||
|
|
||||||
let (mqtt_alarm_id, mqtt_alarm_node) = mqtt_out::mqtt_out_node(
|
|
||||||
mqtt_broker_id,
|
|
||||||
tab_id,
|
|
||||||
&alarm_topic,
|
|
||||||
"🚨 Alarm MQTT",
|
|
||||||
900,
|
|
||||||
y_mqtt + 60,
|
|
||||||
);
|
|
||||||
all_nodes.push(mqtt_alarm_node);
|
|
||||||
|
|
||||||
// Aggregator
|
|
||||||
let aliases: Vec<String> = variables
|
|
||||||
.iter()
|
|
||||||
.filter(|v| v.is_enabled)
|
|
||||||
.map(|v| v.alias.clone())
|
|
||||||
.collect();
|
|
||||||
let (aggregator_id, aggregator_node) = mqtt_out::telemetry_aggregator_node(
|
|
||||||
&aliases,
|
|
||||||
project_name,
|
|
||||||
&device.name,
|
|
||||||
tab_id,
|
|
||||||
700,
|
|
||||||
y_mqtt,
|
|
||||||
vec![vec![mqtt_telemetry_id]],
|
|
||||||
);
|
|
||||||
all_nodes.push(aggregator_node);
|
|
||||||
|
|
||||||
// Generar cadena de lectura OPC UA para cada variable
|
|
||||||
for (var_idx, variable) in variables.iter().enumerate() {
|
|
||||||
if !variable.is_enabled {
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
|
|
||||||
let y_pos = 80 + (var_idx as u32 * 80);
|
|
||||||
|
|
||||||
// Determinar output wires
|
|
||||||
if variable.alarm_enabled && (variable.alarm_min.is_some() || variable.alarm_max.is_some())
|
|
||||||
{
|
|
||||||
let (alarm_id, alarm_node) = alarm::alarm_check_node(
|
|
||||||
variable,
|
|
||||||
project_id,
|
|
||||||
tab_id,
|
|
||||||
600,
|
|
||||||
y_pos,
|
|
||||||
vec![vec![aggregator_id.clone()], vec![mqtt_alarm_id.clone()]],
|
|
||||||
);
|
|
||||||
all_nodes.push(alarm_node);
|
|
||||||
|
|
||||||
// OPC UA read chain → alarm
|
|
||||||
let chain_nodes = opcua::opcua_read_chain(
|
|
||||||
variable,
|
|
||||||
&endpoint_id,
|
&endpoint_id,
|
||||||
device,
|
device,
|
||||||
tab_id,
|
tab_id,
|
||||||
300,
|
300,
|
||||||
y_pos,
|
y,
|
||||||
vec![vec![alarm_id]],
|
vec![vec![aggregator_id.to_string()]]
|
||||||
);
|
);
|
||||||
for (_, node) in chain_nodes {
|
for (_, node) in chain {
|
||||||
all_nodes.push(node);
|
all_nodes.push(node);
|
||||||
}
|
}
|
||||||
} else {
|
|
||||||
// OPC UA read chain → aggregator
|
|
||||||
let chain_nodes = opcua::opcua_read_chain(
|
|
||||||
variable,
|
|
||||||
&endpoint_id,
|
|
||||||
device,
|
|
||||||
tab_id,
|
|
||||||
300,
|
|
||||||
y_pos,
|
|
||||||
vec![vec![aggregator_id.clone()]],
|
|
||||||
);
|
|
||||||
for (_, node) in chain_nodes {
|
|
||||||
all_nodes.push(node);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Genera el archivo package.json para las dependencias npm del contenedor Node-RED.
|
/// Genera el archivo package.json para las dependencias npm.
|
||||||
pub fn generate_package_json(npm_packages: &[String]) -> String {
|
pub fn generate_package_json(npm_packages: &[String]) -> String {
|
||||||
let mut deps = serde_json::Map::new();
|
let mut deps = serde_json::Map::new();
|
||||||
for pkg in npm_packages {
|
for pkg in npm_packages {
|
||||||
@@ -398,7 +216,6 @@ pub fn generate_package_json(npm_packages: &[String]) -> String {
|
|||||||
|
|
||||||
let package = json!({
|
let package = json!({
|
||||||
"name": "omnioil-scada-flows",
|
"name": "omnioil-scada-flows",
|
||||||
"description": "Auto-generated Node-RED SCADA configuration by OmniOil",
|
|
||||||
"version": "1.0.0",
|
"version": "1.0.0",
|
||||||
"dependencies": deps
|
"dependencies": deps
|
||||||
});
|
});
|
||||||
@@ -419,14 +236,22 @@ mod tests {
|
|||||||
id: Uuid::new_v4(),
|
id: Uuid::new_v4(),
|
||||||
name: "Test SCADA".to_string(),
|
name: "Test SCADA".to_string(),
|
||||||
client: "Test Client".to_string(),
|
client: "Test Client".to_string(),
|
||||||
|
operator_name: "Test Operator".to_string(),
|
||||||
|
contract_number: "CNT-001".to_string(),
|
||||||
description: Some("Test project".to_string()),
|
description: Some("Test project".to_string()),
|
||||||
metadata: serde_json::json!({}),
|
metadata: serde_json::json!({}),
|
||||||
is_active: true,
|
is_active: true,
|
||||||
installer_status: Some("NOT_STARTED".to_string()),
|
installer_status: Some("NOT_STARTED".to_string()),
|
||||||
installer_url: None,
|
installer_url: None,
|
||||||
last_health_report: None,
|
last_health_report: None,
|
||||||
|
municipality: None,
|
||||||
|
department: None,
|
||||||
|
latitude: None,
|
||||||
|
longitude: None,
|
||||||
created_at: Utc::now(),
|
created_at: Utc::now(),
|
||||||
updated_at: Utc::now(),
|
updated_at: Utc::now(),
|
||||||
|
mqtt_password: None,
|
||||||
|
agent_master_secret: None,
|
||||||
};
|
};
|
||||||
|
|
||||||
let asset = EdgeAsset {
|
let asset = EdgeAsset {
|
||||||
@@ -437,6 +262,12 @@ mod tests {
|
|||||||
asset_type: EdgeAssetType::Pozo,
|
asset_type: EdgeAssetType::Pozo,
|
||||||
is_active: true,
|
is_active: true,
|
||||||
metadata: serde_json::json!({}),
|
metadata: serde_json::json!({}),
|
||||||
|
last_calibration_date: None,
|
||||||
|
next_calibration_date: None,
|
||||||
|
calibration_certificate_url: None,
|
||||||
|
external_api_url: None,
|
||||||
|
external_api_key: None,
|
||||||
|
is_collector_enabled: false,
|
||||||
created_at: Utc::now(),
|
created_at: Utc::now(),
|
||||||
updated_at: Utc::now(),
|
updated_at: Utc::now(),
|
||||||
};
|
};
|
||||||
@@ -459,12 +290,12 @@ mod tests {
|
|||||||
device_id: device.id,
|
device_id: device.id,
|
||||||
address: "HR:40001".to_string(),
|
address: "HR:40001".to_string(),
|
||||||
data_type: VariableDataType::Float32,
|
data_type: VariableDataType::Float32,
|
||||||
alias: "Presion Cabeza".to_string(),
|
alias: "Presion".to_string(),
|
||||||
unit: Some("PSI".to_string()),
|
unit: Some("PSI".to_string()),
|
||||||
polling_interval_ms: 5000,
|
polling_interval_ms: 1000,
|
||||||
alarm_min: Some(50.0),
|
alarm_min: None,
|
||||||
alarm_max: Some(200.0),
|
alarm_max: None,
|
||||||
alarm_enabled: true,
|
alarm_enabled: false,
|
||||||
byte_order: "BE".to_string(),
|
byte_order: "BE".to_string(),
|
||||||
metadata: serde_json::json!({}),
|
metadata: serde_json::json!({}),
|
||||||
is_enabled: true,
|
is_enabled: true,
|
||||||
@@ -485,61 +316,32 @@ mod tests {
|
|||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn test_generate_flows_produces_valid_json() {
|
fn test_modbus_node_generation_integrity() {
|
||||||
let config = make_test_config();
|
let config = make_test_config();
|
||||||
let mqtt_config = MqttBrokerConfig::default();
|
let result = generate_flows(&config).unwrap();
|
||||||
let result = generate_flows(&config, &mqtt_config).unwrap();
|
let nodes: Vec<Value> = serde_json::from_str(&result.flows_json).unwrap();
|
||||||
|
|
||||||
// Parse the JSON to verify it's valid
|
// 1. Debe existir un modbus-client
|
||||||
let parsed: Vec<Value> = serde_json::from_str(&result.flows_json).unwrap();
|
let client = nodes.iter().find(|n| n["type"] == "modbus-client")
|
||||||
|
.expect("Falta el nodo modbus-client");
|
||||||
|
|
||||||
// Should have multiple nodes
|
let client_id = client["id"].as_str().unwrap();
|
||||||
assert!(
|
assert!(client["tcpHost"].is_string());
|
||||||
parsed.len() > 5,
|
assert!(client["tcpPort"].is_number()); // Validamos el fix de tipo número
|
||||||
"Expected multiple nodes, got {}",
|
|
||||||
parsed.len()
|
|
||||||
);
|
|
||||||
|
|
||||||
// Should have at least one tab
|
// 2. Debe existir un modbus-read
|
||||||
let tabs: Vec<&Value> = parsed
|
let read = nodes.iter().find(|n| n["type"] == "modbus-read")
|
||||||
.iter()
|
.expect("Falta el nodo modbus-read");
|
||||||
.filter(|n| n.get("type").and_then(|t| t.as_str()) == Some("tab"))
|
|
||||||
.collect();
|
|
||||||
assert!(!tabs.is_empty(), "Expected at least one tab node");
|
|
||||||
|
|
||||||
// Should have modbus-client config
|
// 3. El modbus-read debe apuntar al ID del cliente
|
||||||
let modbus_clients: Vec<&Value> = parsed
|
assert_eq!(read["server"].as_str(), Some(client_id), "El nodo de lectura no apunta al cliente correcto");
|
||||||
.iter()
|
|
||||||
.filter(|n| n.get("type").and_then(|t| t.as_str()) == Some("modbus-client"))
|
|
||||||
.collect();
|
|
||||||
assert!(!modbus_clients.is_empty(), "Expected modbus-client node");
|
|
||||||
|
|
||||||
// Should have modbus-read nodes
|
// 4. Validar tipos de datos críticos para evitar 'Nodos Rojos'
|
||||||
let modbus_reads: Vec<&Value> = parsed
|
assert!(read["unitid"].is_string(), "unitid debe ser string para compatibilidad");
|
||||||
.iter()
|
assert!(read["adr"].is_string(), "adr debe ser string");
|
||||||
.filter(|n| n.get("type").and_then(|t| t.as_str()) == Some("modbus-read"))
|
assert!(read["quantity"].is_string(), "quantity debe ser string");
|
||||||
.collect();
|
|
||||||
assert_eq!(modbus_reads.len(), 2, "Expected 2 modbus-read nodes");
|
|
||||||
|
|
||||||
// Should require node-red-contrib-modbus
|
// 5. Validar que el paquete npm esté en la lista
|
||||||
assert!(
|
assert!(result.npm_packages.contains(&"node-red-contrib-modbus".to_string()));
|
||||||
result
|
|
||||||
.npm_packages
|
|
||||||
.contains(&"node-red-contrib-modbus".to_string())
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn test_generate_package_json() {
|
|
||||||
let packages = vec![
|
|
||||||
"node-red-contrib-modbus".to_string(),
|
|
||||||
"node-red-contrib-opcua".to_string(),
|
|
||||||
];
|
|
||||||
let pkg_json = generate_package_json(&packages);
|
|
||||||
let parsed: Value = serde_json::from_str(&pkg_json).unwrap();
|
|
||||||
|
|
||||||
assert_eq!(parsed["name"], "omnioil-scada-flows");
|
|
||||||
assert!(parsed["dependencies"]["node-red-contrib-modbus"].is_string());
|
|
||||||
assert!(parsed["dependencies"]["node-red-contrib-opcua"].is_string());
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
108
services/flow-generator/src/nodes/agent_out.rs
Normal file
108
services/flow-generator/src/nodes/agent_out.rs
Normal file
@@ -0,0 +1,108 @@
|
|||||||
|
//! Agent Output node generators (HTTP Request to Edge Agent)
|
||||||
|
|
||||||
|
use super::common::generate_node_id;
|
||||||
|
use serde_json::{Value, json};
|
||||||
|
|
||||||
|
/// Genera un nodo HTTP Request para enviar datos al Edge Agent local.
|
||||||
|
pub fn agent_http_out_node(
|
||||||
|
tab_id: &str,
|
||||||
|
name: &str,
|
||||||
|
x: u32,
|
||||||
|
y: u32,
|
||||||
|
) -> (String, Value) {
|
||||||
|
let id = generate_node_id();
|
||||||
|
// Usamos host.docker.internal para hablar con el agente que corre en el host Windows
|
||||||
|
let url = "http://host.docker.internal:8081/api/edge/data";
|
||||||
|
|
||||||
|
let node = json!({
|
||||||
|
"id": id,
|
||||||
|
"type": "http request",
|
||||||
|
"z": tab_id,
|
||||||
|
"name": name,
|
||||||
|
"method": "POST",
|
||||||
|
"ret": "obj",
|
||||||
|
"paytoqs": "ignore",
|
||||||
|
"url": url,
|
||||||
|
"tls": "",
|
||||||
|
"persist": false,
|
||||||
|
"proxy": "",
|
||||||
|
"insecureHTTPParser": false,
|
||||||
|
"authType": "",
|
||||||
|
"senderr": false,
|
||||||
|
"headers": [],
|
||||||
|
"x": x,
|
||||||
|
"y": y,
|
||||||
|
"wires": [[]]
|
||||||
|
});
|
||||||
|
|
||||||
|
(id, node)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Genera un nodo Function que agrega todos los valores de las variables
|
||||||
|
/// leídas en un objeto JSON antes de enviarlo por HTTP al agente.
|
||||||
|
pub fn telemetry_aggregator_node(
|
||||||
|
_project_id: &str,
|
||||||
|
_project_name: &str,
|
||||||
|
asset_id: &str,
|
||||||
|
asset_name: &str,
|
||||||
|
device_name: &str,
|
||||||
|
tab_id: &str,
|
||||||
|
x: u32,
|
||||||
|
y: u32,
|
||||||
|
wires: Vec<Vec<String>>,
|
||||||
|
) -> (String, Value) {
|
||||||
|
let id = generate_node_id();
|
||||||
|
|
||||||
|
// Genera código JS optimizado (Estructura IDEAL)
|
||||||
|
let func_code = format!(
|
||||||
|
r#"// 1. Almacenar valor en contexto de flujo
|
||||||
|
var key = msg.topic;
|
||||||
|
var data = flow.get("telemetry_data") || {{}};
|
||||||
|
data[key] = {{
|
||||||
|
value: msg.payload,
|
||||||
|
unit: msg.unit || "",
|
||||||
|
timestamp: new Date().toISOString()
|
||||||
|
}};
|
||||||
|
flow.set("telemetry_data", data);
|
||||||
|
|
||||||
|
// 2. Estrategia de Batching (Throttle de 100ms)
|
||||||
|
if (!flow.get("batch_timer")) {{
|
||||||
|
var timer = setTimeout(function() {{
|
||||||
|
var finalData = flow.get("telemetry_data");
|
||||||
|
|
||||||
|
// Estructura Ideal: Identidad (UUID), Nombre Amigable y Datos
|
||||||
|
node.send({{
|
||||||
|
payload: {{
|
||||||
|
asset_id: "{asset_id}",
|
||||||
|
asset: "{asset_name}",
|
||||||
|
device: "{device_name}",
|
||||||
|
timestamp: new Date().toISOString(),
|
||||||
|
variables: finalData
|
||||||
|
}}
|
||||||
|
}});
|
||||||
|
|
||||||
|
flow.set("batch_timer", null);
|
||||||
|
}}, 100);
|
||||||
|
|
||||||
|
flow.set("batch_timer", timer);
|
||||||
|
}}
|
||||||
|
|
||||||
|
return null;"#,
|
||||||
|
asset_id = asset_id,
|
||||||
|
asset_name = asset_name,
|
||||||
|
device_name = device_name,
|
||||||
|
);
|
||||||
|
|
||||||
|
let node = super::common::function_node(
|
||||||
|
&id,
|
||||||
|
tab_id,
|
||||||
|
&format!("📦 Aggregator: {}", device_name),
|
||||||
|
&func_code,
|
||||||
|
1,
|
||||||
|
x,
|
||||||
|
y,
|
||||||
|
wires,
|
||||||
|
);
|
||||||
|
|
||||||
|
(id, node)
|
||||||
|
}
|
||||||
@@ -3,3 +3,4 @@ pub mod modbus;
|
|||||||
pub mod opcua;
|
pub mod opcua;
|
||||||
pub mod mqtt_out;
|
pub mod mqtt_out;
|
||||||
pub mod alarm;
|
pub mod alarm;
|
||||||
|
pub mod agent_out;
|
||||||
|
|||||||
@@ -8,9 +8,8 @@ use super::common::generate_node_id;
|
|||||||
pub fn modbus_client_node(device: &EdgeDevice) -> (String, Value) {
|
pub fn modbus_client_node(device: &EdgeDevice) -> (String, Value) {
|
||||||
let id = generate_node_id();
|
let id = generate_node_id();
|
||||||
let tcp_type = match device.protocol {
|
let tcp_type = match device.protocol {
|
||||||
DeviceProtocol::ModbusTcp => "TCP",
|
DeviceProtocol::ModbusTcp => "tcp",
|
||||||
DeviceProtocol::ModbusRtu => "RTU",
|
_ => "tcp",
|
||||||
_ => "TCP",
|
|
||||||
};
|
};
|
||||||
|
|
||||||
let unit_id = device.protocol_config
|
let unit_id = device.protocol_config
|
||||||
@@ -28,17 +27,24 @@ pub fn modbus_client_node(device: &EdgeDevice) -> (String, Value) {
|
|||||||
.and_then(|v| v.as_u64())
|
.and_then(|v| v.as_u64())
|
||||||
.unwrap_or(5000);
|
.unwrap_or(5000);
|
||||||
|
|
||||||
|
// Si el host es local, traducirlo a host.docker.internal para que el contenedor lo vea
|
||||||
|
let tcp_host = if device.host == "localhost" || device.host == "127.0.0.1" {
|
||||||
|
"host.docker.internal"
|
||||||
|
} else {
|
||||||
|
&device.host
|
||||||
|
};
|
||||||
|
|
||||||
let node = json!({
|
let node = json!({
|
||||||
"id": id,
|
"id": id,
|
||||||
"type": "modbus-client",
|
"type": "modbus-client",
|
||||||
"name": format!("{} ({}:{})", device.name, device.host, device.port),
|
"name": format!("{} ({}:{})", device.name, tcp_host, device.port),
|
||||||
"clienttype": tcp_type,
|
"clienttype": tcp_type,
|
||||||
"bufferCommands": true,
|
"bufferCommands": true,
|
||||||
"stateLogEnabled": false,
|
"stateLogEnabled": false,
|
||||||
"queueLogEnabled": false,
|
"queueLogEnabled": false,
|
||||||
"failureLogEnabled": true,
|
"failureLogEnabled": true,
|
||||||
"tcpHost": device.host,
|
"tcpHost": tcp_host,
|
||||||
"tcpPort": device.port.to_string(),
|
"tcpPort": device.port, // Debe ser número
|
||||||
"tcpType": "DEFAULT",
|
"tcpType": "DEFAULT",
|
||||||
"serialPort": "",
|
"serialPort": "",
|
||||||
"serialType": "RTU-BUFFERED",
|
"serialType": "RTU-BUFFERED",
|
||||||
@@ -48,7 +54,7 @@ pub fn modbus_client_node(device: &EdgeDevice) -> (String, Value) {
|
|||||||
"serialParity": "none",
|
"serialParity": "none",
|
||||||
"serialConnectionDelay": "100",
|
"serialConnectionDelay": "100",
|
||||||
"serialAsciiResponseStartDelimiter": "0x3A",
|
"serialAsciiResponseStartDelimiter": "0x3A",
|
||||||
"unit_id": unit_id,
|
"unit_id": unit_id.to_string(),
|
||||||
"commandDelay": "1",
|
"commandDelay": "1",
|
||||||
"clientTimeout": timeout_ms,
|
"clientTimeout": timeout_ms,
|
||||||
"reconnectOnTimeout": true,
|
"reconnectOnTimeout": true,
|
||||||
@@ -70,7 +76,9 @@ fn parse_modbus_address(address: &str) -> (&str, u32) {
|
|||||||
let addr = parts[1].parse::<u32>().unwrap_or(0);
|
let addr = parts[1].parse::<u32>().unwrap_or(0);
|
||||||
(register_type, addr)
|
(register_type, addr)
|
||||||
} else {
|
} else {
|
||||||
("HR", 0)
|
// Si no hay dos partes, intentamos extraer solo el número y asumimos HR por defecto
|
||||||
|
let addr = address.parse::<u32>().unwrap_or(0);
|
||||||
|
("HR", addr)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -101,6 +109,7 @@ pub fn modbus_read_node(
|
|||||||
variable: &EdgeVariable,
|
variable: &EdgeVariable,
|
||||||
server_id: &str,
|
server_id: &str,
|
||||||
tab_id: &str,
|
tab_id: &str,
|
||||||
|
unit_id: u8,
|
||||||
x: u32,
|
x: u32,
|
||||||
y: u32,
|
y: u32,
|
||||||
wires: Vec<Vec<String>>,
|
wires: Vec<Vec<String>>,
|
||||||
@@ -120,7 +129,7 @@ pub fn modbus_read_node(
|
|||||||
"logIOActivities": false,
|
"logIOActivities": false,
|
||||||
"showErrors": false,
|
"showErrors": false,
|
||||||
"showWarnings": true,
|
"showWarnings": true,
|
||||||
"unitid": "",
|
"unitid": unit_id.to_string(),
|
||||||
"dataType": data_type,
|
"dataType": data_type,
|
||||||
"adr": address.to_string(),
|
"adr": address.to_string(),
|
||||||
"quantity": quantity.to_string(),
|
"quantity": quantity.to_string(),
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
//! MQTT Output node generators for telemetry forwarding
|
//! MQTT Output node generators for telemetry forwarding
|
||||||
|
|
||||||
use serde_json::{json, Value};
|
|
||||||
use super::common::generate_node_id;
|
use super::common::generate_node_id;
|
||||||
|
use serde_json::{Value, json};
|
||||||
|
|
||||||
/// Genera un nodo de configuración MQTT Broker.
|
/// Genera un nodo de configuración MQTT Broker.
|
||||||
pub fn mqtt_broker_config_node(
|
pub fn mqtt_broker_config_node(
|
||||||
|
|||||||
@@ -8,10 +8,16 @@ use super::common::generate_node_id;
|
|||||||
pub fn opcua_endpoint_node(device: &EdgeDevice) -> (String, Value) {
|
pub fn opcua_endpoint_node(device: &EdgeDevice) -> (String, Value) {
|
||||||
let id = generate_node_id();
|
let id = generate_node_id();
|
||||||
|
|
||||||
|
let host = if device.host == "localhost" || device.host == "127.0.0.1" {
|
||||||
|
"host.docker.internal"
|
||||||
|
} else {
|
||||||
|
&device.host
|
||||||
|
};
|
||||||
|
|
||||||
let endpoint_url = device.protocol_config
|
let endpoint_url = device.protocol_config
|
||||||
.get("endpoint_url")
|
.get("endpoint_url")
|
||||||
.and_then(|v| v.as_str())
|
.and_then(|v| v.as_str())
|
||||||
.unwrap_or(&format!("opc.tcp://{}:{}", device.host, device.port))
|
.unwrap_or(&format!("opc.tcp://{}:{}", host, device.port))
|
||||||
.to_string();
|
.to_string();
|
||||||
|
|
||||||
let security_policy = device.protocol_config
|
let security_policy = device.protocol_config
|
||||||
|
|||||||
@@ -4,6 +4,12 @@ FROM node:22-alpine AS builder
|
|||||||
# Argumentos de construcción (Dokploy los pasará aquí)
|
# Argumentos de construcción (Dokploy los pasará aquí)
|
||||||
ARG VITE_API_BASE
|
ARG VITE_API_BASE
|
||||||
ENV VITE_API_BASE=$VITE_API_BASE
|
ENV VITE_API_BASE=$VITE_API_BASE
|
||||||
|
ARG VITE_ADMIN_APP_ORIGIN
|
||||||
|
ENV VITE_ADMIN_APP_ORIGIN=$VITE_ADMIN_APP_ORIGIN
|
||||||
|
ARG VITE_MOBILE_APP_ORIGIN
|
||||||
|
ENV VITE_MOBILE_APP_ORIGIN=$VITE_MOBILE_APP_ORIGIN
|
||||||
|
ARG VITE_API_URL
|
||||||
|
ENV VITE_API_URL=$VITE_API_URL
|
||||||
|
|
||||||
# =============================================================================
|
# =============================================================================
|
||||||
# PNPM CONFIGURATION
|
# PNPM CONFIGURATION
|
||||||
@@ -15,8 +21,9 @@ WORKDIR /app
|
|||||||
COPY package.json ./
|
COPY package.json ./
|
||||||
COPY pnpm-lock.yaml* ./
|
COPY pnpm-lock.yaml* ./
|
||||||
|
|
||||||
# Instalar dependencias usando PNPM (más rápido que NPM)
|
# Instalar dependencias usando PNPM (con caché persistente)
|
||||||
RUN pnpm install --frozen-lockfile || pnpm install
|
RUN --mount=type=cache,id=pnpm,target=/pnpm/store \
|
||||||
|
pnpm install --frozen-lockfile --aggregate-output
|
||||||
|
|
||||||
# Copiar el resto del código fuente del frontend
|
# Copiar el resto del código fuente del frontend
|
||||||
COPY . .
|
COPY . .
|
||||||
@@ -30,27 +37,39 @@ RUN pnpm build
|
|||||||
FROM nginx:alpine
|
FROM nginx:alpine
|
||||||
COPY --from=builder /app/dist /usr/share/nginx/html
|
COPY --from=builder /app/dist /usr/share/nginx/html
|
||||||
|
|
||||||
# Configuración Nginx optimizada para SPA y Proxy Inverso
|
# Configuración Nginx optimizada para SPA y Cache Control
|
||||||
RUN echo 'server { \
|
RUN echo 'server { \
|
||||||
listen 80; \
|
listen 80; \
|
||||||
\
|
|
||||||
# Ruta principal para la SPA (React/Vue) \
|
|
||||||
location / { \
|
|
||||||
root /usr/share/nginx/html; \
|
root /usr/share/nginx/html; \
|
||||||
index index.html index.htm; \
|
index index.html; \
|
||||||
|
\
|
||||||
|
# Cache Control para el index.html (NUNCA cachear) \
|
||||||
|
location = /index.html { \
|
||||||
|
add_header Cache-Control "no-store, no-cache, must-revalidate, proxy-revalidate, max-age=0"; \
|
||||||
|
} \
|
||||||
|
\
|
||||||
|
# Cache Control para assets estáticos (Vite usa hashes, así que es seguro cachear) \
|
||||||
|
location /assets/ { \
|
||||||
|
expires 1y; \
|
||||||
|
add_header Cache-Control "public, immutable"; \
|
||||||
|
} \
|
||||||
|
\
|
||||||
|
# Ruta principal para la SPA \
|
||||||
|
location / { \
|
||||||
try_files $uri $uri/ /index.html; \
|
try_files $uri $uri/ /index.html; \
|
||||||
} \
|
} \
|
||||||
\
|
\
|
||||||
# Proxy para evitar errores de conexión y CORS \
|
# Proxy para la API con re-resolución DNS automática \
|
||||||
location /api/ { \
|
location /api/ { \
|
||||||
proxy_pass http://backend-api:8000; \
|
resolver 127.0.0.11 valid=30s; \
|
||||||
|
set $backend_upstream backend-api; \
|
||||||
|
proxy_pass http://$backend_upstream:8000; \
|
||||||
proxy_http_version 1.1; \
|
proxy_http_version 1.1; \
|
||||||
proxy_set_header Upgrade $http_upgrade; \
|
proxy_set_header Upgrade $http_upgrade; \
|
||||||
proxy_set_header Connection "upgrade"; \
|
proxy_set_header Connection "upgrade"; \
|
||||||
proxy_set_header Host $host; \
|
proxy_set_header Host $host; \
|
||||||
proxy_cache_bypass $http_upgrade; \
|
|
||||||
} \
|
} \
|
||||||
}' > /etc/nginx/conf.d/default.conf
|
}' > /etc/nginx/conf.d/default.conf
|
||||||
|
|
||||||
EXPOSE 80
|
EXPOSE 80
|
||||||
CMD ["nginx", "-g", "daemon off;"]
|
CMD ["nginx", "-g", "daemon off;"]
|
||||||
|
|||||||
7735
services/frontend-admin/package-lock.json
generated
7735
services/frontend-admin/package-lock.json
generated
File diff suppressed because it is too large
Load Diff
@@ -17,12 +17,14 @@
|
|||||||
"clsx": "^2.1.1",
|
"clsx": "^2.1.1",
|
||||||
"geist": "^1.7.0",
|
"geist": "^1.7.0",
|
||||||
"lucide-react": "^0.577.0",
|
"lucide-react": "^0.577.0",
|
||||||
|
"qrcode.react": "^4.2.0",
|
||||||
"radix-ui": "^1.4.3",
|
"radix-ui": "^1.4.3",
|
||||||
"react": "^19.2.0",
|
"react": "^19.2.0",
|
||||||
"react-dom": "^19.2.0",
|
"react-dom": "^19.2.0",
|
||||||
"react-hook-form": "^7.71.2",
|
"react-hook-form": "^7.71.2",
|
||||||
"react-router-dom": "^7.13.1",
|
"react-router-dom": "^7.13.1",
|
||||||
"recharts": "^3.8.0",
|
"recharts": "^3.8.0",
|
||||||
|
"sonner": "^2.0.7",
|
||||||
"tailwind-merge": "^3.5.0",
|
"tailwind-merge": "^3.5.0",
|
||||||
"tw-animate-css": "^1.4.0",
|
"tw-animate-css": "^1.4.0",
|
||||||
"zod": "^4.3.6",
|
"zod": "^4.3.6",
|
||||||
|
|||||||
2406
services/frontend-admin/pnpm-lock.yaml
generated
2406
services/frontend-admin/pnpm-lock.yaml
generated
File diff suppressed because it is too large
Load Diff
@@ -1,4 +1,6 @@
|
|||||||
import { Outlet, NavLink, useNavigate } from 'react-router-dom'
|
import { useState, useEffect } from 'react'
|
||||||
|
import { Outlet, NavLink, useNavigate, useLocation } from 'react-router-dom'
|
||||||
|
import { Toaster } from 'sonner'
|
||||||
import { useAuthStore } from '@/features/auth/stores/auth-store'
|
import { useAuthStore } from '@/features/auth/stores/auth-store'
|
||||||
import {
|
import {
|
||||||
LayoutDashboard,
|
LayoutDashboard,
|
||||||
@@ -11,32 +13,52 @@ import {
|
|||||||
FolderOpen,
|
FolderOpen,
|
||||||
Activity,
|
Activity,
|
||||||
Globe2,
|
Globe2,
|
||||||
HardDrive
|
HardDrive,
|
||||||
|
Users,
|
||||||
|
Menu,
|
||||||
|
X,
|
||||||
|
ChevronRight,
|
||||||
|
Code2
|
||||||
} from 'lucide-react'
|
} from 'lucide-react'
|
||||||
import { Badge } from '@/components/ui/badge'
|
import { Badge } from '@/components/ui/badge'
|
||||||
import { cn } from '@/lib/utils/cn'
|
import { cn } from '@/lib/utils/cn'
|
||||||
|
|
||||||
const navSections = [
|
interface NavItem {
|
||||||
|
to: string
|
||||||
|
label: string
|
||||||
|
icon: any
|
||||||
|
badge?: number
|
||||||
|
roles?: string[]
|
||||||
|
}
|
||||||
|
|
||||||
|
interface NavSection {
|
||||||
|
label: string
|
||||||
|
items: NavItem[]
|
||||||
|
}
|
||||||
|
|
||||||
|
const navSections: NavSection[] = [
|
||||||
{
|
{
|
||||||
label: 'GENERAL',
|
label: 'GENERAL',
|
||||||
items: [
|
items: [
|
||||||
{ to: '/app/admin/dashboard', label: 'Panorama', icon: LayoutDashboard },
|
{ to: '/app/admin/dashboard', label: 'Panorama', icon: LayoutDashboard, roles: ['admin', 'superadmin', 'auditor'] },
|
||||||
{ to: '/app/admin/projects', label: 'Proyectos Edge', icon: FolderOpen },
|
{ to: '/app/admin/projects', label: 'Proyectos Edge', icon: FolderOpen, roles: ['admin', 'superadmin'] },
|
||||||
{ to: '/app/admin/validation', label: 'Auditoría', icon: ShieldCheck },
|
{ to: '/app/admin/validation', label: 'Auditoría', icon: ShieldCheck, roles: ['admin', 'superadmin'] },
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
label: 'OPERACIONES & ANALÍTICA',
|
label: 'OPERACIONES & ANALÍTICA',
|
||||||
items: [
|
items: [
|
||||||
{ to: '/app/admin/anh-report', label: 'Reportes ANH', icon: FileJson },
|
{ to: '/app/admin/anh-report', label: 'Reportes ANH', icon: FileJson, roles: ['admin', 'superadmin', 'auditor'] },
|
||||||
{ to: '/app/admin/realtime', label: 'Telemetría', icon: Activity },
|
{ to: '/app/admin/realtime', label: 'Telemetría', icon: Activity, roles: ['admin', 'superadmin'] },
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
label: 'CONFIGURACIÓN',
|
label: 'CONFIGURACIÓN',
|
||||||
items: [
|
items: [
|
||||||
{ to: '/app/admin/settings', label: 'Ajustes Globales', icon: Settings },
|
{ to: '/app/admin/users', label: 'Usuarios', icon: Users, roles: ['admin', 'superadmin'] },
|
||||||
{ to: '/app/admin/alerts', label: 'Alertas', icon: Bell, badge: 3 },
|
{ to: '/app/admin/api-explorer', label: 'API & Docs', icon: Code2, roles: ['admin', 'superadmin', 'auditor'] },
|
||||||
|
{ to: '/app/admin/settings', label: 'Ajustes Globales', icon: Settings, roles: ['admin', 'superadmin'] },
|
||||||
|
{ to: '/app/admin/alerts', label: 'Alertas', icon: Bell, badge: 3, roles: ['admin', 'superadmin'] },
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
]
|
]
|
||||||
@@ -48,6 +70,10 @@ export default function AdminLayout() {
|
|||||||
const activeProjectId = useAuthStore((s) => s.activeProjectId)
|
const activeProjectId = useAuthStore((s) => s.activeProjectId)
|
||||||
const setActiveProject = useAuthStore((s) => s.setActiveProject)
|
const setActiveProject = useAuthStore((s) => s.setActiveProject)
|
||||||
const navigate = useNavigate()
|
const navigate = useNavigate()
|
||||||
|
const location = useLocation()
|
||||||
|
|
||||||
|
const [isSidebarOpen, setIsSidebarOpen] = useState(false)
|
||||||
|
const [isHovered, setIsHovered] = useState(false)
|
||||||
|
|
||||||
const activeProject = projects.find((p) => p.id === activeProjectId)
|
const activeProject = projects.find((p) => p.id === activeProjectId)
|
||||||
|
|
||||||
@@ -56,132 +82,185 @@ export default function AdminLayout() {
|
|||||||
navigate('/login')
|
navigate('/login')
|
||||||
}
|
}
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
setIsSidebarOpen(false)
|
||||||
|
}, [location.pathname])
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (isSidebarOpen) {
|
||||||
|
document.body.style.overflow = 'hidden'
|
||||||
|
} else {
|
||||||
|
document.body.style.overflow = 'unset'
|
||||||
|
}
|
||||||
|
}, [isSidebarOpen])
|
||||||
|
|
||||||
|
const filteredSections = navSections.map(section => ({
|
||||||
|
...section,
|
||||||
|
items: section.items.filter(item => !item.roles || item.roles.includes(user?.role || ''))
|
||||||
|
})).filter(section => section.items.length > 0);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex h-screen bg-background overflow-hidden selection:bg-primary/30">
|
<div className="flex h-screen bg-[#050505] text-foreground font-inter overflow-hidden">
|
||||||
{/* SIDEBAR - Panel Izquierdo Glassmórfico */}
|
<header className="md:hidden fixed top-0 w-full h-16 px-4 flex items-center justify-between border-b border-border/40 bg-background/80 backdrop-blur-xl z-30">
|
||||||
<aside className="relative flex flex-col w-72 shrink-0 border-r border-border/40 glass-card z-10 m-3 rounded-2xl overflow-hidden shadow-2xl">
|
<div className="flex items-center gap-2">
|
||||||
{/* Decoración de fondo */}
|
<div className="h-8 w-8 rounded-lg bg-primary flex items-center justify-center shadow-lg shadow-primary/20">
|
||||||
|
<Droplets className="h-4 w-4 text-primary-foreground" />
|
||||||
|
</div>
|
||||||
|
<span className="font-black text-sm tracking-tight">OmniOil</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<button
|
||||||
|
onClick={() => setIsSidebarOpen(true)}
|
||||||
|
className="p-2 rounded-xl bg-secondary/50 border border-border/50 text-foreground"
|
||||||
|
>
|
||||||
|
<Menu className="h-5 w-5" />
|
||||||
|
</button>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
{isSidebarOpen && (
|
||||||
|
<div
|
||||||
|
className="fixed inset-0 bg-background/60 backdrop-blur-sm z-40 md:hidden transition-opacity duration-300"
|
||||||
|
onClick={() => setIsSidebarOpen(false)}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* SIDEBAR INTELIGENTE (Auto-colapso) */}
|
||||||
|
<aside
|
||||||
|
onMouseEnter={() => setIsHovered(true)}
|
||||||
|
onMouseLeave={() => setIsHovered(false)}
|
||||||
|
className={cn(
|
||||||
|
"fixed inset-y-0 left-0 z-50 md:z-20 flex flex-col shrink-0 border-r border-border/40 bg-zinc-950/40 backdrop-blur-3xl m-0 md:m-3 rounded-none md:rounded-3xl overflow-hidden shadow-2xl transition-all duration-500 ease-in-out md:relative md:translate-x-0",
|
||||||
|
isSidebarOpen ? "translate-x-0 w-[280px]" : "-translate-x-full md:translate-x-0",
|
||||||
|
!isSidebarOpen && (isHovered ? "md:w-72" : "md:w-24")
|
||||||
|
)}
|
||||||
|
>
|
||||||
<div className="absolute top-0 inset-x-0 h-40 bg-gradient-to-b from-primary/10 to-transparent pointer-events-none" />
|
<div className="absolute top-0 inset-x-0 h-40 bg-gradient-to-b from-primary/10 to-transparent pointer-events-none" />
|
||||||
|
|
||||||
{/* Brand/Logo */}
|
<div className="flex items-center justify-between px-6 h-20 shrink-0 relative z-10">
|
||||||
<div className="flex items-center gap-3 px-6 h-20 shrink-0 relative z-10">
|
<div className="flex items-center gap-3">
|
||||||
<div className="flex items-center justify-center h-10 w-10 rounded-xl bg-gradient-to-br from-primary to-primary/60 shadow-[0_0_15px_rgba(var(--primary),0.3)] shrink-0">
|
<div className="flex items-center justify-center h-10 w-10 rounded-xl bg-gradient-to-br from-primary to-primary/60 shadow-[0_0_15px_rgba(var(--primary),0.3)] shrink-0 transition-transform duration-500">
|
||||||
<Droplets className="h-5 w-5 text-primary-foreground" />
|
<Droplets className="h-5 w-5 text-primary-foreground" />
|
||||||
</div>
|
</div>
|
||||||
<div className="min-w-0">
|
<div className={cn("transition-all duration-500 overflow-hidden", isHovered || isSidebarOpen ? "w-32 opacity-100" : "w-0 opacity-0")}>
|
||||||
<h1 className="text-base font-black tracking-wide text-foreground leading-tight">OmniOil</h1>
|
<h1 className="text-base font-black tracking-wide text-white leading-tight">OmniOil</h1>
|
||||||
<p className="text-xs text-primary font-medium uppercase tracking-widest mt-0.5">Control Center</p>
|
<p className="text-[10px] text-primary font-black uppercase tracking-widest mt-0.5">Edge Cloud</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
<button
|
||||||
|
onClick={() => setIsSidebarOpen(false)}
|
||||||
|
className="md:hidden p-2 rounded-lg hover:bg-secondary/80 text-muted-foreground"
|
||||||
|
>
|
||||||
|
<X className="h-5 w-5" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
{/* Global Project Selector */}
|
<div className="px-5 mb-6 relative z-10">
|
||||||
<div className="px-5 mb-4 relative z-10">
|
<div className="bg-white/5 border border-white/10 rounded-2xl p-3 backdrop-blur-md overflow-hidden">
|
||||||
<div className="bg-background/40 border border-border/50 rounded-xl p-3 backdrop-blur-md">
|
<div className={cn("flex items-center gap-2 mb-2 text-[10px] font-black text-muted-foreground uppercase tracking-widest transition-opacity duration-300", isHovered || isSidebarOpen ? "opacity-100" : "opacity-0")}>
|
||||||
<div className="flex items-center gap-2 mb-2 text-xs font-semibold text-muted-foreground uppercase tracking-wider">
|
<HardDrive className="h-3.5 w-3.5 text-primary" /> Proyecto
|
||||||
<HardDrive className="h-3.5 w-3.5 text-primary" /> Proyecto Activo
|
|
||||||
</div>
|
</div>
|
||||||
{projects.length === 0 ? (
|
<div className="flex items-center justify-center min-h-[30px]">
|
||||||
<span className="text-sm text-muted-foreground">Sin proyectos asignados</span>
|
{!(isHovered || isSidebarOpen) ? (
|
||||||
) : projects.length === 1 ? (
|
<div className="h-2 w-2 rounded-full bg-primary animate-pulse shadow-[0_0_8px_rgba(var(--primary),0.5)]" />
|
||||||
<span className="text-sm font-semibold text-foreground glow-text">{activeProject?.name}</span>
|
|
||||||
) : (
|
) : (
|
||||||
|
<div className="w-full">
|
||||||
|
{projects.length <= 1 ? (
|
||||||
|
<span className="text-xs font-black text-white truncate block">{activeProject?.name}</span>
|
||||||
|
) : (
|
||||||
|
<div className="relative">
|
||||||
<select
|
<select
|
||||||
value={activeProjectId ?? ''}
|
value={activeProjectId ?? ''}
|
||||||
onChange={(e) => setActiveProject(e.target.value)}
|
onChange={(e) => setActiveProject(e.target.value)}
|
||||||
className="w-full text-sm font-medium bg-transparent border-0 text-foreground focus:outline-none focus:ring-0 cursor-pointer overflow-hidden text-ellipsis"
|
className="w-full appearance-none text-xs font-black bg-transparent border-0 text-white focus:outline-none focus:ring-0 cursor-pointer pr-8 truncate uppercase tracking-tighter"
|
||||||
>
|
>
|
||||||
{!activeProjectId && (
|
|
||||||
<option value="" disabled>Seleccione un proyecto</option>
|
|
||||||
)}
|
|
||||||
{projects.map((p) => (
|
{projects.map((p) => (
|
||||||
<option key={p.id} value={p.id} className="bg-sidebar text-foreground">{p.name}</option>
|
<option key={p.id} value={p.id} className="bg-zinc-900 text-white uppercase">{p.name}</option>
|
||||||
))}
|
))}
|
||||||
</select>
|
</select>
|
||||||
|
<ChevronRight className="absolute right-0 top-1/2 -translate-y-1/2 h-3 w-3 text-primary rotate-90" />
|
||||||
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Navigation Wrapper */}
|
<nav className="flex-1 overflow-y-auto custom-sidebar-scroll px-3 space-y-8 pb-6 relative z-10">
|
||||||
<div className="flex-1 overflow-y-auto custom-scrollbar px-3 space-y-6 pb-6 relative z-10">
|
{filteredSections.map((section) => (
|
||||||
{navSections.map((section) => (
|
|
||||||
<div key={section.label}>
|
<div key={section.label}>
|
||||||
<div className="px-4 mb-2">
|
<div className={cn("px-4 mb-3 transition-opacity duration-300", isHovered || isSidebarOpen ? "opacity-100" : "opacity-0 h-0 overflow-hidden")}>
|
||||||
<span className="text-[10px] font-bold uppercase tracking-widest text-muted-foreground/80">
|
<span className="text-[10px] font-black uppercase tracking-[0.2em] text-muted-foreground/50">
|
||||||
{section.label}
|
{section.label}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
<div className="space-y-1">
|
<div className="space-y-1.5">
|
||||||
{section.items.map((item) => (
|
{section.items.map((item) => (
|
||||||
<NavLink
|
<NavLink
|
||||||
key={item.to}
|
key={item.to}
|
||||||
to={item.to}
|
to={item.to}
|
||||||
className={({ isActive }) =>
|
className={({ isActive }) =>
|
||||||
cn(
|
cn(
|
||||||
'group flex items-center gap-3 rounded-xl px-4 py-2.5 text-sm font-medium transition-all duration-200',
|
'group flex items-center rounded-2xl px-5 py-3 text-sm font-black transition-all duration-300',
|
||||||
isActive
|
isActive
|
||||||
? 'bg-primary/10 text-primary shadow-[inset_2px_0_0_0_oklch(0.65_0.18_160)]'
|
? 'bg-primary/10 text-primary shadow-[inset_4px_0_0_0_oklch(0.65_0.18_160)]'
|
||||||
: 'text-muted-foreground hover:bg-secondary/60 hover:text-foreground'
|
: 'text-muted-foreground hover:bg-white/5 hover:text-white'
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
>
|
>
|
||||||
<item.icon className="h-4 w-4 shrink-0 transition-transform group-hover:scale-110" />
|
<item.icon className={cn("h-5 w-5 shrink-0 transition-transform group-hover:scale-110", isHovered || isSidebarOpen ? "mr-4" : "mx-auto")} />
|
||||||
<span className="flex-1">{item.label}</span>
|
<span className={cn("flex-1 transition-all duration-500 overflow-hidden uppercase tracking-tight text-xs", isHovered || isSidebarOpen ? "opacity-100 w-auto" : "opacity-0 w-0")}>
|
||||||
{'badge' in item && item.badge ? (
|
{item.label}
|
||||||
<Badge className="bg-destructive/10 text-destructive border-0 text-[10px] h-5 px-1.5 ml-auto">
|
</span>
|
||||||
{item.badge}
|
|
||||||
</Badge>
|
|
||||||
) : null}
|
|
||||||
</NavLink>
|
</NavLink>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
))}
|
))}
|
||||||
</div>
|
</nav>
|
||||||
|
|
||||||
{/* Server Status (Real) */}
|
<div className={cn("p-4 relative z-10 border-t border-white/5 transition-all duration-500", isHovered || isSidebarOpen ? "opacity-100" : "opacity-0 h-0 p-0 overflow-hidden")}>
|
||||||
<div className="px-5 pb-2 pt-4 relative z-10">
|
<div className="flex items-center gap-2 px-3 py-3 rounded-2xl bg-emerald-500/5 border border-emerald-500/10">
|
||||||
<div className="flex items-center gap-2 px-3 py-2 rounded-xl bg-success/5 border border-success/10">
|
<Globe2 className="h-4 w-4 text-emerald-500 shrink-0" />
|
||||||
<Globe2 className="h-4 w-4 text-success shrink-0" />
|
<div className="flex-1 text-[10px]">
|
||||||
<div className="flex-1 text-xs">
|
<p className="text-white font-black uppercase">Edge Gateway</p>
|
||||||
<p className="text-foreground font-medium">Panel Gateway</p>
|
<p className="text-emerald-500 font-bold opacity-80">Syncing</p>
|
||||||
<p className="text-success opacity-80">En línea y Operativo</p>
|
|
||||||
</div>
|
</div>
|
||||||
<div className="h-2 w-2 rounded-full bg-success animate-pulse" />
|
<div className="h-1.5 w-1.5 rounded-full bg-emerald-500 animate-pulse" />
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Footer User Profile */}
|
|
||||||
<div className="p-4 relative z-10">
|
<div className="p-4 relative z-10">
|
||||||
<div className="bg-background/20 rounded-xl border border-border/30 p-2 flex items-center gap-3">
|
<div className={cn("bg-white/5 rounded-2xl border border-white/10 p-2 flex items-center gap-3 transition-all duration-500", !(isHovered || isSidebarOpen) && "justify-center")}>
|
||||||
<div className="h-9 w-9 rounded-lg bg-secondary flex items-center justify-center shrink-0 border border-border/50">
|
<div className="h-10 w-10 rounded-xl bg-white/5 flex items-center justify-center shrink-0 border border-white/10">
|
||||||
<span className="text-sm font-bold text-foreground">
|
<span className="text-xs font-black text-primary">
|
||||||
{user?.email?.[0]?.toUpperCase() ?? 'U'}
|
{user?.email?.[0]?.toUpperCase() ?? 'U'}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
|
{(isHovered || isSidebarOpen) && (
|
||||||
<div className="flex-1 min-w-0">
|
<div className="flex-1 min-w-0">
|
||||||
<p className="text-sm font-semibold text-foreground truncate">{user?.email}</p>
|
<p className="text-xs font-black text-white truncate tracking-tighter">{user?.email}</p>
|
||||||
<p className="text-[10px] text-muted-foreground uppercase tracking-widest">{user?.role}</p>
|
<p className="text-[10px] text-muted-foreground uppercase font-black">{user?.role}</p>
|
||||||
</div>
|
</div>
|
||||||
|
)}
|
||||||
|
{(isHovered || isSidebarOpen) && (
|
||||||
<button
|
<button
|
||||||
onClick={handleLogout}
|
onClick={handleLogout}
|
||||||
title="Cerrar Sessión"
|
className="p-2 text-muted-foreground hover:text-red-500 hover:bg-red-500/10 rounded-xl transition-colors"
|
||||||
className="p-2 text-muted-foreground hover:text-destructive hover:bg-destructive/10 rounded-lg transition-colors"
|
|
||||||
aria-label="Cerrar sesión"
|
|
||||||
>
|
>
|
||||||
<LogOut className="h-4 w-4" />
|
<LogOut className="h-4 w-4" />
|
||||||
</button>
|
</button>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</aside>
|
</aside>
|
||||||
|
|
||||||
{/* ÁREA PRINCIPAL */}
|
<main className="flex-1 relative flex flex-col min-w-0 overflow-y-auto custom-scrollbar pt-16 md:pt-0">
|
||||||
<main className="flex-1 relative flex flex-col min-w-0 overflow-y-auto custom-scrollbar">
|
<div className="absolute top-0 left-0 right-0 h-[40vh] bg-gradient-to-br from-primary/5 via-background to-background pointer-events-none" />
|
||||||
{/* Fondo gradiente sutil principal */}
|
<div className="relative flex-1 p-4 md:p-10 w-full">
|
||||||
<div className="absolute top-0 left-0 right-0 h-[30vh] bg-gradient-to-br from-primary/5 via-background to-background pointer-events-none" />
|
|
||||||
|
|
||||||
<div className="relative flex-1 p-6 md:p-8 lg:p-10 w-full max-w-[1600px] mx-auto z-10">
|
|
||||||
<Outlet />
|
<Outlet />
|
||||||
</div>
|
</div>
|
||||||
|
<Toaster richColors position="bottom-right" />
|
||||||
</main>
|
</main>
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -24,7 +24,7 @@ export default function ProtectedRoute({ allowedRoles }: ProtectedRouteProps) {
|
|||||||
}
|
}
|
||||||
}, [redirect.externalHref])
|
}, [redirect.externalHref])
|
||||||
|
|
||||||
if (!token) {
|
if (!token || !user) {
|
||||||
return <Navigate to="/login" replace />
|
return <Navigate to="/login" replace />
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -9,9 +9,11 @@ import CreateProjectPage from '@/features/admin/projects/pages/CreateProjectPage
|
|||||||
import ProjectDetailsPage from '@/features/admin/projects/pages/ProjectDetailsPage'
|
import ProjectDetailsPage from '@/features/admin/projects/pages/ProjectDetailsPage'
|
||||||
import ProtectedRoute from './ProtectedRoute'
|
import ProtectedRoute from './ProtectedRoute'
|
||||||
|
|
||||||
|
const UsersPage = lazy(() => import('@/features/admin/users/pages/UsersPage'))
|
||||||
const TanksPage = lazy(() => import('@/features/admin/tanks/pages/TanksPage'))
|
const TanksPage = lazy(() => import('@/features/admin/tanks/pages/TanksPage'))
|
||||||
const TankDetailPage = lazy(() => import('@/features/admin/tanks/pages/TankDetailPage'))
|
const TankDetailPage = lazy(() => import('@/features/admin/tanks/pages/TankDetailPage'))
|
||||||
|
const ANHReportPage = lazy(() => import('@/features/admin/anh-reports/pages/ANHReportPage'))
|
||||||
|
const APIExplorerPage = lazy(() => import('@/features/admin/api-explorer/pages/APIExplorerPage'))
|
||||||
|
|
||||||
// Placeholder pages for routes not yet implemented
|
// Placeholder pages for routes not yet implemented
|
||||||
function ComingSoon({ title }: { title: string }) {
|
function ComingSoon({ title }: { title: string }) {
|
||||||
@@ -34,7 +36,6 @@ export const router = createBrowserRouter([
|
|||||||
path: '/register',
|
path: '/register',
|
||||||
element: <RegisterPage />,
|
element: <RegisterPage />,
|
||||||
},
|
},
|
||||||
|
|
||||||
{
|
{
|
||||||
path: '/app/admin',
|
path: '/app/admin',
|
||||||
element: <ProtectedRoute />,
|
element: <ProtectedRoute />,
|
||||||
@@ -49,7 +50,9 @@ export const router = createBrowserRouter([
|
|||||||
{ path: 'projects/:id', element: <ProjectDetailsPage /> },
|
{ path: 'projects/:id', element: <ProjectDetailsPage /> },
|
||||||
{ path: 'validation', element: <ComingSoon title="Centro de Validación" /> },
|
{ path: 'validation', element: <ComingSoon title="Centro de Validación" /> },
|
||||||
{ path: 'alerts', element: <ComingSoon title="Panel de Alertas" /> },
|
{ path: 'alerts', element: <ComingSoon title="Panel de Alertas" /> },
|
||||||
{ path: 'anh-report', element: <ComingSoon title="Reporte ANH" /> },
|
{ path: 'anh-report', element: <Suspense fallback={null}><ANHReportPage /></Suspense> },
|
||||||
|
{ path: 'api-explorer', element: <Suspense fallback={null}><APIExplorerPage /></Suspense> },
|
||||||
|
{ path: 'users', element: <Suspense fallback={null}><UsersPage /></Suspense> },
|
||||||
{ path: 'settings', element: <ComingSoon title="Configuración" /> },
|
{ path: 'settings', element: <ComingSoon title="Configuración" /> },
|
||||||
{ path: 'hf/tanks', element: <Suspense fallback={null}><TanksPage /></Suspense> },
|
{ path: 'hf/tanks', element: <Suspense fallback={null}><TanksPage /></Suspense> },
|
||||||
{ path: 'hf/tanks/:id', element: <Suspense fallback={null}><TankDetailPage /></Suspense> },
|
{ path: 'hf/tanks/:id', element: <Suspense fallback={null}><TankDetailPage /></Suspense> },
|
||||||
|
|||||||
@@ -0,0 +1,71 @@
|
|||||||
|
import { useEffect } from 'react';
|
||||||
|
|
||||||
|
// Declaration for the global variable injected by Vite
|
||||||
|
declare global {
|
||||||
|
const __BUILD_TIME__: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* AutoUpdateChecker component
|
||||||
|
* Periodically checks if a new version of the app is available on the server.
|
||||||
|
* If a new version is detected, it reloads the page to ensure the user has the latest version.
|
||||||
|
*/
|
||||||
|
export const AutoUpdateChecker = () => {
|
||||||
|
useEffect(() => {
|
||||||
|
// Only run this in production
|
||||||
|
if (import.meta.env.DEV) return;
|
||||||
|
|
||||||
|
const checkVersion = async () => {
|
||||||
|
try {
|
||||||
|
// Fetch the version.json file from the server
|
||||||
|
// Add a cache-busting timestamp to the request itself to ensure we get the latest file
|
||||||
|
const response = await fetch(`/version.json?t=${new Date().getTime()}`, {
|
||||||
|
cache: 'no-store',
|
||||||
|
});
|
||||||
|
|
||||||
|
if (response.ok) {
|
||||||
|
const data = await response.json();
|
||||||
|
const serverVersion = data.buildTime;
|
||||||
|
|
||||||
|
// Compare server version with the build time injected at compile time
|
||||||
|
// Use a small 1-second threshold to avoid issues with slight build variances
|
||||||
|
if (serverVersion && serverVersion > (__BUILD_TIME__ + 1000)) {
|
||||||
|
const lastReloadedVersion = localStorage.getItem('last_reloaded_version');
|
||||||
|
|
||||||
|
if (lastReloadedVersion !== String(serverVersion)) {
|
||||||
|
console.log('New version detected, reloading...');
|
||||||
|
localStorage.setItem('last_reloaded_version', String(serverVersion));
|
||||||
|
window.location.reload();
|
||||||
|
} else {
|
||||||
|
console.warn('New version detected but already reloaded once. Stopping loop.');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error checking version:', error);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// Check version on mount
|
||||||
|
checkVersion();
|
||||||
|
|
||||||
|
// Check version when the page becomes visible again (e.g., user returns to the tab)
|
||||||
|
const handleVisibilityChange = () => {
|
||||||
|
if (document.visibilityState === 'visible') {
|
||||||
|
checkVersion();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// Check version every 5 minutes
|
||||||
|
const interval = setInterval(checkVersion, 1000 * 60 * 5);
|
||||||
|
|
||||||
|
document.addEventListener('visibilitychange', handleVisibilityChange);
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
clearInterval(interval);
|
||||||
|
document.removeEventListener('visibilitychange', handleVisibilityChange);
|
||||||
|
};
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
return null; // This component doesn't render anything
|
||||||
|
};
|
||||||
@@ -0,0 +1,32 @@
|
|||||||
|
import { apiClient } from '@/lib/api/client'
|
||||||
|
|
||||||
|
export interface ANHReport {
|
||||||
|
id: string
|
||||||
|
filename: string
|
||||||
|
generated_at: string
|
||||||
|
status: 'GENERATED' | 'SENT' | 'FAILED'
|
||||||
|
hash_sha384: string
|
||||||
|
file_size_bytes: number
|
||||||
|
report_period_start: string
|
||||||
|
report_period_end: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function listANHReports(token: string): Promise<ANHReport[]> {
|
||||||
|
return apiClient<ANHReport[]>('/anh/reports', { token })
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function getReportPreview(id: string, token: string): Promise<any> {
|
||||||
|
const report = await apiClient<ANHReport>(`/anh/reports/${id}`, { token })
|
||||||
|
return report.content
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getReportDownloadUrl(id: string, token: string): string {
|
||||||
|
// Retornamos la URL completa para usar con window.open o un <a>
|
||||||
|
const baseUrl = import.meta.env.VITE_API_URL || 'http://localhost:8000/api'
|
||||||
|
return `${baseUrl}/anh/reports/${id}/download`
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getReportHashUrl(id: string, token: string): string {
|
||||||
|
const baseUrl = import.meta.env.VITE_API_URL || 'http://localhost:8000/api'
|
||||||
|
return `${baseUrl}/anh/reports/${id}/hash`
|
||||||
|
}
|
||||||
@@ -0,0 +1,256 @@
|
|||||||
|
import { useState } from 'react'
|
||||||
|
import { useQuery } from '@tanstack/react-query'
|
||||||
|
import {
|
||||||
|
FileJson,
|
||||||
|
Download,
|
||||||
|
Eye,
|
||||||
|
CheckCircle2,
|
||||||
|
Clock,
|
||||||
|
AlertCircle,
|
||||||
|
Search,
|
||||||
|
Filter,
|
||||||
|
FileCode,
|
||||||
|
Copy,
|
||||||
|
Check,
|
||||||
|
Hash,
|
||||||
|
X
|
||||||
|
} from 'lucide-react'
|
||||||
|
import { useAuthStore } from '@/features/auth/stores/auth-store'
|
||||||
|
import {
|
||||||
|
listANHReports,
|
||||||
|
getReportPreview,
|
||||||
|
getReportDownloadUrl,
|
||||||
|
getReportHashUrl,
|
||||||
|
type ANHReport
|
||||||
|
} from '../api/anh-api'
|
||||||
|
import { Button } from '@/components/ui/button'
|
||||||
|
import { Badge } from '@/components/ui/badge'
|
||||||
|
import { toast } from 'sonner'
|
||||||
|
|
||||||
|
export default function ANHReportPage() {
|
||||||
|
const token = useAuthStore((s) => s.token)
|
||||||
|
const [selectedReportId, setSelectedReportId] = useState<string | null>(null)
|
||||||
|
const [copiedHash, setCopiedHash] = useState<string | null>(null)
|
||||||
|
|
||||||
|
const { data: reports, isLoading } = useQuery({
|
||||||
|
queryKey: ['anh-reports'],
|
||||||
|
queryFn: () => listANHReports(token!),
|
||||||
|
enabled: !!token
|
||||||
|
})
|
||||||
|
|
||||||
|
const { data: previewData, isLoading: isPreviewLoading } = useQuery({
|
||||||
|
queryKey: ['anh-report-preview', selectedReportId],
|
||||||
|
queryFn: () => getReportPreview(selectedReportId!, token!),
|
||||||
|
enabled: !!selectedReportId && !!token
|
||||||
|
})
|
||||||
|
|
||||||
|
const copyToClipboard = (text: string, id: string) => {
|
||||||
|
navigator.clipboard.writeText(text)
|
||||||
|
setCopiedHash(id)
|
||||||
|
toast.success('Hash copiado al portapapeles')
|
||||||
|
setTimeout(() => setCopiedHash(null), 2000)
|
||||||
|
}
|
||||||
|
|
||||||
|
const selectedReport = reports?.find(r => r.id === selectedReportId)
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-8 animate-in fade-in slide-in-from-bottom-4 duration-700">
|
||||||
|
<div className="flex flex-col md:flex-row md:items-end justify-between gap-6">
|
||||||
|
<div>
|
||||||
|
<div className="flex items-center gap-3 mb-2">
|
||||||
|
<div className="h-10 w-10 rounded-xl bg-primary/10 flex items-center justify-center">
|
||||||
|
<FileJson className="h-6 w-6 text-primary" />
|
||||||
|
</div>
|
||||||
|
<h1 className="text-3xl font-black tracking-tight text-white uppercase">Reportes ANH</h1>
|
||||||
|
</div>
|
||||||
|
<p className="text-muted-foreground text-sm max-w-2xl font-medium">
|
||||||
|
Gestión y auditoría de archivos JSON según Resolución 0651.
|
||||||
|
Todos los archivos incluyen firma digital y hash SHA-384 para integridad.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Button className="rounded-2xl font-black uppercase tracking-wider h-12 px-8 shadow-xl shadow-primary/20">
|
||||||
|
Generar Nuevo Reporte
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="grid grid-cols-1 lg:grid-cols-3 gap-8">
|
||||||
|
{/* Historial */}
|
||||||
|
<div className="lg:col-span-2 space-y-6">
|
||||||
|
<div className="bg-zinc-950/40 backdrop-blur-xl border border-white/5 rounded-[2.5rem] p-8">
|
||||||
|
<div className="flex items-center justify-between mb-8">
|
||||||
|
<h2 className="text-lg font-black text-white uppercase tracking-widest flex items-center gap-3">
|
||||||
|
<Clock className="h-5 w-5 text-primary" /> Historial de Envío
|
||||||
|
</h2>
|
||||||
|
<div className="flex gap-2">
|
||||||
|
<div className="relative">
|
||||||
|
<Search className="absolute left-3 top-1/2 -translate-y-1/2 h-4 w-4 text-muted-foreground" />
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
placeholder="Buscar..."
|
||||||
|
className="bg-white/5 border border-white/10 rounded-xl py-2 pl-10 pr-4 text-xs focus:outline-none focus:ring-1 focus:ring-primary w-48 transition-all"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<Button variant="outline" size="icon" className="rounded-xl border-white/10 bg-white/5">
|
||||||
|
<Filter className="h-4 w-4" />
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="overflow-hidden">
|
||||||
|
<table className="w-full">
|
||||||
|
<thead>
|
||||||
|
<tr className="text-left border-b border-white/5">
|
||||||
|
<th className="pb-4 text-[10px] font-black text-muted-foreground uppercase tracking-[0.2em]">Archivo</th>
|
||||||
|
<th className="pb-4 text-[10px] font-black text-muted-foreground uppercase tracking-[0.2em]">Estado</th>
|
||||||
|
<th className="pb-4 text-[10px] font-black text-muted-foreground uppercase tracking-[0.2em]">Fecha</th>
|
||||||
|
<th className="pb-4 text-right text-[10px] font-black text-muted-foreground uppercase tracking-[0.2em]">Acciones</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody className="divide-y divide-white/5">
|
||||||
|
{reports?.map((report) => (
|
||||||
|
<tr
|
||||||
|
key={report.id}
|
||||||
|
className={cn(
|
||||||
|
"group transition-colors hover:bg-white/[0.02] cursor-pointer",
|
||||||
|
selectedReportId === report.id && "bg-primary/5"
|
||||||
|
)}
|
||||||
|
onClick={() => setSelectedReportId(report.id)}
|
||||||
|
>
|
||||||
|
<td className="py-5">
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<div className="h-9 w-9 rounded-lg bg-zinc-900 border border-white/5 flex items-center justify-center shrink-0">
|
||||||
|
<FileCode className="h-5 w-5 text-muted-foreground group-hover:text-primary transition-colors" />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<p className="text-sm font-bold text-white tracking-tight">{report.filename}</p>
|
||||||
|
<p className="text-[10px] text-muted-foreground font-medium">{(report.file_size_bytes / 1024).toFixed(1)} KB</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
<td className="py-5">
|
||||||
|
{report.status === 'SENT' ? (
|
||||||
|
<Badge className="bg-emerald-500/10 text-emerald-500 border-emerald-500/20 rounded-lg px-2 py-0.5 text-[10px] font-black uppercase">Enviado</Badge>
|
||||||
|
) : report.status === 'GENERATED' ? (
|
||||||
|
<Badge className="bg-amber-500/10 text-amber-500 border-amber-500/20 rounded-lg px-2 py-0.5 text-[10px] font-black uppercase">Pendiente</Badge>
|
||||||
|
) : (
|
||||||
|
<Badge variant="destructive" className="rounded-lg px-2 py-0.5 text-[10px] font-black uppercase">Error</Badge>
|
||||||
|
)}
|
||||||
|
</td>
|
||||||
|
<td className="py-5 text-sm text-muted-foreground font-medium">
|
||||||
|
{new Date(report.generated_at).toLocaleDateString()}
|
||||||
|
</td>
|
||||||
|
<td className="py-5 text-right">
|
||||||
|
<div className="flex items-center justify-end gap-2">
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="icon"
|
||||||
|
className="h-8 w-8 rounded-lg hover:bg-primary/20 hover:text-primary"
|
||||||
|
onClick={(e) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
const url = getReportDownloadUrl(report.id, token!);
|
||||||
|
window.open(url, '_blank');
|
||||||
|
toast.info('Iniciando descarga...');
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Download className="h-4 w-4" />
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="icon"
|
||||||
|
className="h-8 w-8 rounded-lg hover:bg-white/10"
|
||||||
|
>
|
||||||
|
<Eye className="h-4 w-4" />
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
))}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Panel Lateral: Vista Previa y Hash */}
|
||||||
|
<div className="space-y-6">
|
||||||
|
<div className="bg-zinc-950/40 backdrop-blur-xl border border-white/5 rounded-[2.5rem] p-8 sticky top-8">
|
||||||
|
{!selectedReportId ? (
|
||||||
|
<div className="h-[500px] flex flex-col items-center justify-center text-center p-6 border-2 border-dashed border-white/5 rounded-3xl">
|
||||||
|
<div className="h-16 w-16 rounded-2xl bg-white/5 flex items-center justify-center mb-4">
|
||||||
|
<FileJson className="h-8 w-8 text-muted-foreground/30" />
|
||||||
|
</div>
|
||||||
|
<h3 className="text-white font-bold mb-1">Vista Previa</h3>
|
||||||
|
<p className="text-xs text-muted-foreground">Selecciona un reporte del historial para ver su contenido y verificar su integridad.</p>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="space-y-8 animate-in fade-in slide-in-from-right-4 duration-500">
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<h3 className="text-lg font-black text-white uppercase tracking-widest">Detalle del Archivo</h3>
|
||||||
|
<Button variant="ghost" size="icon" onClick={() => setSelectedReportId(null)}>
|
||||||
|
<X className="h-4 w-4" />
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="space-y-4">
|
||||||
|
<div className="p-4 rounded-2xl bg-white/5 border border-white/10">
|
||||||
|
<div className="flex items-center gap-3 mb-3">
|
||||||
|
<Hash className="h-4 w-4 text-primary" />
|
||||||
|
<span className="text-[10px] font-black text-muted-foreground uppercase tracking-widest">Hash SHA-384</span>
|
||||||
|
</div>
|
||||||
|
<div className="relative group">
|
||||||
|
<div className="bg-black/40 rounded-xl p-3 text-[10px] font-mono break-all text-muted-foreground/80 border border-white/5 pr-10">
|
||||||
|
{selectedReport?.hash_sha384}
|
||||||
|
</div>
|
||||||
|
<Button
|
||||||
|
size="icon"
|
||||||
|
variant="ghost"
|
||||||
|
className="absolute right-2 top-1/2 -translate-y-1/2 h-7 w-7 rounded-lg opacity-0 group-hover:opacity-100 transition-opacity"
|
||||||
|
onClick={() => copyToClipboard(selectedReport?.hash_sha384 || '', selectedReport?.id || '')}
|
||||||
|
>
|
||||||
|
{copiedHash === selectedReport?.id ? <Check className="h-3 w-3 text-emerald-500" /> : <Copy className="h-3 w-3" />}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="p-4 rounded-2xl bg-white/5 border border-white/10">
|
||||||
|
<div className="flex items-center gap-3 mb-4">
|
||||||
|
<Eye className="h-4 w-4 text-primary" />
|
||||||
|
<span className="text-[10px] font-black text-muted-foreground uppercase tracking-widest">Vista Previa (Estructura)</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="bg-black/60 rounded-2xl p-4 h-[300px] overflow-y-auto custom-scrollbar border border-white/5 font-mono text-[11px] leading-relaxed">
|
||||||
|
{isPreviewLoading ? (
|
||||||
|
<div className="h-full flex items-center justify-center">
|
||||||
|
<div className="h-5 w-5 border-2 border-primary border-t-transparent rounded-full animate-spin" />
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<pre className="text-emerald-500/90 whitespace-pre-wrap">
|
||||||
|
{JSON.stringify(previewData, null, 2)}
|
||||||
|
</pre>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Button
|
||||||
|
className="w-full rounded-2xl font-black uppercase tracking-wider py-6 h-auto shadow-lg shadow-primary/10"
|
||||||
|
onClick={() => {
|
||||||
|
const url = getReportDownloadUrl(selectedReportId, token!);
|
||||||
|
window.open(url, '_blank');
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Download className="mr-2 h-4 w-4" /> Descargar JSON + Hash
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function cn(...inputs: any[]) {
|
||||||
|
return inputs.filter(Boolean).join(' ')
|
||||||
|
}
|
||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user