55 Commits

Author SHA1 Message Date
Wilman Yesid Farfan Diaz
2f2b8f3538 feat: implement edge agent service core, industrial simulators, and build orchestration tools 2026-05-02 09:02:23 -05:00
Wilman Yesid Farfan Diaz
3bdd7b7594 feat: implement edge agent, build orchestrator, ANH reporting service, and Node-RED E2E test infrastructure 2026-05-01 19:09:20 -05:00
Wilman Yesid Farfan Diaz
58448b5496 feat: implement base microservice architecture with relay, generator, and edge-agent services 2026-04-29 22:11:23 -05:00
dc0d9b99a8 feat(frontend-pwa): mejora flujo de sincronizacion y UX de campo 2026-04-29 20:52:05 -05:00
Wilman Yesid Farfan Diaz
ccb25a91a3 feat: add project-wide Docker support including build orchestrator and frontend service containers 2026-04-27 22:25:50 -05:00
Wilman Yesid Farfan Diaz
83c3713bea feat: implement telemetry ingestor service and build-orchestrator image registry push functionality 2026-04-27 21:24:39 -05:00
Wilman Yesid Farfan Diaz
67840dac0e feat: implement periodic container health checker with auto-restart and rollback capabilities 2026-04-25 23:55:30 -05:00
Wilman Yesid Farfan Diaz
3d6bcf66b2 feat: add DashboardPage and ProjectDetailsPage to frontend-admin feature modules 2026-04-25 23:36:45 -05:00
Wilman Yesid Farfan Diaz
c1a7ab0458 chore: update MinIO configuration, adjust service dependencies, and pass S3 bucket environment variable to MQTT service 2026-04-25 23:25:56 -05:00
Wilman Yesid Farfan Diaz
c1b5d8a72e feat: implement real-time dashboard page with WebSocket telemetry integration and historical data visualization 2026-04-25 22:54:25 -05:00
Wilman Yesid Farfan Diaz
1adeb7448e feat: implement frontend PWA and Admin services with Vite, Docker configurations, and routing logic. 2026-04-25 22:42:17 -05:00
Wilman Yesid Farfan Diaz
42e7f20d2d feat: bootstrap initial project architecture, shared libraries, and core service modules for edge, backend, and frontend applications. 2026-04-25 21:52:46 -05:00
Wilman Yesid Farfan Diaz
c7648ff0e6 feat: add Dockerfile for frontend-admin and configure VITE_ADMIN_APP_ORIGIN in docker-compose 2026-04-25 21:15:55 -05:00
Wilman Yesid Farfan Diaz
d22b4191ae feat: implement dashboard page with real-time monitoring and operational stats overview 2026-04-25 20:51:13 -05:00
Wilman Yesid Farfan Diaz
8131bb3093 feat: add dashboard and project details pages with domain-based routing configuration 2026-04-25 20:47:35 -05:00
Wilman Yesid Farfan Diaz
86df19afe9 feat: implement admin dashboard and project details pages for monitoring and asset management 2026-04-25 20:43:59 -05:00
Wilman Yesid Farfan Diaz
b18e0af4c3 feat: implement cross-domain routing logic and initial admin dashboard page architecture 2026-04-25 20:40:22 -05:00
Wilman Yesid Farfan Diaz
546b82e560 feat: initialize core services, MQTT infrastructure, and backend API with telemetry processing and watchdog monitoring 2026-04-25 19:18:07 -05:00
Wilman Yesid Farfan Diaz
e227eccb57 Reconstrucci[on validada. 2026-04-25 16:39:18 -05:00
Wilman Yesid Farfan Diaz
76113ab9d2 Cargar valores por defecto.
Actualización de nuevas variables de entorno y comunicación.
2026-04-25 13:42:47 -05:00
Wilman Yesid Farfan Diaz
4ab80e24bc feat: implement backend API service with MQTT telemetry handling and edge system management routes 2026-04-24 13:51:57 -05:00
Wilman Yesid Farfan Diaz
42ffe50202 feat: implement dynamic MQTT configuration and automated edge-agent binary injection in build orchestrator 2026-04-24 13:42:56 -05:00
Wilman Yesid Farfan Diaz
036e99edbe Eliminacion de basura 2026-04-24 10:26:40 -05:00
Wilman Yesid Farfan Diaz
9a15f77095 feat: add Mosquitto entrypoint script and configuration template for dynamic PostgreSQL authentication setup 2026-04-24 09:20:41 -05:00
Wilman Yesid Farfan Diaz
fa33e268a3 feat: initialize core microservices architecture including telemetry, backend API, build orchestrator, edge-agent, and admin dashboard. 2026-04-24 00:34:16 -05:00
Wilman Yesid Farfan Diaz
e83accea80 feat: implement JWT-based authentication service and initialize core infrastructure across backend, edge-agent, and dashboard services 2026-04-23 18:15:25 -05:00
Wilman Yesid Farfan Diaz
923511d5a9 refactor: replace .env.example with environment-specific .env.example.docker and .env.example.local templates and update .gitignore 2026-04-23 12:40:06 -05:00
Wilman Yesid Farfan Diaz
42cf6b9e9c Actualización de variables de entorno para pruebas locales 2026-04-21 08:22:50 -05:00
Wilman Yesid Farfan Diaz
2f229b6cfb Actualizaciones locales. 2026-04-20 21:49:37 -05:00
Wilman Yesid Farfan Diaz
790b1213c3 Actualizaci[on de agente con la configuraci[on para despliegue. 2026-04-20 20:47:32 -05:00
Wilman Yesid Farfan Diaz
4daba89ba3 Cambio de usuarios, inscripci[on activada. 2026-04-20 17:52:55 -05:00
Wilman Yesid Farfan Diaz
77f1bf07cb Despliegue completo.
Funcionalidad de despliegue por agente funcionando.
Hay que validar URL.
2026-04-19 22:33:37 -05:00
Wilman Yesid Farfan Diaz
39f1f811e6 Validaci[on de reconstrucci[on hecho. 2026-04-19 18:42:20 -05:00
Wilman Yesid Farfan Diaz
a2152e4bce Actualizaci[on de los logs del sistema.
Validaci[on de los puntos de base de datos.
2026-04-19 16:59:39 -05:00
Wilman Yesid Farfan Diaz
f051555569 Inicializaci[on de datos 2026-04-19 14:18:20 -05:00
Wilman Yesid Farfan Diaz
34e9e0a2ba Actualizado sistema a inicializaci[on. 2026-04-19 14:17:00 -05:00
5f2c5a8e11 feat(admin): gestión de usuarios con CRUD, roles, proyectos y QR
- Backend: 7 endpoints admin (CRUD usuarios, roles, proyectos por usuario)
- AdminClaims extractor con validación de rol admin (403)
- Frontend admin: página de usuarios con filtros, modal crear/editar
- QR con deep link a PWA para compartir credenciales en campo
- PWA: prefill de email desde query params del QR
- Fix: Redis restart loop (command como array en docker-compose)
- Fix: crash ProjectDetailsPage por stage undefined
- Fix: migración audit_trail compatible con schema existente
- Cleanup: eliminado RegisterPage y ruta /register del admin
2026-04-19 07:31:00 -05:00
2688509d78 fix(tests): corregir tests del flow-generator tras nuevos campos en modelos
Los structs EdgeProject y EdgeAsset fueron ampliados con campos ANH
(operador, contrato, localización, calibración) pero los tests no se
actualizaron. También se corrige la aserción de nodos modbus-read
que esperaba 2 en vez de 1 por variable.
2026-04-18 19:28:03 -05:00
Wilman Yesid Farfan Diaz
039557e8e2 Actualizaci[on de todos los elementos para completar el flujo 2026-04-18 18:52:07 -05:00
Wilman Yesid Farfan Diaz
7cc7460220 Listo para pruebas de flujo. 2026-04-17 22:43:54 -05:00
Wilman Yesid Farfan Diaz
bc09dc1f80 Cambias actualizando el sistema 2026-04-17 21:44:13 -05:00
Wilman Yesid Farfan Diaz
b2e3da2728 Ultimo cambio 2026-04-11 16:57:12 -05:00
Wilman Yesid Farfan Diaz
f94c75cdd0 Revert "Actualizaci[on de flujo concurrente en el instalador"
This reverts commit 8f239c6ff4.
2026-04-11 16:52:03 -05:00
Wilman Yesid Farfan Diaz
7b9d3998b1 Revert "Actualizaci[on de los archivos para el flujo"
This reverts commit c62c9991d6.
2026-04-11 16:50:32 -05:00
Wilman Yesid Farfan Diaz
c62c9991d6 Actualizaci[on de los archivos para el flujo 2026-04-11 16:31:03 -05:00
Wilman Yesid Farfan Diaz
8f239c6ff4 Actualizaci[on de flujo concurrente en el instalador 2026-04-11 16:03:14 -05:00
Wilman Yesid Farfan Diaz
399c203b10 Actualizada configuración de contenedores para visualizarse entre ellos 2026-04-11 14:49:04 -05:00
Wilman Yesid Farfan Diaz
eba624d58b Actualizaci[on del instalador 2026-04-11 14:22:20 -05:00
Wilman Yesid Farfan Diaz
582348ef45 Correcciones en el json generator 2026-04-11 11:06:36 -05:00
Wilman Yesid Farfan Diaz
f6e1a2e616 Actualizaci[on del metodo de sqlx 2026-04-11 10:41:40 -05:00
Wilman Yesid Farfan Diaz
66dfedaf20 Actualizaci[on del contrato y nombre del contrato con el operador 2026-04-11 10:34:01 -05:00
Wilman Yesid Farfan Diaz
f776f61430 Arreglando bucle infinito 2026-04-11 09:04:06 -05:00
Wilman Yesid Farfan Diaz
c9d3afe4c0 feat: implement auto-update mechanism and optimize Nginx cache-control policies for frontend services 2026-04-11 08:39:37 -05:00
9393886836 docs: agregar reporte de correccion auth mobile 2026-04-11 00:35:33 -05:00
5cbadb17e1 fix(auth): deshabilitar registro publico en mobile 2026-04-11 00:27:29 -05:00
201 changed files with 22060 additions and 12164 deletions

10
.cargo/config.toml Normal file
View 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"

View File

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

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

File diff suppressed because it is too large Load Diff

View File

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

View File

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

View File

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

View File

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

View 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*

View File

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

View File

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

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

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

View 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

View File

@@ -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}')

View File

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

View File

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

View File

@@ -41,4 +41,4 @@ auth_opt_log_level info
# Persistence # Persistence
persistence true persistence true
persistence_location /mosquitto/data/ persistence_location /mosquitto/data

View File

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

View File

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

View 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'
);

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

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

View 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);
}
}

View File

@@ -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."
})))
} }

View File

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

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

View File

@@ -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(&params.level) .bind(&params.level)
.bind(&params.category) .bind(&params.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(&params.level) .bind(&params.level)
.bind(&params.category) .bind(&params.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)

View File

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

View File

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

View File

@@ -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,17 +112,15 @@ 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).map_err(|_| (StatusCode::UNAUTHORIZED, "Invalid user ID".to_string()))?)
.bind(Uuid::parse_str(&claims.sub).unwrap()) .bind(id)
.bind(id) .fetch_optional(&pool)
.fetch_optional(&pool) .await
.await .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 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")
@@ -212,17 +214,15 @@ 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).map_err(|_| (StatusCode::UNAUTHORIZED, "Invalid user ID".to_string()))?)
.bind(Uuid::parse_str(&claims.sub).unwrap()) .bind(id)
.bind(id) .fetch_optional(&pool)
.fetch_optional(&pool) .await
.await .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 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;
@@ -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"
})))
}

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

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

View File

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

View File

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

View 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(&params.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))
}

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

View File

@@ -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 {
tracing::error!("MQTT connection error: {:?}", e); Ok(notification) => {
tokio::time::sleep(std::time::Duration::from_secs(5)).await; 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);
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);
}

View 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(())
}
}

View 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, &notifier).await {
tracing::error!("Watchdog error (silence check): {}", e);
}
if let Err(e) = check_unhealthy_agents(&pool, &notifier).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(())
}

View File

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

View File

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

View File

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

View 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(())
}

View File

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

View File

@@ -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) .bind(project_id)
.execute(&pool) .fetch_one(&pool)
.await?; .await?;
// 1b. Generar Credenciales MQTT para este Agente (Project Isolation) match existing {
let mqtt_user = project_id.to_string(); (Some(pass), Some(secret)) => {
let mqtt_password = Uuid::new_v4().to_string(); // Contraseña aleatoria única para este instalador 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
);
// 1c. Registrar en Infraestructura (Postgres con Auto-Hashing) // Persistir en DB para futuros rebuilds
tracing::info!("🔑 Registrando identidad MQTT dinámica en DB para el proyecto: {}", project_id); 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)
.execute(&pool)
.await?;
// Insertamos o actualizamos el usuario con hasheo Bcrypt (bf) nativo (project_id.to_string(), new_pass, new_secret)
}
}
};
// 1c. Sincronizar Identidad MQTT en DB (Postgres con Auto-Hashing para Mosquitto)
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,49 +229,127 @@ 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,
.await; deployment_id,
"OVERLAY_INJECTION",
"FAILED",
Some(json!({"error": e.to_string()})),
)
.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,
.await; "WIX_GENERATION",
"FAILED",
Some(json!({"error": format!("Timeout esperando MSI en {:?}", msi_path)})),
)
.await;
return Err("Timeout esperando MSI".into()); return Err("Timeout esperando MSI".into());
} }
tokio::time::sleep(std::time::Duration::from_secs(2)).await; tokio::time::sleep(std::time::Duration::from_secs(2)).await;
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,11 +407,15 @@ 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,
.await; "S3_UPLOAD",
"FAILED",
Some(json!({"error": format!("{:?}", e)})),
)
.await;
} }
} }

View File

@@ -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...");

View File

@@ -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 {
], key: "TZ".to_string(),
volume_mounts: vec![ value: "America/Bogota".to_string(),
VolumeMount { }],
host_path: "/opt/omnioil/nodered-data".to_string(), volume_mounts: vec![],
container_path: "/data".to_string(), flows_json: Some(flows_json.to_string()),
read_only: Some(false), package_json: Some(package_json.to_string()),
}, settings_js: Some(settings_js.to_string()),
], devices,
env_vars: vec![
EnvVar {
key: "TZ".to_string(),
value: "America/Bogota".to_string(),
},
],
}; };
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(())
} }

View File

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

View File

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

View File

@@ -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
Ok(data_map) => { let active_assets = match sqlx::query_as::<_, EdgeAsset>(
for (asset_code, (time, data)) in data_map { "SELECT * FROM edge_assets WHERE is_collector_enabled = true"
if let Err(e) = persistence::ingest_telemetry_batch(&pool, &asset_code, time, data).await { ).fetch_all(&pool).await {
error!("❌ Fatal error during ingestion for {}: {}", asset_code, e); Ok(assets) => assets,
}
}
}
Err(e) => { Err(e) => {
error!("❌ External API communication error: {}", 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) => {
if data_map.is_empty() {
warn!("⚠️ No data returned for asset {}", asset.name);
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) => {
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;
} }
} }

View File

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

View File

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

View File

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

View File

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

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

View File

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

View File

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

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

View 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;

View 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()
}
}

View 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()
}
}

View File

@@ -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();
.await; let payload = json_buf.clone();
} tokio::spawn(async move {
let _ = mqtt
// Lógica de recuperación .publish(&topic, QoS::AtMostOnce, false, payload)
match status { .await;
ContainerStatus::Running => { });
if consecutive_failures > 0 { tracing::debug!("📡 Native health report sent via MQTT for project: {}", project_id);
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);
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);
}
} }
} }
} }

View File

@@ -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();
.await; tokio::spawn(async move {
let _ = mqtt
.publish(&topic, QoS::AtLeastOnce, false, payload.as_bytes())
.await;
});
} }
match level { match level {

View File

@@ -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 report = system_validator::validate_system().await; let rt = tokio::runtime::Runtime::new()?;
system_validator::print_report(&report); rt.block_on(async {
let report = system_validator::validate_system().await;
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 {
log_reporter.log_deploy(&signal.image_tag, "RECEIVED").await; let manager = collector_manager.clone();
{ let reporter = log_reporter.clone();
let mut sig = last_deploy_signal.write().await; tokio::spawn(async move {
*sig = Some(signal.clone()); manager.update_config(devices, reporter).await;
} });
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;
} }
} }
} }

View File

@@ -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(_) => {}

View File

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

View File

@@ -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,19 +68,24 @@ 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) {
tokio::select! { Ok(config) => {
res = crate::run_agent(config) => { tokio::select! {
if let Err(e) = res { res = crate::run_agent(config) => {
tracing::error!("Agent exited with error: {}", e); if let Err(e) = res {
tracing::error!("Agent exited with error: {}", e);
}
},
_ = shutdown_rx.recv() => {
tracing::info!("Service stopping via SCM signal");
} }
},
_ = shutdown_rx.recv() => {
tracing::info!("Service stopping via SCM signal");
} }
},
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;
} }
} else {
shutdown_rx.recv().await;
} }
}); });

View File

@@ -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);
}
}
}
// 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
} }
total_free / (1024 * 1024 * 1024) // Convert to GB #[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);

View File

@@ -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");
} }

View File

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

View File

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

View File

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

View File

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

View File

@@ -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,
500,
y_pos,
vec![
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;
decoder_node["id"] = json!(decoder_id);
all_nodes.push(decoder_node);
}
// Modbus Read → Decoder
let (_, read_node) = modbus::modbus_read_node(
variable,
&modbus_client_id,
tab_id, tab_id,
200, 500,
y_pos, y,
vec![vec![decoder_id]], vec![vec![aggregator_id.to_string()]]
);
let mut decoder_node = decoder_node;
decoder_node["id"] = json!(decoder_id);
all_nodes.push(decoder_node);
let (_, read_node) = modbus::modbus_read_node(
var,
&client_id,
tab_id,
unit_id,
250,
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( &endpoint_id,
mqtt_broker_id, device,
tab_id, tab_id,
&telemetry_topic, 300,
"📤 Telemetry MQTT", y,
900, vec![vec![aggregator_id.to_string()]]
y_mqtt, );
); for (_, node) in chain {
all_nodes.push(mqtt_telemetry_node); all_nodes.push(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,
device,
tab_id,
300,
y_pos,
vec![vec![alarm_id]],
);
for (_, node) in chain_nodes {
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());
} }
} }

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -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; \
try_files $uri $uri/ /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"; \
} \ } \
\ \
# Proxy para evitar errores de conexión y CORS \ # Cache Control para assets estáticos (Vite usa hashes, así que es seguro cachear) \
location /api/ { \ location /assets/ { \
proxy_pass http://backend-api:8000; \ expires 1y; \
proxy_http_version 1.1; \ add_header Cache-Control "public, immutable"; \
proxy_set_header Upgrade $http_upgrade; \
proxy_set_header Connection "upgrade"; \
proxy_set_header Host $host; \
proxy_cache_bypass $http_upgrade; \
} \ } \
}' > /etc/nginx/conf.d/default.conf \
# Ruta principal para la SPA \
location / { \
try_files $uri $uri/ /index.html; \
} \
\
# Proxy para la API con re-resolución DNS automática \
location /api/ { \
resolver 127.0.0.11 valid=30s; \
set $backend_upstream backend-api; \
proxy_pass http://$backend_upstream:8000; \
proxy_http_version 1.1; \
proxy_set_header Upgrade $http_upgrade; \
proxy_set_header Connection "upgrade"; \
proxy_set_header Host $host; \
} \
}' > /etc/nginx/conf.d/default.conf
EXPOSE 80 EXPOSE 80
CMD ["nginx", "-g", "daemon off;"] CMD ["nginx", "-g", "daemon off;"]

File diff suppressed because it is too large Load Diff

View File

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

File diff suppressed because it is too large Load Diff

View File

@@ -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
{/* Global Project Selector */} onClick={() => setIsSidebarOpen(false)}
<div className="px-5 mb-4 relative z-10"> className="md:hidden p-2 rounded-lg hover:bg-secondary/80 text-muted-foreground"
<div className="bg-background/40 border border-border/50 rounded-xl p-3 backdrop-blur-md"> >
<div className="flex items-center gap-2 mb-2 text-xs font-semibold text-muted-foreground uppercase tracking-wider"> <X className="h-5 w-5" />
<HardDrive className="h-3.5 w-3.5 text-primary" /> Proyecto Activo </button>
</div>
<div className="px-5 mb-6 relative z-10">
<div className="bg-white/5 border border-white/10 rounded-2xl p-3 backdrop-blur-md overflow-hidden">
<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")}>
<HardDrive className="h-3.5 w-3.5 text-primary" /> Proyecto
</div>
<div className="flex items-center justify-center min-h-[30px]">
{!(isHovered || isSidebarOpen) ? (
<div className="h-2 w-2 rounded-full bg-primary animate-pulse shadow-[0_0_8px_rgba(var(--primary),0.5)]" />
) : (
<div className="w-full">
{projects.length <= 1 ? (
<span className="text-xs font-black text-white truncate block">{activeProject?.name}</span>
) : (
<div className="relative">
<select
value={activeProjectId ?? ''}
onChange={(e) => setActiveProject(e.target.value)}
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"
>
{projects.map((p) => (
<option key={p.id} value={p.id} className="bg-zinc-900 text-white uppercase">{p.name}</option>
))}
</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>
{projects.length === 0 ? (
<span className="text-sm text-muted-foreground">Sin proyectos asignados</span>
) : projects.length === 1 ? (
<span className="text-sm font-semibold text-foreground glow-text">{activeProject?.name}</span>
) : (
<select
value={activeProjectId ?? ''}
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"
>
{!activeProjectId && (
<option value="" disabled>Seleccione un proyecto</option>
)}
{projects.map((p) => (
<option key={p.id} value={p.id} className="bg-sidebar text-foreground">{p.name}</option>
))}
</select>
)}
</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>
<div className="flex-1 min-w-0"> {(isHovered || isSidebarOpen) && (
<p className="text-sm font-semibold text-foreground truncate">{user?.email}</p> <div className="flex-1 min-w-0">
<p className="text-[10px] text-muted-foreground uppercase tracking-widest">{user?.role}</p> <p className="text-xs font-black text-white truncate tracking-tighter">{user?.email}</p>
</div> <p className="text-[10px] text-muted-foreground uppercase font-black">{user?.role}</p>
<button </div>
onClick={handleLogout} )}
title="Cerrar Sessión" {(isHovered || isSidebarOpen) && (
className="p-2 text-muted-foreground hover:text-destructive hover:bg-destructive/10 rounded-lg transition-colors" <button
aria-label="Cerrar sesión" onClick={handleLogout}
> className="p-2 text-muted-foreground hover:text-red-500 hover:bg-red-500/10 rounded-xl transition-colors"
<LogOut className="h-4 w-4" /> >
</button> <LogOut className="h-4 w-4" />
</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>
) )

View File

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

View File

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

View File

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

View File

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

View File

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