32 Commits

Author SHA1 Message Date
017c180c85 fix(auth): completar separacion de superficies admin y campo 2026-04-10 23:13:14 -05:00
Wilman Yesid Farfan Diaz
9f5d03142e Subir cambio de red, para ver si se soluciona ailsmiento de contenedores 2026-04-10 12:12:02 -05:00
Wilman Yesid Farfan Diaz
251bf7635e feat: implement CRUD handlers and full configuration retrieval for edge projects 2026-04-10 11:42:21 -05:00
Wilman Yesid Farfan Diaz
6d3443de37 Modificaci[on en la variable de aws para evitar el panic en el contenedor 2026-04-10 10:38:13 -05:00
Wilman Yesid Farfan Diaz
cde41d0c8e Actualizaci[on de informacion adicional 2026-04-09 23:07:03 -05:00
Wilman Yesid Farfan Diaz
9135c9db7e Listo el depliegue 2026-04-09 22:39:02 -05:00
Wilman Yesid Farfan Diaz
93e0359789 Actualizacion del agente y los reportes 2026-04-09 17:03:19 -05:00
Wilman Yesid Farfan Diaz
aacedc1c91 Actualizaci[on del telemetry para ingesta de datos 2026-04-08 20:32:28 -05:00
Wilman Yesid Farfan Diaz
055d036ecd Actualizaci[on completa sobre seguridad y comunicaci[on segura. 2026-04-08 19:55:51 -05:00
Wilman Yesid Farfan Diaz
3608585768 Cargando el doccjer cpmpse mpdificado 2026-04-08 16:13:39 -05:00
f0a23ef6ce docs: add repair.md (registro usuarios) 2026-04-08 09:47:32 -05:00
4ea6d12bd7 feat(api): support comma-separated ALLOWED_ORIGINS for proper CORS in prod 2026-04-08 08:57:42 -05:00
dcf423185d fix(gateway+pwa): map mobile.omnioil.app to PWA and set VITE_API_BASE=/api for frontend-pwa 2026-04-08 08:27:20 -05:00
06202ab5f6 fix(api): re-enable public user registration endpoint (/api/auth/register) 2026-04-08 07:57:20 -05:00
Wilman Yesid Farfan Diaz
75bd4b3482 Actualizado todo el sistema con las mejoras en seguridad encontradas, despliegue sin migraciones, solo la inicial. 2026-04-07 22:49:23 -05:00
Wilman Yesid Farfan Diaz
ac2fa56a22 Soluci[on de seguridad de los puntos realizada 2026-04-07 19:18:40 -05:00
Wilman Yesid Farfan Diaz
24b0082690 Funcionalidades de seguridad implementadas. 2026-04-07 14:25:38 -05:00
Wilman Yesid Farfan Diaz
ffbe78a09c Cambios para despliegue con mosquito 2026-04-07 10:03:17 -05:00
693360ea79 docs: agregar PRD de estabilización y preparación para producción (v1.0) 2026-04-07 09:56:28 -05:00
Wilman Yesid Farfan Diaz
e726160c0d Actualizado entrypoint.sh a versión 1.0.2 2026-04-07 09:32:18 -05:00
Wilman Yesid Farfan Diaz
dfe0e563a5 Archivo mosquito cambiado. 2026-04-07 09:18:52 -05:00
Wilman Yesid Farfan Diaz
37149607dd Actualizaci[on archivo mosquito para la validaci[on de la comunicaci[on encriptada y aceptada entre contenedores 2026-04-07 08:08:13 -05:00
Wilman Yesid Farfan Diaz
7754b69989 Ajustes finales de comunicaci[on segura 2026-04-06 22:18:29 -05:00
Wilman Yesid Farfan Diaz
77e88ce77a Actualizaci[on de seguridad fuerte.
Actualizaci[on en el readme
2026-04-06 21:49:16 -05:00
Wilman Yesid Farfan Diaz
86c834e2b4 Base de datos para la validación de las credenciales 2026-04-06 16:48:44 -05:00
Wilman Yesid Farfan Diaz
9b16294a41 Adicion de mosquito.config 2026-04-06 16:31:56 -05:00
Wilman Yesid Farfan Diaz
7482704fd7 Actualización. del docker 2026-04-06 16:13:46 -05:00
Wilman Yesid Farfan Diaz
7d90aa7b6a Cambios en la seguridad y nuevo campo 2026-04-06 16:09:45 -05:00
Wilman Yesid Farfan Diaz
4d35a56719 Cambios importantes relacionados a la seguridad, pendientes de implementación, solo esta la documentación 2026-04-06 07:55:36 -05:00
Wilman Yesid Farfan Diaz
0c10dd5eed Actualizado flujo del agente de borde e instalador 2026-04-05 18:29:59 -05:00
416cc476ef Sincronizar frontend-pwa con frontend-admin
Copiar layouts, UI components, rutas admin, dashboard, projects, tanks,
auth y estilos del admin al PWA. Se preservan movements, sync worker
y archivos exclusivos del PWA.
2026-04-05 18:26:12 -05:00
16495a052f Estabilización multi-tenant: auth con proyectos, type safety y limpieza de datos quemados
- Backend: login retorna projects[] consultando user_project_access
- Auth store (admin + pwa): projects[], activeProjectId, setActiveProject(), version 1
- LoginPage (admin + pwa): pasa projects al store tras login
- AdminLayout: header con selector de proyecto activo
- useTanks: elimina UUID hardcodeado, usa activeProjectId del store
- projects-api: BuildTriggerResponse y BuildMetadata interfaces, sin any
- wells-api: EdgeAssetType union type
- DashboardPage: elimina datos quemados (KPIs, charts, sensores, usuario)
2026-04-05 12:53:25 -05:00
195 changed files with 16192 additions and 10222 deletions

View File

@@ -26,6 +26,5 @@ infrastructure/postgres/data/
# Docs & Meta # Docs & Meta
docs/ docs/
*.md
LICENSE LICENSE
!README.md !README.md

46
.env.example Normal file
View File

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

42
AGENTS.md Normal file
View File

@@ -0,0 +1,42 @@
# Repository Guidelines
## Project Structure & Module Organization
- services/: Rust microservices and UIs.
- Rust: `backend-api/`, `data-collector/`, `events-relay/`, `json-generator/`, `shared-lib/`, `build-orchestrator/`, `edge-agent/`.
- Frontends: `frontend-admin/` (admin dashboard), `frontend-pwa/` (field PWA), `landing/`.
- infrastructure/: Nginx, Mosquitto, Postgres, seed data, test helpers.
- tests/: Endtoend Modbus simulation and helpers.
- docs/: Architecture, deployment, and security references.
## Build, Test, and Development Commands
- Rust workspace:
- Build all: `cargo build --release`
- Run a service: `cargo run -p backend-api`
- Test all or per crate: `cargo test` or `cargo test -p shared-lib`
- Frontends (run from each app folder):
- Install: `npm install`
- Dev server: `npm run dev`
- Build: `npm run build`
- Lint: `npm run lint`
- Docker (local stack):
- Start: `docker compose -f docker-compose.yml -f docker-compose.dev.yml up -d`
- Rebuild images: `docker compose build`
## Coding Style & Naming Conventions
- Rust: format with `cargo fmt`; 4space indent; `snake_case` for files/functions, `PascalCase` for types; keep modules small and reuse `services/shared-lib`.
- TypeScript/React: 2space indent; `PascalCase` components, `camelCase` hooks/utilities; prefer named exports; colocate UI in `src/features/...`.
- Linting: Rust via `cargo fmt -- --check`; Frontend via ESLint (`npm run lint`).
## Testing Guidelines
- Rust: add unit/integration tests near code; aim for meaningful coverage on domain logic in `shared-lib`. Run with `cargo test`.
- E2E: Python utilities under `tests/modbus_end_to_end/` for Modbus and mock backend; run individually, e.g., `python tests/modbus_end_to_end/modbus_sim_minimal.py`.
- Infra checks: MQTT auth script at `infrastructure/tests/test_mqtt_auth.py`.
## Commit & Pull Request Guidelines
- Commits: short, imperative, scoped. Examples: `feat(admin): tanks inventory`, `fix(edge): MQTT reconnect`, `docs: update DEPLOYMENT`.
- PRs: include purpose, linked issue, test notes (commands), and screenshots for UI. Keep changes focused; update docs when behavior changes.
## Security & Configuration Tips
- Never commit secrets. Start from `.env.example` and keep `.env` local.
- Use Docker Compose defaults; expose ports only via `docker-compose.dev.yml`.
- Follow docs in `docs/` for deployment, data flow, and hardening.

1297
Cargo.lock generated

File diff suppressed because it is too large Load Diff

View File

@@ -8,6 +8,7 @@ members = [
"services/build-orchestrator", "services/build-orchestrator",
"services/edge-agent", "services/edge-agent",
"services/events-relay", "services/events-relay",
"services/telemetry-ingestor",
] ]
resolver = "2" resolver = "2"

View File

@@ -15,7 +15,8 @@ ENV CARGO_INCREMENTAL=0
# 2. PLANNER: Solo analiza dependencias (cambia poco) # 2. PLANNER: Solo analiza dependencias (cambia poco)
FROM base AS planner FROM base AS planner
COPY . . COPY Cargo.toml Cargo.lock ./
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 (con cache mounts)
@@ -29,7 +30,8 @@ RUN --mount=type=cache,sharing=locked,target=/usr/local/cargo/registry \
# 4. BUILDER-BASE: Código + dependencias pre-compiladas + cache mounts # 4. BUILDER-BASE: Código + dependencias pre-compiladas + cache mounts
FROM base AS builder FROM base AS builder
COPY . . COPY Cargo.toml Cargo.lock ./
COPY services/ services/
# COMPILACIÓN CRUZADA PARA WINDOWS (Template del agente de borde) # 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/*
@@ -91,6 +93,21 @@ COPY --from=builder-json-generator /app/json-generator .
ENV RUST_LOG=info ENV RUST_LOG=info
CMD ["./json-generator"] CMD ["./json-generator"]
# --- TELEMETRY-INGESTOR ---
FROM builder AS builder-telemetry-ingestor
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=/app/target \
cargo build --release -p telemetry-ingestor && \
cp target/release/telemetry-ingestor /app/telemetry-ingestor
FROM debian:bookworm-slim AS telemetry-ingestor
WORKDIR /app
RUN apt-get update && apt-get install -y libssl3 ca-certificates && rm -rf /var/lib/apt/lists/*
COPY --from=builder-telemetry-ingestor /app/telemetry-ingestor .
ENV RUST_LOG=info
CMD ["./telemetry-ingestor"]
# --- EVENTS-RELAY --- # --- EVENTS-RELAY ---
FROM builder AS builder-events-relay FROM builder AS builder-events-relay
RUN --mount=type=cache,sharing=locked,target=/usr/local/cargo/registry \ RUN --mount=type=cache,sharing=locked,target=/usr/local/cargo/registry \
@@ -116,7 +133,7 @@ RUN --mount=type=cache,sharing=locked,target=/usr/local/cargo/registry \
FROM debian:bookworm-slim AS build-orchestrator FROM debian:bookworm-slim AS build-orchestrator
WORKDIR /app WORKDIR /app
RUN apt-get update && apt-get install -y libssl3 ca-certificates && rm -rf /var/lib/apt/lists/* RUN apt-get update && apt-get install -y libssl3 ca-certificates mosquitto-clients && rm -rf /var/lib/apt/lists/*
# Binario del orquestador # Binario del orquestador
COPY --from=builder-build-orchestrator /app/build-orchestrator . COPY --from=builder-build-orchestrator /app/build-orchestrator .
@@ -125,6 +142,10 @@ COPY --from=builder-build-orchestrator /app/build-orchestrator .
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 /app/bin/windows/edge-agent.exe target/x86_64-pc-windows-gnu/release/
# Cargo.toml del agente (PARA EXTRACTAR VERSIÓN EN RUNTIME)
RUN mkdir -p services/edge-agent
COPY services/edge-agent/Cargo.toml services/edge-agent/Cargo.toml
# Carpeta para MSI temporal # Carpeta para MSI temporal
RUN mkdir -p services/edge-agent/incoming RUN mkdir -p services/edge-agent/incoming

View File

@@ -6,10 +6,18 @@
## 🏗️ Architecture Stack ## 🏗️ Architecture Stack
- **Backend API**: Rust Axum (Asynchronous, High-Performance) - **Backend API**: Rust Axum (Asynchronous, High-Performance)
- **Data Ingestion**: Rust (10-minute high-frequency polling) - **Data## 🛠️ Infraestructura de Telemetría (Security Hardened)
- **Reports (JSON)**: Daily automated generation (ANH v4.0 standard)
- **Event Relay**: Transactional Outbox pattern with Redis Streams OmniOil utiliza Eclipse Mosquitto con un sistema de autenticación dinámico mediante Postgres (`Go-Auth`).
- **Web UI**: React + Vite + Vanilla CSS (**Premium Design**: Glassmorphism, animations, responsive)
### 🛡️ Blindaje de Seguridad Implementado
- **Cero Secretos**: Las claves nunca viajan en el código ni en archivos estáticos `.conf`.
- **Hasheo Nativo (Postgres)**: Usamos la extensión `pgcrypto` para generar Hashes Bcrypt dinámicamente en la base de datos al registrar agentes.
- **Aislamiento Multi-Tenant**: Cada Agente de Borde tiene un **DNI MQTT único (UUID)** y una clave aleatoria de 40 caracteres generada por el orquestador.
- **Micro-Segmentación por ACL**: Los usuarios de clientes están restringidos mediante ACLs dinámicas a su propio canal: `telemetry/{project_id}/#`.
### 🚀 Despliegue Dinámico
El sistema utiliza un puente de inyección basado en un `entrypoint.sh` personalizado que pobla las variables de entorno en el archivo de configuración de Mosquitto en tiempo de ejecución, eliminando la necesidad de archivos `.passwd` o configuraciones manuales.
- **Persistence**: TimescaleDB (Time-series optimization) + MinIO (S3 object storage) - **Persistence**: TimescaleDB (Time-series optimization) + MinIO (S3 object storage)
- **Edge Layer**: Local Agent for field device extraction (Modbus/OPC-UA) - **Edge Layer**: Local Agent for field device extraction (Modbus/OPC-UA)
- **Build System**: Unified Docker multi-stage pipeline with **Cargo Chef** & sharing-locked caching. - **Build System**: Unified Docker multi-stage pipeline with **Cargo Chef** & sharing-locked caching.

View File

@@ -0,0 +1,52 @@
# Reporte de Fortalecimiento y Remediación de Seguridad - OmniOil Backend
Este documento resume las acciones tomadas para asegurar la infraestructura de **OmniOil** y resolver los hallazgos críticos de la auditoría.
## 1. Resumen de Remediaciones
| # | Hallazgo Original | Estado | Acción Tomada |
|---|-------------------|--------|---------------|
| 1 | API Abierta (Sin JWT) | **RESUELTO** | Se integró el extractor `Claims` en todos los handlers de la API. Ninguna ruta (excepto Auth login/register) es accesible sin un JWT válido. |
| 2 | Movimientos sin Backend | **RESUELTO** | Se creó el módulo `operations_movements.rs`, se definieron los modelos en `shared-lib` y se implementó la migración SQL `20260407000000_operation_movements.sql`. |
| 3 | Field Name Mismatch | **RESUELTO** | Se sincronizó el PWA (`EdgeAsset`) con el backend. Los campos `asset_code`/`asset_name` ahora son `code`/`name` en todos los componentes de la interfaz. |
| 4 | Multi-tenant sin Enforcement | **RESUELTO** | Cada consulta SQL (CRUD, Telemetría, Logs, Dashboard) ahora incluye un JOIN con `user_project_access` para garantizar que el usuario solo vea sus datos. |
---
## 2. Cambios Técnicos Realizados
### Backend (Rust / Axum)
- **Handlers Asegurados**:
- `edge_projects.rs`, `edge_assets.rs`, `edge_devices.rs`, `edge_variables.rs`, `edge_deployments.rs`: Todos ahora validan el rol del usuario y el acceso al proyecto antes de procesar peticiones.
- `telemetry.rs`: Protegido para evitar la fuga de datos de sensores entre diferentes clientes.
- `dashboard.rs`: El resumen estadístico ahora filtra dinámicamente según el portafolio del usuario.
- `edge_agents.rs`: Los logs y el estado de salud están restringidos a los dueños del proyecto.
- **Nuevas Funcionalidades**:
- `operations_movements.rs`: Handlers `POST /api/movements` y `GET /api/movements` para el cumplimiento con la ANH.
### Base de Datos (PostgreSQL / TimescaleDB)
- **Migración O6**: Creación de la tabla `operation_movements` con tipos específicos para hidrocarburos (Venta, Recibo, Transferencia, etc.) y precisión decimal.
- **Sincronización**: La ejecución de migraciones es automática al inicio del contenedor backend.
### Frontend (PWA / Vite)
- **Alineación de Modelos**: Se actualizó `EdgeAsset` en `assets-api.ts`.
- **UI Corregida**: Se modificaron `MeasurementPage`, `MovementPage` y sus `AssetSelector` correspondientes para usar los nuevos campos mapeados desde el backend, eliminando los errores de "undefined".
---
## 3. Guía de Despliegue y Validación
### Paso 1: Reconstrucción en Dokploy
Para que estos cambios surtan efecto en producción, debe forzar una reconstrucción ("Redeploy") desde el panel de Dokploy.
> [!IMPORTANT]
> El backend ejecutará automáticamente la nueva migración SQL al arrancar. No es necesario realizar cambios manuales en la base de datos.
### Paso 2: Pruebas de Validación
1. **Acceso Prohibido**: Intente llamar a `/api/edge/projects` sin el header `Authorization`. El backend debe responder con `401 Unauthorized`.
2. **Aislamiento Multi-tenant**: Use una cuenta de tipo "Supervisor" o "Operador" y verifique que solo aparecen los proyectos asignados en su tabla de acceso.
3. **Registro de Movimientos**: Desde la PWA, cree un movimiento de "Venta" y verifique que se guarda correctamente en la nueva tabla del backend.
---
> [!TIP]
> **Recomendación para Desarrolladores**: Mantener el modelo de "Backend como fuente de verdad". Ante cualquier desajuste futuro entre PWA y Backend, siempre debe prevalecer el esquema de Rust para mantener la integridad de la base de datos.

53
TRACKING.md Normal file
View File

@@ -0,0 +1,53 @@
# Tracking — Estabilización OmniOil (local)
Fecha: 2026-04-07
Estado: plan operativo inmediato (no publicado)
Fuentes clave
- PRD completo: `docs/PRD_ESTABILIZACION_PRODUCCION.md` (publicado en main)
- Resumen ejecutivo: `docs/PRD_RESUMEN_EJECUTIVO.md` (local, sin commit)
Objetivo 72h
- Dejar la plataforma segura y consistente para continuar con cierres funcionales.
Checklist inmediato (72h)
- [ ] Seguridad: aplicar middleware/JWT en todos los handlers del backend.
- [ ] Seguridad: rotar `JWT_SECRET` y variables sensibles; mover a `.env`/secrets.
- [ ] Seguridad: CORS por allowlist (dominios específicos por ambiente).
- [ ] Seguridad: cerrar puertos externos (MQTT/DB/Redis/MinIO) y usar red interna.
- [ ] Consistencia: corregir mismatch de campos de assets entre backend y PWA.
- [ ] Datos: reconciliar migraciones `sqlx` con `init.sql` (fuente única: migraciones).
- [ ] Funcional: implementar `GET/POST /api/movements` y modelo compartido.
- [ ] Offline: integrar `use-sync-worker` para movements (cola + reintentos).
Notas técnicas rápidas
- Enforce JWT: agregar validador/middleware global y remover `#[allow(dead_code)]` de `verify_jwt()`; exigir auth en rutas `/api/**`.
- Multitenant: filtrar por usuario/proyecto en queries (usar `user_project_access`).
- Migraciones: crear migración que cree/ajuste tablas faltantes (`operations_movements`, `operations_downtime`, `lab_results`, etc.) y alinear tipos/constraints.
Comandos útiles
- Publicar resumen ejecutivo (cuando decidas):
- `git add docs/PRD_RESUMEN_EJECUTIVO.md`
- `git commit -m "docs: resumen ejecutivo PRD"`
- `git push origin main`
- Auditar handlers sin auth (búsqueda rápida):
- `rg -n "(axum::routing|Router::new|route\(|get\(|post\()" services/backend-api/src | rg -v "auth|jwt|layer"`
- `rg -n "TODO|FIXME|allow\(dead_code\)|unwrap\(\)" services/backend-api/src`
- Diferencias de esquema:
- `rg -n "CREATE TABLE|ALTER TABLE" infrastructure/postgres/init.sql`
- `ls services/backend-api/migrations && rg -n "CREATE TABLE|ALTER TABLE" services/backend-api/migrations`
- Verificación de puertos expuestos (infra):
- `rg -n "ports:\n|\- \"?\d{2,5}:\d{2,5}\"?" docker-compose.yml infrastructure -n`
Decisiones pendientes
- Política CORS por entorno (dominios/clientes permitidos).
- Estrategia de secretos (dotenv vs. secrets manager en despliegue).
- Modelo de roles/ACL por proyecto y scopes de API.
Siguientes entregables
- PR: Fase 1 completa (seguridad) + migración de reconciliación.
- PR: Movements backend + integración de sync en PWA.

View File

@@ -28,9 +28,22 @@ services:
- "5000:5000" - "5000:5000"
# Exponer frontend para ver la UI localmente # Exponer frontend para ver la UI localmente
frontend: frontend-admin:
build: build:
args: args:
- VITE_API_BASE=/api - VITE_API_BASE=/api
ports: ports:
- "3000:80" - "3000:80"
# Exponer PWA para campo
frontend-pwa:
build:
args:
- VITE_API_BASE=/api
ports:
- "3001:80"
# Exponer Mosquitto para que el Agente en Windows lo vea
mosquitto:
ports:
- "1883:1883"

View File

@@ -1,39 +1,38 @@
# ============================================================================= # =============================================================================
# OmniOil SCADA - Configuración Base (Producción/Segura) # OmniOil SCADA - Configuración Base (Simplificada)
# ============================================================================= # =============================================================================
services: services:
# ---------------------------------------------------------------------------
# ALMACENAMIENTO (Persistencia e Infraestructura)
# ---------------------------------------------------------------------------
timescaledb: timescaledb:
image: timescale/timescaledb:2.26.1-pg16 image: timescale/timescaledb:2.26.1-pg16
container_name: anh_timescaledb container_name: anh_timescaledb
restart: always restart: always
environment: environment:
POSTGRES_USER: ${DB_USER:-adminomnioil} POSTGRES_USER: ${DB_USER}
POSTGRES_PASSWORD: ${DB_PASSWORD:-admin123} POSTGRES_PASSWORD: ${DB_PASSWORD}
POSTGRES_DB: ${DB_NAME:-omnioil} POSTGRES_DB: ${DB_NAME}
MQTT_USER: ${MQTT_USER}
MQTT_PASSWORD: ${MQTT_PASSWORD}
volumes: volumes:
- ${TIMESCALEDB_DATA:-timescaledb_data}:/var/lib/postgresql/data - ${TIMESCALEDB_DATA:-timescaledb_data}:/var/lib/postgresql/data
- ./infrastructure/postgres/init.sql:/docker-entrypoint-initdb.d/01_base.sql
- ./infrastructure/postgres/edge_init.sql:/docker-entrypoint-initdb.d/02_edge.sql
- ./infrastructure/postgres/03_outbox.sql:/docker-entrypoint-initdb.d/03_outbox.sql
networks: networks:
- anh_network - anh_network
# NOTA: Sin puertos expuestos en PROD. healthcheck:
test: [ "CMD-SHELL", "pg_isready -U ${DB_USER} -d ${DB_NAME}" ]
interval: 5s
timeout: 5s
retries: 5
start_period: 10s
redis: redis:
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 --appendonly yes command: redis-server --requirepass ${REDIS_PASSWORD} --appendonly yes
volumes: volumes:
- ${REDIS_DATA:-redis_data}:/data - ${REDIS_DATA:-redis_data}:/data
networks: networks:
- anh_network - anh_network
# NOTA: Sin puertos expuestos en PROD.
minio: minio:
image: minio/minio:RELEASE.2025-09-07T16-13-09Z-cpuv1 image: minio/minio:RELEASE.2025-09-07T16-13-09Z-cpuv1
@@ -44,17 +43,12 @@ services:
- "9000" - "9000"
- "9001" - "9001"
environment: environment:
MINIO_ROOT_USER: ${MINIO_USER:-minioadmin} MINIO_ROOT_USER: ${MINIO_USER}
MINIO_ROOT_PASSWORD: ${MINIO_PASSWORD:-minio123} MINIO_ROOT_PASSWORD: ${MINIO_PASSWORD}
networks: networks:
- anh_network - anh_network
volumes: volumes:
- ${MINIO_DATA:-minio_data}:/data - ${MINIO_DATA:-minio_data}:/data
# NOTA: Sin puertos expuestos. Acceso vía tunnel o proxy opcional.
# ---------------------------------------------------------------------------
# MICROSERVICIOS (Core del Sistema)
# ---------------------------------------------------------------------------
backend-api: backend-api:
container_name: anh_backend_api container_name: anh_backend_api
@@ -64,31 +58,28 @@ services:
dockerfile: Dockerfile.unified dockerfile: Dockerfile.unified
target: backend-api target: backend-api
environment: environment:
- DATABASE_URL=postgres://${DB_USER:-adminomnioil}:${DB_PASSWORD:-admin123}@timescaledb:5432/${DB_NAME:-omnioil} - DATABASE_URL=postgres://${DB_USER}:${DB_PASSWORD}@timescaledb:5432/${DB_NAME}
- JWT_SECRET=${JWT_SECRET:-devsecretkeysomnioil} - JWT_SECRET=${JWT_SECRET}
- MQTT_HOST=mosquitto - MQTT_HOST=mosquitto
- MQTT_PORT=1883 - MQTT_PORT=1883
- MQTT_USER=${MQTT_USER}
- MQTT_PASSWORD=${MQTT_PASSWORD}
- REDIS_URL=redis://:${REDIS_PASSWORD}@redis:6379
ports: ports:
- "8000:8000" - "8000:8000"
depends_on: depends_on:
- timescaledb timescaledb:
- redis condition: service_healthy
networks: redis:
- anh_network condition: service_started
data-collector:
container_name: anh_data_collector
restart: always
build:
context: .
dockerfile: Dockerfile.unified
target: data-collector
environment:
- DATABASE_URL=postgres://${DB_USER:-adminomnioil}:${DB_PASSWORD:-admin123}@timescaledb:5432/${DB_NAME:-omnioil}
depends_on:
- timescaledb
networks: networks:
- anh_network - anh_network
healthcheck:
test: [ "CMD-SHELL", "bash -c 'echo > /dev/tcp/localhost/8000' || exit 1" ]
interval: 5s
timeout: 5s
retries: 10
start_period: 30s
events-relay: events-relay:
container_name: anh_events_relay container_name: anh_events_relay
@@ -98,11 +89,34 @@ services:
dockerfile: Dockerfile.unified dockerfile: Dockerfile.unified
target: events-relay target: events-relay
environment: environment:
- DATABASE_URL=postgres://${DB_USER:-adminomnioil}:${DB_PASSWORD:-admin123}@timescaledb:5432/${DB_NAME:-omnioil} - DATABASE_URL=postgres://${DB_USER}:${DB_PASSWORD}@timescaledb:5432/${DB_NAME}
- REDIS_URL=redis://redis:6379 - REDIS_URL=redis://:${REDIS_PASSWORD}@redis:6379
depends_on: depends_on:
- timescaledb backend-api:
- redis condition: service_healthy
redis:
condition: service_started
networks:
- anh_network
telemetry-ingestor:
container_name: anh_telemetry_ingestor
restart: always
build:
context: .
dockerfile: Dockerfile.unified
target: telemetry-ingestor
environment:
- DATABASE_URL=postgres://${DB_USER}:${DB_PASSWORD}@timescaledb:5432/${DB_NAME}
- MQTT_HOST=mosquitto
- MQTT_PORT=1883
- MQTT_USER=${MQTT_USER}
- MQTT_PASSWORD=${MQTT_PASSWORD}
depends_on:
timescaledb:
condition: service_healthy
mosquitto:
condition: service_started
networks: networks:
- anh_network - anh_network
@@ -114,19 +128,16 @@ services:
dockerfile: Dockerfile.unified dockerfile: Dockerfile.unified
target: json-generator target: json-generator
environment: environment:
- DATABASE_URL=postgres://${DB_USER:-adminomnioil}:${DB_PASSWORD:-admin123}@timescaledb:5432/${DB_NAME:-omnioil} - DATABASE_URL=postgres://${DB_USER}:${DB_PASSWORD}@timescaledb:5432/${DB_NAME}
- MINIO_ENDPOINT=http://minio:9000 - MINIO_ENDPOINT=http://minio:9000
depends_on: depends_on:
- timescaledb backend-api:
- minio condition: service_healthy
minio:
condition: service_started
networks: networks:
- anh_network - anh_network
# ---------------------------------------------------------------------------
# FRONTENDS (Entradas Principales - SaaS Architecture - Manejado por Traefik)
# ---------------------------------------------------------------------------
# Landing Page: omnioil.app / www.omnioil.app
landing: landing:
container_name: anh_landing container_name: anh_landing
build: build:
@@ -135,20 +146,22 @@ services:
networks: networks:
- anh_network - anh_network
# Field PWA: campo.omnioil.app
frontend-pwa: frontend-pwa:
container_name: anh_frontend_pwa container_name: anh_frontend_pwa
restart: always
build: build:
context: . context: .
dockerfile: services/frontend-pwa/Dockerfile dockerfile: services/frontend-pwa/Dockerfile
args:
- VITE_API_BASE=/api
depends_on: depends_on:
- backend-api - backend-api
networks: networks:
- anh_network - anh_network
# SaaS Dashboard: admin.omnioil.app frontend-admin:
frontend: container_name: anh_frontend_admin
container_name: anh_frontend restart: always
build: build:
context: ./services/frontend-admin context: ./services/frontend-admin
dockerfile: Dockerfile dockerfile: Dockerfile
@@ -159,23 +172,26 @@ services:
networks: networks:
- anh_network - anh_network
# ---------------------------------------------------------------------------
# EDGE SYSTEM (MQTT + Registry + Orchestrator)
# ---------------------------------------------------------------------------
mosquitto: mosquitto:
image: eclipse-mosquitto:2.1.2-alpine
container_name: anh_mosquitto container_name: anh_mosquitto
restart: always restart: always
expose: build:
- "1883" context: ./infrastructure/mosquitto
- "9883" dockerfile: Dockerfile
environment:
- MOSQUITTO_GO_AUTH=true
- POSTGRES_HOST=timescaledb
- POSTGRES_USER=${DB_USER}
- POSTGRES_PASSWORD=${DB_PASSWORD}
- POSTGRES_DB=${DB_NAME}
volumes: volumes:
- ./infrastructure/mosquitto/mosquitto.conf:/mosquitto/config/mosquitto.conf
- ${MOSQUITTO_DATA:-mosquitto_data}:/mosquitto/data - ${MOSQUITTO_DATA:-mosquitto_data}:/mosquitto/data
- ${MOSQUITTO_LOG:-mosquitto_log}:/mosquitto/log - ${MOSQUITTO_LOG:-mosquitto_log}:/mosquitto/log
networks: networks:
- anh_network - anh_network
depends_on:
backend-api:
condition: service_healthy
docker-registry: docker-registry:
image: registry:3.0.0 image: registry:3.0.0
@@ -185,11 +201,11 @@ services:
- "5000" - "5000"
environment: environment:
REGISTRY_STORAGE_DELETE_ENABLED: "true" REGISTRY_STORAGE_DELETE_ENABLED: "true"
OTEL_TRACES_EXPORTER: "none"
networks: networks:
- anh_network - anh_network
volumes: volumes:
- ${REGISTRY_DATA:-registry_data}:/var/lib/registry - ${REGISTRY_DATA:-registry_data}:/var/lib/registry
# NOTA: Sin puertos expuestos. Solo accesible desde el Build Orchestrator interno.
build-orchestrator: build-orchestrator:
container_name: anh_build_orchestrator container_name: anh_build_orchestrator
@@ -198,24 +214,32 @@ services:
dockerfile: Dockerfile.unified dockerfile: Dockerfile.unified
target: build-orchestrator target: build-orchestrator
environment: environment:
- DATABASE_URL=postgres://${DB_USER:-adminomnioil}:${DB_PASSWORD:-admin123}@timescaledb:5432/${DB_NAME:-omnioil} - DATABASE_URL=postgres://${DB_USER}:${DB_PASSWORD}@timescaledb:5432/${DB_NAME}
- MQTT_HOST=mosquitto - MQTT_HOST=mosquitto
- MQTT_PORT=1883 - MQTT_PORT=1883
- MQTT_USER=${MQTT_USER}
- MQTT_PASSWORD=${MQTT_PASSWORD}
- DOCKER_REGISTRY_URL=docker-registry:5000 - DOCKER_REGISTRY_URL=docker-registry:5000
- MINIO_ENDPOINT_URL=http://minio:9000 - MINIO_ENDPOINT_URL=http://minio:9000
- AWS_ACCESS_KEY_ID=${MINIO_USER:-minioadmin} - AWS_ACCESS_KEY_ID=${MINIO_USER}
- AWS_SECRET_ACCESS_KEY=${MINIO_PASSWORD:-minio123} - AWS_SECRET_ACCESS_KEY=${MINIO_PASSWORD}
- AWS_REGION=us-east-1 - AWS_REGION=us-east-1
- AWS_EC2_METADATA_DISABLED=true
- BACKEND_API_URL=${BACKEND_API_URL}
- MQTT_PUBLIC_HOST=${MQTT_PUBLIC_HOST}
volumes: volumes:
- /var/run/docker.sock:/var/run/docker.sock - /var/run/docker.sock:/var/run/docker.sock
- ./infrastructure/mosquitto:/app/infrastructure/mosquitto
- ${INSTALLER_INCOMING:-installer_incoming}:/app/services/edge-agent/incoming
- ${INSTALLER_OUTPUT:-installer_output}:/app/services/edge-agent/output
depends_on: depends_on:
- timescaledb backend-api:
- mosquitto condition: service_healthy
- docker-registry mosquitto:
condition: service_started
networks: networks:
- anh_network - anh_network
# Generador de instaladores .msi para los agentes de borde
edge-agent-msi-builder: edge-agent-msi-builder:
container_name: anh_msi_builder container_name: anh_msi_builder
build: build:
@@ -224,17 +248,13 @@ services:
volumes: volumes:
- ${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
healthcheck:
test: [ "CMD-SHELL", "pgrep -f msi_worker.sh || exit 1" ]
interval: 30s
timeout: 10s
retries: 3
networks: networks:
- anh_network - anh_network
networks: networks:
anh_network: anh_network:
driver: bridge driver: bridge
name: anh_network
volumes: volumes:
timescaledb_data: timescaledb_data:

View File

@@ -6,24 +6,47 @@ This guide describes how to deploy and configure the OmniOil SCADA system in a p
## 🏗️ 1. Infrastructure Overview (Dokploy) ## 🏗️ 1. Infrastructure Overview (Dokploy)
When deploying to **Dokploy** (Traefik-based proxy), each main service is exposed as a subdomain: ### 🏗️ Configuración Dinámica de Mosquitto
El broker MQTT (`mosquitto`) ya no depende de archivos de configuración estáticos con contraseñas.
#### El Flujo de Inyección en Caliente:
1. **Plantilla**: Se usa el archivo `infrastructure/mosquitto/mosquitto.conf` como una plantilla (template).
2. **Inyección**: El archivo `infrastructure/mosquitto/entrypoint.sh` se ejecuta al arrancar el contenedor y realiza el reemplazo de los campos `PLACEHOLDER` por las variables reales (`DB_USER`, `DB_PASSWORD`, etc.).
3. **Encadenamiento**:Mosquitto inicia con la configuración inyectada en memoria (dentro del contenedor).
### 🛡️ Seguridad Multi-Cliente (Postgres-Backed)
La autenticación de cada nuevo agente se gestiona dinámicamente mediante la tabla `mqtt_users`:
- **Auto-Hashing**: No es necesario generar hashes manuales. El orquestador inserta la clave en texto plano y Postgres genera el hash Bcrypt al vuelo usando `pgcrypto`.
- **UUID Identity**: Los agentes utilizan el ID del proyecto como nombre de usuario para asegurar un mapeo unívoco.
- **Micro-Segmentación (ACL)**: Cada agente tiene un permiso restringido para escribir únicamente en su canal industrial: `omnioil/telemetry/{PROYECTO_ID}/#`.
- **Ingesta Centralizada**: El microservicio **telemetry-ingestor** procesa todos los mensajes bajo el prefijo `omnioil/` y los persiste en TimescaleDB.
When deploying to **Dokploy**, each main service is exposed as a subdomain:
| Service | Port (Internal) | Subdomain (Recommended) | HTTPS | Description | | Service | Port (Internal) | Subdomain (Recommended) | HTTPS | Description |
| :--- | :--- | :--- | :--- | :--- | | :--- | :--- | :--- | :--- | :--- |
| **Landing Page** | 80 | `omnioil.app` / `www` | ✅ | Public Marketing & Sales | | **Landing Page** | 80 | `omnioil.app` / `www` | ✅ | Public Marketing & Sales |
| **SaaS Dashboard**| 80 | `app.omnioil.app` | ✅ | Central Administration Dashboard | | **SaaS Dashboard**| 80 | `app.omnioil.app` | ✅ | `frontend-admin` canonical admin/dashboard surface |
| **Field PWA** | 80 | `mobile.omnioil.app` | ✅ | Progressive Web App for operators | | **Field PWA** | 80 | `mobile.omnioil.app` | ✅ | `frontend-pwa` canonical field/operator surface |
| **Backend API** | 8000 | `api.omnioil.app` | ✅ | Central Orchestrator & Logic | | **Backend API** | 8000 | `api.omnioil.app` | ✅ | Central Orchestrator & Logic |
| **Telemetry Ingestor**| - | *(Internal)* | - | MQTT-to-DB Bridge (Rust) |
| **MinIO Console** | 9001 | `storage.omnioil.app` | ✅ | S3 Object UI | | **MinIO Console** | 9001 | `storage.omnioil.app` | ✅ | S3 Object UI |
| **Mosquitto (WSS)** | 9883 | `mqtt.omnioil.app` | ✅ | Encrypted Edge Communication | | **Mosquitto (WSS)*** | 443 | `mqtt.omnioil.app` | ✅ | Encrypted Edge Communication |
*\*Nota: El puerto público es el 443 (HTTPS/WSS) mapeado internamente por el proxy.*
--- ---
## 📡 2. Edge Agent Communication (MQTT over WSS) ## 📡 2. Edge Agent Communication (MQTT over WSS)
Communication with field agents MUST be done via **WSS (Port 443)** for security: Communication with field agents MUST be done via **WSS (Port 443)** for maximum compatibility:
1. **Redirection in Dokploy**: Direct the public subdomain `mqtt.tu-dominio.com` (Port 443) to the internal container `mosquitto` (Port 9883). 1. **Redirection in Dokploy**: Direct the public subdomain `mqtt.omnioil.app` (Port 443) to the internal container `mosquitto` (Port 9001 - WebSocket).
2. **Security**: Dokploy handles TLS termination, ensuring all MQTT traffic is encrypted. 2. **Security**: Dokploy/Traefik handles TLS termination. The agent connects via `wss://mqtt.omnioil.app:443`.
3. **Single Channel**: Telemetry, Health, and Logs all flow through this single encrypted connection.
4. **Topics Estandarizados**:
- `omnioil/telemetry/{id}`: Datos SCADA.
- `omnioil/health/{id}`: Estado del hardware y contenedores.
- `omnioil/logs/{id}`: Trazabilidad del agente.
--- ---
@@ -58,7 +81,9 @@ TUNNEL_SECRET=llave_para_tuneles_seguros_edge
# --- EDGE & ORQUESTACIÓN --- # --- EDGE & ORQUESTACIÓN ---
DOCKER_REGISTRY_URL=docker-registry:5000 DOCKER_REGISTRY_URL=docker-registry:5000
MQTT_HOST=mosquitto MQTT_HOST=mosquitto
MQTT_PORT=1883 # NOTA: Puerto interno (Docker a Docker), no expuesto. MQTT_PORT=1883 # Puerto interno (inter-service)
BACKEND_API_URL=https://api.omnioil.app
MQTT_PUBLIC_HOST=mqtt.omnioil.app
# --- FRONTEND --- # --- FRONTEND ---
VITE_API_BASE=https://api.omnioil.app VITE_API_BASE=https://api.omnioil.app
@@ -99,3 +124,19 @@ OmniOil uses a unified multi-stage build pipeline with **Cargo Chef** to drastic
- [ ] 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.
- [ ] 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
Use a single canonical hostname per frontend surface:
| Frontend | Canonical host | Ownership |
| :--- | :--- | :--- |
| `frontend-admin` | `app.omnioil.app` | Admin and superadmin dashboard/login |
| `frontend-pwa` | `mobile.omnioil.app` | Field/operator login and PWA flows |
Legacy hostnames stay only as migration redirects at the gateway layer:
| Legacy host | Redirect target | Policy |
| :--- | :--- | :--- |
| `admin.omnioil.app` | `https://app.omnioil.app$request_uri` | Redirect only, no direct app ownership |
| `campo.omnioil.app` | `https://mobile.omnioil.app$request_uri` | Redirect only, no direct app ownership |

View File

@@ -26,3 +26,24 @@ Finalmente, el archivo `OmniOilEdgeAgent.msi` resultante se pone a disposición
> [!IMPORTANT] > [!IMPORTANT]
> El error `Error al compilar recursos de Windows` durante el build inicial suele deberse a la falta del compilador de recursos `windres` en el path de Linux (cross-compilation). Ha sido corregido forzando la ruta hacia `x86_64-w64-mingw32-windres` en `build.rs`. > El error `Error al compilar recursos de Windows` durante el build inicial suele deberse a la falta del compilador de recursos `windres` en el path de Linux (cross-compilation). Ha sido corregido forzando la ruta hacia `x86_64-w64-mingw32-windres` en `build.rs`.
---
## 🏗️ Deuda Técnica y Recomendaciones Futuras (Fase 2)
A medida que el despliegue del Agente de Borde escala en producción, se recomienda estructurar las siguientes 3 mejoras arquitectónicas clave para sistemas IoT remotos:
### 1. El Auto-Updater vs El Windows Installer (.msi)
Actualmente el módulo interno `updater.rs` descarga actualizaciones desde Gitea y utiliza el método `self_replace` para sobrescribir el propio archivo `.exe` del agente.
* **Conflicto:** Como ahora la distribución se realiza oficial y exclusivamente a través de Windows Installer (`.msi`), realizar un reemplazo al `.exe` por debajo de la mesa eludirá el caché de Windows, corrompiendo los registros de desinstalación de "Agregar o quitar programas", dificultando reparaciones limpias.
* **Recomendación:** Refactorizar el Auto-Updater para que descargue silenciosamente el nuevo `.msi` completo en lugar del binario puro. Una vez descargado, ejecutar desde background mediante CMD: `msiexec /i instalador_nuevo.msi /qn`. De este modo, la actualización entra nativamente al registro de Windows, WiX detiene el servicio actual, hace el swapping seguro de DLLs y reinicia la aplicación exitosamente.
### 2. Resiliencia de Red y Latencia (Store & Forward)
Los pozos petroleros en locaciones geográficas inaccesibles lidian con desconexiones de red constantes (Micro-cortes en radio, celular o satélite).
* **Conflicto:** Si la conexión a la nube subyacente de Docker experimenta timeout y se emiten eventos o logs críticos por la librería `rumqttc`, el `Data Gateway` perderá dichos datos en el aire si la red cae durante un envío.
* **Recomendación:** Modificar la capa IPC/Gateways del agente y embeber una base de datos ligera como **SQLite**. Implementar un patrón de arquitectura **Store & Forward**: Cuando no hay internet, la telemetría reposa temporalmente en disco. Cuando se re-establece la conectividad MQTT (`QoS::AtLeastOnce`), se desaloja ("flush") la cola de métricas local asíncronamente sincronizando el Pozo con los servidores de Azure en orden cronológico exacto.
### 3. Orfandad de Contenedores en Desinstalación
El agente monta localmente volúmenes de Docker e imágenes pesadas (~500MB) para levantar Node-RED y TimescaleDB local y encapsular los drivers SCADA (Modbus, OPC UA).
* **Conflicto:** Al hacer click en *Desinstalar* en Windows, el instalador (`wixl`) elimina el ejecutable base, pero los contenedores administrados de Docker perduran ejecutándose como sistemas en la sombra (Orfandad), secuestrando permanentemente CPU y almacenamiento del equipo industrial del cliente.
* **Recomendación:** Extender el script principal `wix/main.wxs` insertando un **CustomAction**. Este evento pre-desinstalación le daría una orden explícita a la API de Docker Desktop: `docker stop omnioil-edge && docker rm omnioil-edge && docker rmi X` destruyendo de raíz toda la virtualización anclada **antes** de permitirse la auto-eliminación física de los binarios y directorios del agente OmniOil.

View File

@@ -0,0 +1,99 @@
# OmniOil: Infraestructura de Datos y API Reference
Este documento detalla la estructura lógica de la base de datos y los puntos de acceso (REST/MQTT) para el desarrollo del ecosistema OmniOil.
---
## 1. Modelo de Datos (ER Diagram)
El sistema utiliza **TimescaleDB** (PostgreSQL) para gestionar tanto datos relacionales como series de tiempo (telemetría).
```mermaid
erDiagram
CUSTOMERS ||--o{ EDGE_PROJECTS : "pertenece a"
EDGE_PROJECTS ||--o{ EDGE_ASSETS : "contiene"
EDGE_ASSETS ||--o{ EDGE_VARIABLES : "define"
EDGE_PROJECTS ||--o{ MQTT_USERS : "identidad"
MQTT_USERS ||--o{ MQTT_ACLS : "permisos"
EDGE_PROJECTS ||--o{ AGENT_LOGS : "genera"
EDGE_PROJECTS ||--o{ TELEMETRY_RAW : "registra historial"
EDGE_PROJECTS {
uuid id PK
string name
string installer_status "READY, BUILDING, ERROR"
jsonb last_health_report "Snapshot en tiempo real"
}
TELEMETRY_RAW {
timestamptz time PK
uuid project_id FK
uuid asset_id FK
jsonb data "Valores de variables"
}
MQTT_USERS {
string username PK
string password_hash "Bcrypt"
}
```
---
## 2. API REST (Backend)
Base URL: `http://localhost:3000/api`
### Auth & Usuarios
- `POST /auth/login`: Autenticación JWT.
- `GET /auth/me`: Verificación de sesión.
### Gestión de Agentes (Edge)
- `GET /edge/projects`: Lista todos los proyectos/agentes.
- `POST /edge/projects`: Crea un nuevo agente (dispara generación de MSI).
- `GET /edge/projects/:id`: Detalle completo y último estado de salud.
- `GET /edge/projects/:id/installer`: Obtiene el link de descarga del MSI.
### Assets & Variables
- `GET /edge/assets`: Lista de gemelos digitales.
- `POST /edge/assets`: Crear nuevo activo.
- `GET /edge/variables?asset_id=...`: Lista variables (Sensores) de un activo.
### Telemetría & Logs
- `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.
---
## 3. Arquitectura MQTT (Mosquitto)
El frontend y los agentes se comunican vía MQTT para el **Hot Path** (Tiempo Real).
### Tópicos Clave
| Tópico | Dirección | Descripción |
| :--- | :--- | :--- |
| `omnioil/health/:project_id` | Agente -> Todos | Reporte de vida y estado de Docker. |
| `omnioil/telemetry/:project_id` | Agente -> Todos | Datos de sensores en tiempo real (Live View). |
| `omnioil/logs/:project_id` | Agente -> Todos | Eventos de sistema y errores. |
| `omnioil/deploy/:project_id` | Web -> Agente | Comando para desplegar nueva configuración. |
| `omnioil/commands/:project_id` | Web -> Agente | Comandos directos (Reinicio, etc). |
---
## 4. Guía de Conexión para Frontend
### Paso A: Visualización en Tiempo Real (WSS)
El frontend debe usar una librería como `mqtt.js` para conectar:
- **Broker**: `wss://tu-dominio.com/mqtt` (Puerto 443).
- **Auth**: El backend proporciona credenciales MQTT temporales o el `project_id`.
- **Suscripción**: `client.subscribe("omnioil/telemetry/+")` para ver todo en vivo.
### Paso B: Almacenamiento y Norma (REST)
Para reportes legales o gráficas de largo plazo (días/meses):
- Consultar `GET /telemetry/history/:project_id`.
- Estos datos están filtrados a la resolución de 10 minutos requerida por la ANH.
---
## 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.
2. **Seguridad**: Nunca exponer el token de la base de datos directo; siempre pasar por el proxy de la API REST.
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.

View File

@@ -0,0 +1,346 @@
# PRD: Estabilizacion y Preparacion para Produccion — OmniOil
**Version:** 1.0
**Fecha:** 2026-04-07
**Autor:** Kyrbot Innovations
**Estado:** Borrador
**Proyecto:** OmniOil — SaaS de captura de datos de produccion de hidrocarburos (Resolucion ANH 0651)
---
## 1. Resumen Ejecutivo
OmniOil es un SaaS multi-tenant para captura de datos de produccion de hidrocarburos, requerido por la Resolucion 0651 de la ANH (Colombia). El sistema esta compuesto por 12 servicios (backend Rust/Axum, frontends React+Vite, MQTT, TimescaleDB, MinIO, Redis) con arquitectura de edge computing para campos petroleros.
Una auditoria tecnica completa revelo que el sistema no esta listo para produccion. Se identificaron **6 hallazgos criticos**, **4 de alta prioridad** y multiples items de deuda tecnica. Los mas graves: la API esta completamente abierta (sin validacion JWT en ningun endpoint), el modulo de movimientos no tiene backend, hay inconsistencias de schema entre `init.sql` y las migraciones sqlx, y el multi-tenant no se aplica a nivel de handlers.
**Impacto de negocio:** Sin estas correcciones, OmniOil no puede desplegarse en ningun cliente. La falta de autenticacion real implica que cualquier persona con acceso al dominio puede leer y modificar datos de produccion de todos los tenants. Los modulos faltantes de la Resolucion 0651 (paradas, pruebas de pozo, laboratorio, medidores) impiden el cumplimiento regulatorio.
Este PRD define un plan de estabilizacion en 4 fases, desde seguridad critica hasta calidad y mantenibilidad, con requisitos especificos, criterios de aceptacion y archivos afectados.
---
## 2. Contexto y Problema
### 2.1 Estado Actual
El sistema tiene funcionalidad parcial en los modulos base (auth, proyectos, activos, tanques, telemetria, dashboard) pero presenta fallas estructurales que bloquean cualquier despliegue productivo.
### 2.2 Hallazgos Criticos de la Auditoria
| # | Hallazgo | Severidad | Detalle |
|---|----------|-----------|---------|
| 1 | API completamente abierta | CRITICO | `verify_jwt()` existe en `services/backend-api/src/auth.rs:72-80` con `#[allow(dead_code)]`. Ningun handler valida JWT. Todos los endpoints son accesibles sin autenticacion. |
| 2 | Movimientos sin backend | CRITICO | PWA tiene UI completa (`services/frontend-pwa/src/features/field/movements/`) y llama a `/api/movements` pero ese handler no existe en `services/backend-api/src/handlers/`. Datos quedan en Dexie localmente. |
| 3 | Field name mismatch | CRITICO | Backend `EdgeAsset` (`shared-lib/src/models.rs:35-36`) retorna `code`/`name`. PWA (`features/field/measurements/api/assets-api.ts:9-10`) espera `asset_code`/`asset_name`. UI muestra `undefined`. |
| 4 | Multi-tenant sin enforcement | CRITICO | `user_project_access` existe en el schema pero los handlers (e.g. `edge_assets.rs:26-34`) hacen `SELECT * FROM edge_assets WHERE project_id = $1` sin filtrar por usuario autenticado. |
| 5 | Schema dual inconsistente | CRITICO | `infrastructure/postgres/init.sql` define `operations_movements` (linea 166), `operations_downtime` (linea 179), `lab_results` (linea 191) — ninguna existe en `services/backend-api/migrations/20260406000000_initial_schema.sql`. |
| 6 | Seguridad deplorable | CRITICO | JWT_SECRET: `devsecretkeysomnioil` (docker-compose.yml:71), CORS permissive (main.rs:173), MQTT 1883 expuesto (docker-compose.yml:179), credenciales por defecto (DB: `admin123`, Redis: `redisoilsecret456`, MinIO: `minio123`). |
### 2.3 Hallazgos de Alta Prioridad
| # | Hallazgo | Severidad |
|---|----------|-----------|
| 7 | Sync worker no funcional — `use-sync-worker.ts` no integrado en layout/router; `addToQueue` ignora ID pasado | ALTO |
| 8 | ~70% frontend duplicado entre admin y PWA (auth-store, client, db, cn, UI components) | ALTO |
| 9 | Sin tests — solo 1 test en `overlay.rs`; sin tests de handlers, frontend, ni CI/CD | ALTO |
| 10 | Modulos ANH faltantes — paradas, pruebas, laboratorio, medidores, visualizacion reportes, gestion usuarios, validacion datos | ALTO |
### 2.4 Completitud de Modulos vs. ANH 0651
| Modulo | Backend | Admin | PWA | Estado |
|--------|---------|-------|-----|--------|
| Auth | Si | Si | Si | Funciona pero sin validacion JWT |
| Projects CRUD | Si | Si | Si | Completo |
| Assets CRUD | Si | Parcial | Parcial | Falta UI creacion en PWA |
| Tanques (inventario + telemetria) | Si | Si | Si | Funcional |
| Mediciones de campo | Si | No | Si | Solo PWA |
| Movimientos | No | No | Si (UI) | Frontend sin backend |
| Paradas de pozo | No | No | No | No implementado |
| Pruebas de pozo | No | No | No | No implementado |
| Analisis de laboratorio | No | No | No | No implementado |
| Medidores (gas/liquido) | No | No | No | No implementado |
| Reporte ANH (generacion) | Si | No | No | Backend only |
| Reporte ANH (visualizacion) | No | No | No | No implementado |
| Dashboard | Si | Si | Si | Funcional |
| Alertas/Alarmas | Modelos | No | No | Solo modelos |
| Validacion de datos | No | No | No | No implementado |
| Edge Agent deploy | Si | Si | No | Solo admin |
| Gestion de usuarios | No | No | No | No implementado |
---
## 3. Objetivos
| ID | Objetivo | Metrica |
|----|----------|---------|
| OBJ-1 | Cerrar todas las vulnerabilidades de seguridad criticas | 0 endpoints accesibles sin JWT valido (excepto login/register/health) |
| OBJ-2 | Lograr consistencia completa entre schema de BD y migraciones | 1 unica fuente de verdad: migraciones sqlx |
| OBJ-3 | Completar todos los modulos requeridos por la Resolucion ANH 0651 | 100% de modulos con backend + al menos 1 frontend funcional |
| OBJ-4 | Implementar multi-tenancy real a nivel de handler | 100% de queries filtradas por user_project_access |
| OBJ-5 | Reducir duplicacion frontend a <15% | Paquete compartido con auth, client, db, UI base |
| OBJ-6 | Establecer infraestructura de testing y CI/CD | >80% cobertura en handlers, pipeline funcional |
---
## 4. Alcance
### 4.1 En Alcance
- Correccion de todas las vulnerabilidades de seguridad identificadas
- Unificacion de schema (migraciones como fuente unica)
- Implementacion de backend para modulos faltantes (movimientos, paradas, pruebas, laboratorio, medidores)
- Multi-tenant enforcement en todos los handlers
- Correccion del sync worker y bug de UUID
- Correccion del field name mismatch (code/name vs asset_code/asset_name)
- UI para reportes ANH y gestion de usuarios
- Validacion de datos operativos
- Frontend deduplication (shared package)
- Tests de handlers, tests de frontend, CI/CD basico
- Cleanup de deuda tecnica identificada
### 4.2 Fuera de Alcance
- Redesign de UI/UX (solo correcciones funcionales)
- Migracion a otro proveedor cloud (se mantiene Azure)
- Implementacion de IoT avanzado (SCADA, OPC UA en tiempo real)
- Aplicacion movil nativa
- Modulo de facturacion/cobro del SaaS
- Integraciones con sistemas ERP de clientes
- Internacionalizacion (el sistema opera en espanol)
- Alta disponibilidad / redundancia geografica
---
## 5. Fases de Implementacion
### Fase 1: Seguridad Critica
**Objetivo:** Cerrar todos los vectores de ataque que bloquean el despliegue.
**Duracion estimada:** 2-3 semanas
**Criterio de entrada:** Acceso al repositorio y entorno de desarrollo
**Criterio de salida:** Ningun endpoint (excepto login, register, health) accesible sin JWT valido; secrets externalizados; CORS restrictivo; MQTT sobre TLS
### Fase 2: Integridad de Datos
**Objetivo:** Garantizar que los datos fluyen correctamente end-to-end y que el multi-tenant es real.
**Duracion estimada:** 3-4 semanas
**Criterio de entrada:** Fase 1 completada (auth middleware funcional)
**Criterio de salida:** Schema unificado, movimientos funcionales, sync worker operativo, field names corregidos, multi-tenant enforced
### Fase 3: Modulos ANH Faltantes
**Objetivo:** Implementar los modulos requeridos por la Resolucion 0651 que no existen.
**Duracion estimada:** 6-8 semanas
**Criterio de entrada:** Fase 2 completada (schema estable, multi-tenant funcional)
**Criterio de salida:** Todos los modulos ANH con backend + frontend funcional; validacion de datos; gestion de usuarios
### Fase 4: Calidad y Mantenibilidad
**Objetivo:** Reducir deuda tecnica, establecer testing y CI/CD.
**Duracion estimada:** 3-4 semanas
**Criterio de entrada:** Fases 1-3 completadas
**Criterio de salida:** Paquete frontend compartido, >80% cobertura en handlers, pipeline CI/CD funcional
---
## 6. Requisitos por Fase
### 6.1 Fase 1: Seguridad Critica
| ID | Descripcion | Criterio de Aceptacion | Archivos Afectados | Prioridad | Complejidad |
|----|-------------|------------------------|---------------------|-----------|-------------|
| REQ-F1-001 | Implementar middleware de autenticacion JWT como extractor de Axum que valide token en header `Authorization: Bearer <token>` y extraiga Claims (sub, role, exp) | Todo request a endpoints protegidos sin JWT valido retorna 401. Claims disponibles en handler via extractor `AuthUser`. | `services/backend-api/src/auth.rs` (remover `#[allow(dead_code)]`), nuevo `services/backend-api/src/middleware/auth.rs` | P0 | M |
| REQ-F1-002 | Aplicar middleware de auth a TODOS los endpoints excepto: `POST /api/auth/login`, `POST /api/auth/register`, `GET /` (health) | Test: request a `/api/edge/projects` sin token retorna 401; con token valido retorna 200 | `services/backend-api/src/main.rs` (lineas 184-210, reestructurar Router con layers) | P0 | M |
| REQ-F1-003 | Incluir `project_id` en Claims JWT y validarlo en handlers que operan sobre un proyecto | Login retorna token con `project_ids: Vec<Uuid>` basado en `user_project_access`. Handlers validan que el project_id del request esta en los claims. | `services/backend-api/src/auth.rs` (Claims struct), `services/backend-api/src/handlers/auth.rs` (login, lineas 87-102) | P0 | L |
| REQ-F1-004 | Externalizar todos los secrets: JWT_SECRET, DB credentials, MQTT credentials, Redis password, MinIO credentials. Eliminar defaults hardcodeados. | `docker-compose.yml` no contiene ningun valor por defecto para secrets. Se usa `.env.example` como referencia sin valores reales. Aplicacion falla al iniciar si falta JWT_SECRET. | `docker-compose.yml` (lineas 15-19, 70-76, 34, 50-51), nuevo `.env.example` | P0 | S |
| REQ-F1-005 | Restringir CORS a dominios especificos: `app.omnioil.app`, `mobile.omnioil.app`, `localhost:*` en dev | CORS rechaza requests desde origenes no listados. Header `Access-Control-Allow-Origin` no es `*`. | `services/backend-api/src/main.rs:173` (reemplazar `CorsLayer::permissive()`) | P0 | S |
| REQ-F1-006 | Configurar MQTT sobre WSS (puerto 443/8883). Deshabilitar listener 1883 en produccion. | Puerto 1883 no expuesto en docker-compose de produccion. Mosquitto acepta conexiones WSS. Edge agents conectan por WSS. | `docker-compose.yml:179-180`, `infrastructure/mosquitto/mosquitto.conf`, edge-agent config | P0 | L |
| REQ-F1-007 | Aplicar rate limiting solo a endpoints de auth (`/api/auth/*`), no globalmente | Rate limit (5 req/30s por IP) aplica a login y register. Endpoints de datos no tienen rate limit global. | `services/backend-api/src/main.rs:176-190` (mover GovernorLayer a sub-router de auth) | P1 | S |
| REQ-F1-008 | Deshabilitar endpoint de registro publico. Registro solo por invitacion de admin. | `POST /api/auth/register` retorna 403 sin token de admin. Admin puede crear usuarios via endpoint protegido. | `services/backend-api/src/handlers/auth.rs:120-182`, `services/backend-api/src/main.rs:187` | P1 | M |
| REQ-F1-009 | No exponer errores de base de datos al cliente. Mapear todos los errores DB a mensajes genericos. | Responses de error no contienen nombres de tablas, columnas, ni detalles SQL. Solo mensajes genericos con correlation ID. | `services/backend-api/src/handlers/edge_assets.rs` (todos los `.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))`) — patron repetido en todos los handlers | P1 | M |
| REQ-F1-010 | Implementar refresh tokens. Access token con TTL corto (15 min), refresh token con TTL largo (7 dias). | Tabla `refresh_tokens` (ya existe en init.sql) utilizada. Login retorna access + refresh. Endpoint `POST /api/auth/refresh`. | `services/backend-api/src/auth.rs`, nuevo handler `refresh`, migracion para tabla `refresh_tokens` | P1 | M |
### 6.2 Fase 2: Integridad de Datos
| ID | Descripcion | Criterio de Aceptacion | Archivos Afectados | Prioridad | Complejidad |
|----|-------------|------------------------|---------------------|-----------|-------------|
| REQ-F2-001 | Unificar schema: migrar tablas de `init.sql` que no estan en migraciones sqlx (`operations_movements`, `operations_downtime`, `lab_results`) a nuevas migraciones | Todas las tablas definidas exclusivamente en migraciones sqlx. `init.sql` se usa solo para seed de datos iniciales (roles, usuario admin). `sqlx migrate run` crea todas las tablas necesarias. | Nueva migracion `services/backend-api/migrations/2026XXXX_add_operations_tables.sql`, `infrastructure/postgres/init.sql` (reducir a seed only) | P0 | M |
| REQ-F2-002 | Corregir field name mismatch: Backend retorna `code`/`name`, PWA espera `asset_code`/`asset_name` | Ambos frontends y backend usan los mismos nombres de campo. API contract documentado. UI no muestra `undefined`. | Opcion A (preferida): agregar `#[serde(rename)]` en `shared-lib/src/models.rs:35-36` para serializar como `asset_code`/`asset_name`. Opcion B: actualizar frontends. | P0 | S |
| REQ-F2-003 | Implementar handler backend para movimientos (`/api/movements`) con CRUD completo | `POST /api/movements` crea movimiento en `operations_movements`. `GET /api/movements?asset_id=X` lista movimientos por activo. PWA puede crear y listar movimientos. | Nuevo `services/backend-api/src/handlers/movements.rs`, actualizar `services/backend-api/src/handlers/mod.rs`, actualizar `services/backend-api/src/main.rs` (agregar rutas) | P0 | M |
| REQ-F2-004 | Enforcement multi-tenant: todos los handlers deben filtrar por proyectos del usuario autenticado | Queries incluyen `JOIN user_project_access upa ON ... WHERE upa.user_id = $auth_user_id`. Un usuario no puede ver/modificar datos de proyectos a los que no tiene acceso. | Todos los handlers en `services/backend-api/src/handlers/`: `edge_projects.rs`, `edge_assets.rs`, `telemetry.rs`, `dashboard.rs`, y futuros handlers | P0 | L |
| REQ-F2-005 | Integrar sync worker en el layout de PWA para que se ejecute automaticamente | `useSyncWorker()` se invoca en el layout principal. Items `pending_sync` se procesan cuando hay conexion. | `services/frontend-pwa/src/app/layouts/` (o equivalente), `services/frontend-pwa/src/lib/sync/use-sync-worker.ts` | P0 | S |
| REQ-F2-006 | Corregir bug de UUID en `addToQueue`: debe usar ID proporcionado si existe, no generar nuevo siempre | Si se pasa un `id` en el entry, se usa ese ID. Si no, se genera con `crypto.randomUUID()`. Test: crear item con ID conocido, verificar que se almacena con ese ID. | `services/frontend-pwa/src/lib/sync/sync-store.ts:34-35` (cambiar `Pick` para incluir `id` opcional, usar `entry.id ?? crypto.randomUUID()`) | P0 | S |
| REQ-F2-007 | Agregar columnas faltantes en migracion sqlx: `alarm_min`, `alarm_max`, `alarm_enabled` en `edge_variables`; `installer_status`, `installer_url` en `edge_projects` | Migraciones sqlx reflejan el schema completo de init.sql. `sqlx migrate run` en DB limpia produce schema identico al esperado. | `services/backend-api/migrations/20260406000000_initial_schema.sql` vs `infrastructure/postgres/init.sql` — nueva migracion para columnas faltantes | P1 | S |
| REQ-F2-008 | Agregar tabla `audit_logs` en migraciones para trazabilidad de operaciones | Tabla `audit_logs` con campos: id, user_id, action, entity_type, entity_id, old_value, new_value, created_at. Inserts en operaciones criticas (CRUD de datos operativos). | Nueva migracion, nuevo middleware o helper de audit | P1 | M |
| REQ-F2-009 | Implementar sync bidireccional: movimientos creados offline en PWA deben sincronizar al backend cuando haya conexion | Sync worker reconoce `operation_type: 'movement'` y llama a `POST /api/movements`. Items sincronizados cambian a `synced`. | `services/frontend-pwa/src/lib/sync/use-sync-worker.ts` (agregar handler para movements, actualmente solo maneja `measurement`) | P1 | M |
### 6.3 Fase 3: Modulos ANH Faltantes
| ID | Descripcion | Criterio de Aceptacion | Archivos Afectados | Prioridad | Complejidad |
|----|-------------|------------------------|---------------------|-----------|-------------|
| REQ-F3-001 | Backend: CRUD de paradas de pozo (`operations_downtime`) | Endpoints `GET/POST/PUT/DELETE /api/downtime`. Filtrado por asset_id y rango de fecha. Campos: asset_id, start_time, end_time, is_planned, reason, comments. | Nuevo `services/backend-api/src/handlers/downtime.rs`, modelo en `shared-lib`, rutas en `main.rs` | P0 | M |
| REQ-F3-002 | Frontend: modulo de paradas de pozo en PWA | Formulario de captura con: seleccion de pozo, fecha inicio/fin, tipo (planificada/no planificada), razon, comentarios. Listado con filtros. Funciona offline con sync. | Nuevo `services/frontend-pwa/src/features/field/downtime/` | P0 | L |
| REQ-F3-003 | Backend: CRUD de pruebas de pozo | Endpoints `GET/POST/PUT/DELETE /api/well-tests`. Campos segun ANH 0651: asset_id, test_date, test_type, bopd, bwpd, gor, bsw, choke_size, wellhead_pressure, casing_pressure, comments. | Nuevo handler, modelo, migracion para tabla `well_tests` | P0 | M |
| REQ-F3-004 | Frontend: modulo de pruebas de pozo en PWA | Formulario con todos los campos ANH. Validacion Zod. Offline-capable. Listado historico por pozo. | Nuevo `services/frontend-pwa/src/features/field/well-tests/` | P0 | L |
| REQ-F3-005 | Backend: CRUD de analisis de laboratorio (`lab_results`) | Endpoints `GET/POST/PUT/DELETE /api/lab-results`. Campos: asset_id, sample_time, results (JSONB flexible), lab_technician. Soporte para diferentes tipos de analisis. | Nuevo handler, modelo. Tabla ya definida en init.sql pero requiere migracion (REQ-F2-001). | P0 | M |
| REQ-F3-006 | Frontend: modulo de analisis de laboratorio en PWA | Formulario dinamico segun tipo de analisis (crudo, agua, gas). Adjuntar resultados. Historial por activo. | Nuevo `services/frontend-pwa/src/features/field/lab/` | P0 | L |
| REQ-F3-007 | Backend: CRUD de medidores de gas y liquido | Endpoints `GET/POST/PUT/DELETE /api/meters/readings`. Campos: asset_id (tipo MEDIDOR_GAS o AGUA), reading_time, value, unit, meter_factor, corrected_value. Migracion para tabla `meter_readings`. | Nuevo handler, modelo, migracion | P0 | M |
| REQ-F3-008 | Frontend: modulo de medidores en PWA | Captura de lecturas de medidores. Diferenciacion gas/liquido. Calculo automatico de factor de correccion si aplica. Historico. | Nuevo `services/frontend-pwa/src/features/field/meters/` | P0 | L |
| REQ-F3-009 | Frontend Admin: visualizacion de reportes ANH | Pantalla en admin que muestra el reporte ANH generado por `json-generator`. Preview del JSON. Opcion de descarga. Filtro por proyecto y periodo. | Nuevo `services/frontend-admin/src/features/admin/reports/` | P1 | M |
| REQ-F3-010 | Backend + Admin: gestion de usuarios | CRUD de usuarios (solo admin). Asignacion de roles. Asignacion usuario-proyecto (`user_project_access`). Activar/desactivar cuentas. | Nuevo `services/backend-api/src/handlers/users.rs`, nuevo `services/frontend-admin/src/features/admin/users/` | P0 | L |
| REQ-F3-011 | Validacion de datos operativos | Reglas de validacion antes de persistir: volumenes positivos, fechas coherentes (end > start), campos obligatorios por tipo de operacion, rangos de BSW (0-100), GOR > 0, presiones > 0. | Logica de validacion en cada handler (movimientos, paradas, pruebas, lab, medidores). Errores claros al usuario. | P1 | M |
| REQ-F3-012 | Admin: modulo de mediciones de campo (paridad con PWA) | Admin tiene acceso a ver y gestionar mediciones que los operadores capturan en PWA. Tabla con filtros por proyecto, activo, fecha. | Nuevo `services/frontend-admin/src/features/admin/measurements/` | P2 | M |
| REQ-F3-013 | Alertas y alarmas funcionales | Backend evalua alarmas definidas en `edge_variables` (alarm_min, alarm_max). Genera eventos cuando telemetria excede umbrales. Frontend muestra notificaciones. | Logica en `events-relay` o `data-collector`, nuevo handler para consultar alertas activas, UI en admin | P2 | XL |
### 6.4 Fase 4: Calidad y Mantenibilidad
| ID | Descripcion | Criterio de Aceptacion | Archivos Afectados | Prioridad | Complejidad |
|----|-------------|------------------------|---------------------|-----------|-------------|
| REQ-F4-001 | Crear paquete compartido de frontend (`packages/shared` o workspace Vite) | Codigo comun extraido: `auth-store.ts`, `client.ts`, `db/index.ts`, `cn.ts`, todos los componentes UI base (`button`, `card`, `badge`, `separator`). Ambos frontends importan del paquete compartido. Duplicacion < 15%. | `services/frontend-admin/src/lib/`, `services/frontend-pwa/src/lib/`, `services/frontend-admin/src/components/ui/`, `services/frontend-pwa/src/components/ui/`, `services/frontend-admin/src/features/auth/` todo migrado a paquete compartido | P1 | XL |
| REQ-F4-002 | Tests de handlers backend (unit + integration) | Cada handler tiene al menos 1 test de exito y 1 de error. Tests de auth middleware. Tests de multi-tenant isolation. Ejecutables con `cargo test`. | Nuevo directorio `services/backend-api/tests/`, archivos de test por modulo | P0 | L |
| REQ-F4-003 | Tests de frontend (componentes criticos) | Tests de auth flow, sync store, formularios de captura. Ejecutables con `vitest`. | Archivos `.test.ts`/`.test.tsx` junto a componentes en ambos frontends | P1 | L |
| REQ-F4-004 | Pipeline CI/CD basico | GitHub Actions: lint (clippy + eslint), test (cargo test + vitest), build (docker build). Se ejecuta en push a main y PRs. | Nuevo `.github/workflows/ci.yml` | P1 | M |
| REQ-F4-005 | Corregir `useTanks` para usar react-query en vez de useState/useEffect | `useTanks` usa `useQuery` de `@tanstack/react-query`, consistente con el patron del resto del frontend. Cache, refetch, loading states manejados por react-query. | `services/frontend-admin/src/features/admin/tanks/hooks/useTanks.ts` | P2 | S |
| REQ-F4-006 | Rehabilitar o eliminar servicios muertos: `flow-generator` (sin main.rs), `data-collector` (comentado) | Cada servicio listado en el workspace Cargo.toml compila y tiene proposito claro, o se elimina. | `services/flow-generator/`, `services/data-collector/`, `docker-compose.yml:86-100`, `Cargo.toml` | P2 | S |
| REQ-F4-007 | Documentar API con OpenAPI/Swagger | Spec OpenAPI generada automaticamente o mantenida manualmente. Disponible en `/api/docs`. Cubre todos los endpoints con request/response schemas. | Nuevo middleware o crate (e.g., `utoipa`), `services/backend-api/src/main.rs` | P2 | M |
---
## 7. Requisitos No Funcionales
### 7.1 Rendimiento
| Requisito | Metrica |
|-----------|---------|
| Latencia API (p95) | < 200ms para CRUD, < 500ms para queries con JOINs complejos |
| Telemetria (ingestion) | Soportar 100 data points/segundo por proyecto sin degradacion |
| PWA offline | Formularios funcionales sin conexion. Sync automatico al reconectar en < 30 segundos |
| Dashboard load | < 3 segundos para carga inicial con datos de 1 mes |
### 7.2 Disponibilidad
| Requisito | Metrica |
|-----------|---------|
| Uptime | 99.5% mensual (excluye mantenimiento programado) |
| Recovery | RTO < 1 hora, RPO < 15 minutos (backups de TimescaleDB) |
| Graceful degradation | PWA funciona offline. Perdida de MQTT no afecta captura manual. |
### 7.3 Seguridad
| Requisito | Detalle |
|-----------|---------|
| Autenticacion | JWT con firma HS256 minimo. Access token TTL <= 15 min. Refresh token TTL <= 7 dias. |
| Autorizacion | RBAC con 4 roles (admin, supervisor, operador, anh_reader). Multi-tenant por proyecto. |
| Transporte | HTTPS obligatorio en produccion. MQTT sobre WSS (puerto 443). |
| Secrets | Externalizados via variables de entorno. Sin defaults en docker-compose. |
| Passwords | Argon2id con salt aleatorio (ya implementado). Minimo 8 caracteres. |
| Auditoria | Todas las operaciones CRUD logueadas en `audit_logs` con user_id y timestamp. |
### 7.4 Cumplimiento
| Requisito | Detalle |
|-----------|---------|
| ANH Resolucion 0651 | Todos los campos de captura definidos por la resolucion implementados y validados |
| Retencion de datos | Telemetria comprimida despues de 7 dias (ya configurado en TimescaleDB). Datos operativos retenidos por periodo reglamentario (5 anos minimo). |
| Trazabilidad | Cada registro tiene `created_at`, `updated_at`, y entry en `audit_logs` |
---
## 8. Riesgos y Mitigaciones
| # | Riesgo | Probabilidad | Impacto | Mitigacion |
|---|--------|-------------|---------|------------|
| R1 | Migraciones de schema rompen datos existentes en entornos de prueba | Media | Alto | Ejecutar migraciones primero en entorno staging. Backup antes de cada migracion. Usar `IF NOT EXISTS` y migrations idempotentes. |
| R2 | Introduccion de auth middleware rompe integraciones existentes (edge agents, MQTT) | Alta | Alto | Edge agents usan MQTT, no la API HTTP directamente. Verificar que el build-orchestrator tiene token de servicio. Crear cuenta de servicio para integraciones internas. |
| R3 | Rename de campos (code->asset_code) rompe clientes existentes | Media | Medio | Usar `#[serde(rename)]` en backend para no cambiar columnas DB. Deprecar nombres viejos con periodo de transicion. |
| R4 | Frontend deduplication introduce regresiones | Media | Medio | Ejecutar tests de humo manuales en ambos frontends despues de cada cambio. Crear tests automatizados primero (REQ-F4-003). |
| R5 | Especificacion ANH 0651 tiene campos no documentados o ambiguos | Media | Alto | Consultar documento oficial de la ANH. Validar estructura de reporte JSON con el `json-generator` existente. |
| R6 | Dependencia de Azure Container Apps puede tener limitaciones de configuracion WSS/MQTT | Baja | Alto | Documentar configuracion de Traefik/proxy para WSS. Tener fallback con Azure API Management para proxy MQTT. |
| R7 | Complejidad de multi-tenant enforcement puede degradar rendimiento de queries | Baja | Medio | Indices en `user_project_access(user_id, project_id)`. Evaluar caching de permisos en Redis. |
---
## 9. Criterios de Exito
### Fase 1 Completada Cuando:
- [ ] `curl -X GET https://api.omnioil.app/api/edge/projects` retorna 401
- [ ] `curl -X GET -H "Authorization: Bearer <valid_token>" https://api.omnioil.app/api/edge/projects` retorna 200 con datos del proyecto asignado
- [ ] docker-compose.yml no contiene ningun secret hardcodeado
- [ ] CORS rechaza request desde `https://malicious-site.com`
- [ ] Puerto 1883 no es accesible desde fuera del docker network
- [ ] `POST /api/auth/register` requiere token de admin
- [ ] Responses de error no contienen informacion de schema de BD
### Fase 2 Completada Cuando:
- [ ] `sqlx migrate run` en BD vacia crea todas las tablas (incluyendo operations_movements, operations_downtime, lab_results, audit_logs)
- [ ] `GET /api/movements?asset_id=X` retorna movimientos del activo
- [ ] PWA muestra `asset_code` y `asset_name` correctamente (no `undefined`)
- [ ] Usuario A no puede ver proyectos de Usuario B
- [ ] Sync worker se ejecuta automaticamente al abrir PWA y procesa items pendientes
- [ ] Movimientos creados offline se sincronizan al reconectar
### Fase 3 Completada Cuando:
- [ ] Cada modulo ANH (paradas, pruebas, laboratorio, medidores) tiene CRUD backend funcional
- [ ] PWA permite capturar datos de todos los modulos ANH
- [ ] Admin permite visualizar reportes ANH generados
- [ ] Admin permite gestionar usuarios y asignaciones proyecto-usuario
- [ ] Datos ingresados pasan validacion (volumenes > 0, fechas coherentes, campos obligatorios presentes)
### Fase 4 Completada Cuando:
- [ ] `cargo test` ejecuta > 20 tests y pasa
- [ ] `vitest` ejecuta tests de componentes criticos y pasa
- [ ] CI pipeline ejecuta en cada PR: lint + test + build
- [ ] Duplicacion entre frontend-admin y frontend-pwa < 15% (medido por archivos identicos)
- [ ] `flow-generator` y `data-collector` estan activos con proposito o eliminados
---
## 10. Dependencias
### Entre Fases
```
Fase 1 (Seguridad) ──> Fase 2 (Integridad)
├──> Fase 3 (Modulos ANH)
└──> Fase 4 (Calidad)
```
- **Fase 2 depende de Fase 1:** El auth middleware (F1) es prerequisito para multi-tenant enforcement (F2). Los handlers de movimientos (F2) necesitan validar JWT.
- **Fase 3 depende de Fase 2:** Los nuevos modulos deben construirse sobre schema unificado (F2) y con multi-tenant ya implementado.
- **Fase 4 puede iniciar parcialmente en paralelo con Fase 3:** Tests y CI/CD pueden configurarse mientras se desarrollan modulos. Frontend dedup puede hacerse post Fase 3.
### Dependencias Externas
| Dependencia | Tipo | Impacto |
|-------------|------|---------|
| Documento Resolucion ANH 0651 | Regulatoria | Define campos exactos de cada modulo. Necesario para REQ-F3-001 a REQ-F3-008. |
| Azure Container Apps | Infraestructura | Configuracion de custom domains, TLS certificates, routing rules para WSS. |
| Azure API Management | Infraestructura | Posible proxy para MQTT WSS si Traefik directo no es viable. |
| Crate `utoipa` o similar | Tecnica | Para documentacion OpenAPI automatica (REQ-F4-007). |
| `@tanstack/react-query` | Tecnica | Ya en uso parcial. Necesario para estandarizacion (REQ-F4-005). |
---
## Apendice A: Resumen de Archivos Clave
| Archivo | Rol | Estado |
|---------|-----|--------|
| `services/backend-api/src/auth.rs` | JWT creation + verification | `verify_jwt` marcado dead_code |
| `services/backend-api/src/main.rs` | Router, middleware, startup | CORS permissive, rate limit global |
| `services/backend-api/src/handlers/auth.rs` | Login/register | Register publico, no project_id en claims |
| `services/backend-api/src/handlers/edge_assets.rs` | CRUD assets | Sin filtro multi-tenant, expone errores DB |
| `services/shared-lib/src/models.rs` | Modelos compartidos | EdgeAsset usa `code`/`name` (no `asset_code`/`asset_name`) |
| `services/backend-api/migrations/` | Schema migraciones sqlx | Faltan tablas de operations, lab, audit |
| `infrastructure/postgres/init.sql` | Schema maestro (legacy) | Tiene tablas que migraciones no tienen |
| `services/frontend-pwa/src/lib/sync/sync-store.ts` | Cola de sync offline | Bug: ignora ID pasado |
| `services/frontend-pwa/src/lib/sync/use-sync-worker.ts` | Worker de sync | No integrado en layout, solo maneja measurements |
| `services/frontend-pwa/src/features/field/movements/` | UI movimientos | Llama a `/api/movements` que no existe |
| `services/frontend-pwa/src/features/field/measurements/api/assets-api.ts` | Tipo EdgeAsset en PWA | Usa `asset_code`/`asset_name` (mismatch con backend) |
| `docker-compose.yml` | Orquestacion | Secrets hardcodeados, MQTT 1883 expuesto |

View File

@@ -0,0 +1,87 @@
# Resumen Ejecutivo — PRD Estabilización y Preparación para Producción — OmniOil
**Versión:** 1.0
**Fecha:** 2026-04-07
**Autor:** Kyrbot Innovations
**Fuente:** `docs/PRD_ESTABILIZACION_PRODUCCION.md`
---
## 1) Objetivo
Estabilizar OmniOil y dejarlo listo para un despliegue productivo seguro y conforme a la Resolución ANH 0651, cerrando brechas críticas de seguridad, completitud funcional y mantenibilidad en cuatro fases.
## 2) Riesgos críticos (actual)
- API sin verificación JWT: ningún endpoint aplica autenticación real.
- Módulo de movimientos: frontend sin backend; datos quedan locales.
- Desalineación PWA vs backend: campos `asset_code/name` vs `code/name`.
- Multitenant sin enforcement: handlers no filtran por usuario/proyecto.
- Esquema dual inconsistente: `init.sql` difiere de migraciones `sqlx`.
- Seguridad débil: secretos por defecto, CORS permisivo, puertos expuestos.
## 3) Alcance por fases (highlevel)
- Fase 1 — Seguridad crítica (P0)
- Enforce JWT en todos los handlers; middleware global.
- Rotación de secretos; CORS restrictivo; hardening de red (MQTT/DB/Redis/MinIO).
- Corrección field mismatch assets; lint de rutas y cabeceras de seguridad.
- Fase 2 — Funcional base y consistencia (P0/P1)
- Backend de movimientos (`/api/movements`) + sync offline PWA.
- Alinear esquema: migraciones `sqlx` vs `init.sql` (única fuente).
- Enforcement multitenant en queries y handlers.
- Integrar `use-sync-worker` y colas (measurements, movements).
- Fase 3 — Módulos ANH faltantes (P0)
- Paradas (`/api/downtime`), Pruebas de pozo (`/api/well-tests`).
- Laboratorio (`/api/lab-results`), Medidores (`/api/meters/readings`).
- Gestión de usuarios y reglas de validación de datos operativos.
- Fase 4 — Calidad y mantenibilidad (P1)
- Paquete compartido de frontend para eliminar duplicación.
- Tests backend/handlers, smoke E2E básicos, CI/CD mínima.
- Documentación de despliegue y procedimientos operativos (runbooks).
## 4) Listo para Producción (L4P) — criterios clave
- Autenticación: 100% endpoints protegidos con JWT y autorización por proyecto.
- Seguridad: secretos rotados; CORS por allowlist; servicios internos no expuestos.
- Datos: migraciones consistentes y reversibles; backups y restauración probados.
- Funcional: movimientos operativos y sync offline funcionando; gaps P0 cerrados.
- Calidad: smoke tests verdes; monitorización básica; sin P0/P1 abiertos.
## 5) Métricas de éxito (objetivos)
- p95 latencia endpoints core < 300 ms; error 5xx < 0.1%.
- Tiempo medio de sync (PWAAPI) < 60 s en conectividad regular.
- Cobertura mínima handlers backend 50% (creciente); E2E smoke básico.
- 0 vulnerabilidades críticas en escaneo SCA/contenerización.
## 6) Plan inmediato (72 horas)
- Enforce JWT + middleware global; rotar `JWT_SECRET` y variables sensibles.
- Cerrar CORS; cerrar puertos externos (MQTT, DB, Redis, MinIO) y usar red interna.
- Arreglar mismatch de campos de assets en PWA o normalizar en backend.
- Definir y aplicar migración de reconciliación `sqlx` vs `init.sql` (fuente única).
- Implementar `POST/GET /api/movements` y conectar `use-sync-worker`.
## 7) Cronograma tentativo
- Semana 1: Fase 1 completa (seguridad) + arranque Fase 2.
- Semanas 23: Fase 2 completa (funcional base + consistencia).
- Semanas 36: Fase 3 (módulos ANH prioritarios).
- Semana 7: Fase 4 (calidad, paquete compartido, CI/CD básica).
## 8) Dependencias y decisiones
- Estrategia de migraciones: consolidar en `services/backend-api/migrations` como fuente única.
- Política CORS por ambientes y dominios cliente.
- Modelo de roles/ACL por proyecto y scopes de API.
- Rotación de secretos y almacenamiento seguro (envvars/secrets manager).
## 9) Seguimiento
- Tablero Estabilización con épicas por fase; etiquetas `P0/P1` y `Seguridad/Funcional/Calidad`.
- Standup corto de riesgos diarios; demo quincenal de avances de Fases 23.

View File

@@ -0,0 +1,43 @@
# OmniOil Security Hardening & MQTT Transition Summary
**Sesión: 08 de Abril, 2026**
## 1. Visión General del Cambio
Se ha migrado la arquitectura de comunicación campo-nube de un modelo híbrido (HTTP/MQTT) a un modelo de **Outbound-Only Single-Tunnel** basado exclusivamente en MQTT sobre WebSockets (WSS) a través del puerto **443**.
## 2. Cambios Técnicos en la Infraestructura
### 2.1 Estandarización de Temas (Topics)
Se ha implementado un nuevo namespace jerárquico bajo el prefijo `omnioil/` para garantizar aislamiento y escalabilidad:
- `omnioil/telemetry/{project_id}`: Datos SCADA en tiempo real.
- `omnioil/health/{project_id}`: Latidos (Heartbeats) y estado de salud (CPU, RAM, Versión).
- `omnioil/logs/{project_id}`: Logs centralizados de los agentes de borde.
### 2.2 Nuevo Microservicio: Telemetry Ingestor
- **Propósito**: Actúa como puente (bridge) entre el Broker MQTT (Mosquitto) y la base de datos (TimescaleDB).
- **Tecnología**: Escrito en Rust para máxima eficiencia y baja latencia.
- **Funcionalidad**: Suscripción global a `omnioil/#`, persistencia de datos en tablas particionadas y actualización de estado en vivo.
### 2.3 Hardening de Base de Datos
- Se añadieron las tablas `agent_logs` y se expandió `edge_projects` con la columna `last_health_report` (JSONB).
- Se unificó la tabla `telemetry_raw` para permitir ingesta simplificada a nivel de proyecto.
## 3. Mejoras en la Interfaz de Usuario (Frontend)
### 3.1 Monitoreo en Tiempo Real (Healthcheck)
Se integró un panel de estado en la página de detalles del proyecto:
- **Estado de Conexión**: Indicador visual dinámico (Online/Offline).
- **Diagnóstico Rápido**: Visualización de Hostname, Tiempo de Uptime y Versión del Agente sin salir del Dashboard.
- **Último Reporte**: Timestamp exacto del último mensaje recibido para garantizar la integridad de los datos visible al usuario.
## 4. Seguridad Industrial
- **ACLs Granulares**: Configuración de permisos a nivel de base de datos para restringir a cada agente únicamente a su propio `project_id`.
- **Admin Bypass**: Verificación de `superquery` en Mosquitto para acceso total de administradores del sistema.
- **Eliminación de Exposición HTTP**: El agente de borde ya no envía datos por API REST, eliminando puntos de ataque externos.
## 5. Pruebas y Validación
- Se desarrolló un script de verificación integral `tests/test_mqtt_ingestion.py`.
- Se resolvieron inconsistencias de checksum en las migraciones de `sqlx`.
- Validación exitosa del flujo completo: **Agente -> MQTT (WSS/443) -> Ingestor -> TimescaleDB -> Frontend Admin**.
---
*OmniOil - Secured Industrial Connectivity Framework*

75
docs/SECURITY_AUDIT.md Normal file
View File

@@ -0,0 +1,75 @@
# 🛠️ Auditoría de Seguridad — Mejoras Críticas (Abril 2026)
Se ha realizado una reconstrucción completa de la capa de autenticación para garantizar un despliegue de grado industrial.
## 🔐 1. Autenticación MQTT Dinámica (Go-Auth)
- **Problem**: Se usaban archivos estáticos y hashes manuales que podían filtrarse en repositorios.
- **Solution**: Migración total a Postgres.
- **Algorithm**: Se ha estandarizado el uso de **Bcrypt (cost 10)** mediante la extensión `pgcrypto` nativa en Postgres. 🛡️
- **Implementation**: El sistema ahora hashea las claves de los agentes al vuelo en la base de datos, eliminando la necesidad de almacenar hashes pre-calculados o secretos en texto plano en el servidor.
### Configuración de Conectividad (Inyectada en Agentes)
El sistema ahora utiliza un enfoque de "Zero-Code Changes" para cambiar endpoints. Asegúrese de configurar estas variables en el servidor:
| Variable | Propósito | Ejemplo |
|----------|-----------|---------|
| `BACKEND_API_URL` | Endpoint para reporte de logs/health extra | `https://api.omnioil.app` |
| `MQTT_PUBLIC_HOST` | Host público para conexión WSS | `mqtt.omnioil.app` |
### Notas de Red Industrial
- **Puerto 443**: El agente intentará conectar siempre por el puerto 443 usando el protocolo WSS. Asegúrese de que su proxy (Traefik/Nginx) mapee este puerto correctamente al contenedor de Mosquitto.
- **Certificados**: El agente utiliza `rustls` por defecto para validar la cadena de confianza del servidor.
### v2.0 - Dinamismo y Endurecimiento Industrial (Sesión Actual)
- **Autenticación Nativa**: Migración de hashes estáticos a Bcrypt dinámico vía `pgcrypto` en TimescaleDB.
- **Canal Único (WSS)**: Implementación de WebSockets sobre SSL (puerto 443) para toda la comunicación MQTT. Esto permite atravesar firewalls industriales sin configuraciones complejas.
- **Micro-segmentación Activa**: ACLs dinámicas que restringen a los agentes a sus propios tópicos de telemetría y comandos.
- **Eliminación de Out-of-Band**: Se clausuraron los canales HTTP para logs y health; ahora toda la metadata del agente fluye por el túnel seguro de MQTT.
## 🏢 2. Multi-Tenancy & Aislamiento (Client Separation)
- **UUID Identity**: Los agentes de borde ya no usan nombres genéricos. Se utiliza el `Project UUID` para identificarlos de forma unívoca.
- **Topic Scoping (ACL)**: La micro-segmentación ya no es opcional. El sistema de ACLs restringe a cada agente a su canal `telemetry/{project_id}/#`. 🔒
- **Credenciales Dinámicas**: El orquestador genera una clave aleatoria de 36-40 caracteres por cada instalador MSI que produce.
## 📦 3. Seguridad en el MSI (Agent Injection)
- Las claves generadas dinámicamente se inyectan en el binario del **Edge Agent** mediante el orquestador (`Project Isolation`).
- El agente nunca conoce el usuario administrador (`omnioil_admin`), solo su propia identidad restringida. 🕵️‍♂️
# Auditoría de Seguridad y Análisis de Riesgos - OmniOil
Este documento expone los hallazgos tras la evaluación arquitectónica de seguridad del ecosistema OmniOil, detallando los puntos fuertes estructurados en su diseño inicial y las vulnerabilidades mitigables de cara a un despliegue en grado de producción industrial masiva.
## 🟢 Fortalezas Arquitectónicas Implementadas (Aciertos)
1. **Blindaje de Almacenamiento (Proxy Inverso a MinIO):**
El clúster de almacenamiento en bloque (MinIO / S3) no expone acceso público ni emite credenciales pre-firmadas (`Pre-Signed URLs`) al exterior. Todas las descargas de artefactos atraviesan la API segura (Rust Axum), operando como proxy y ocultando credenciales, topología y endpoints de cara al público.
2. **Criptografía Robusta en Backend:**
El backend se asegura implementando tokens web (JWT) firmados sin persistencia en sesiones inseguras. La gestión de encriptado, incluyendo las operaciones de Base de Datos y contraseñas (con implementaciones subyacentes blindadas frente a desbordamientos y colisiones mediante librerías como `aws_lc_rs`) está sólidamente estructurada en el servidor.
3. **Distribución del Config (Overlay Injection):**
A diferencia de muchos proyectos IoT convencionales que depositan en claro un `.json` o `.env` vulnerable, el token per-proyecto está inyectado **estructuralmente dentro del ejecutable** (`edge-agent`), blindándolo contra modificaciones no intencionadas en el sistema de archivos del operador.
4. **Micro-aislamiento en Red Docker (Air-Gap):**
La configuración del `docker-compose.yml` sigue un férreo esquema de Zero-Trust a nivel perimetral. Bases de Datos como TimescaleDB, clústeres Redisson o el MinIO están expuestos en una `docker_network` hermética y sin balanceo de mapeos `ports: "X:X"`, permitiendo su consulta unilateral únicamente por el servidor y el orquestador principal.
---
## 🔴 Vectores Críticos y Análisis de Deuda para Fase 2
### A. Cifrado IoT: Tráfico de Telemetría (MQTT en Plano crudo)
* **Severidad:** Crítico 🔥
* **Conflicto:** Por defecto, el servidor subyacente MQTT (Mosquitto) funciona sobre TCP el puerto HTTP plano estándar `1883`. Un Man-in-the-Middle o sniffer de red local podría monitorear u alterar las configuraciones SCADA u obligar peticiones hostiles de Docker.
* **Solución Próxima:** Deshabilitar el oyente `1883` público. Exigir **WSS** (WebSockets Secure) mapeando Traefik al puerto `443/8883` imponiendo los certificados TLS para evitar desestabilización IoT.
### B. Segmentación y Control de Acceso Lateral del Broker (ACLs)
* **Severidad:** Alto ⚠️
* **Conflicto:** Todos los Edge Agents entran y salen al Broker sin aislamiento formal estricto por clústeres. La infiltración o robo estático de la clave de un Pozo-A permitiría en teoría invadir el topic subscrito y leer/inyectar al Pozo-B alterando las reglas de Node-RED remotamente.
* **Solución Próxima:** Restringir el demonio Mosquitto inyectando de cara un **ACL** (Access Control List) obligando a que toda conexión individual reciba una lista blanca pre-concedida al Topic `omnioil/.../<UUID_exclusivo>` prohibiendo la promiscuidad generalizada a `#` (root context).
### C. Ingeniería Inversa sobre Overlay Binario
* **Severidad:** Moderado 🟡
* **Conflicto:** Utilizar un Overlay Json adherido directamente al extremo del compilador abre el vector donde un desarrollador armado con un editor Hexadecimal de la industria, aísle, seque y decodifique las claves del Pozo contenidas en el ejecutable plano.
* **Solución Próxima:** El constructor asincrónico (Build-Orchestrator) deberá transformar con `AES-256` la clave JSON en crudo usando una llave embonada al código generador en Rust previo a inyectar las capas de código en ensamblador de Linux y Windows, dejando el rastro decodificable únicamente expuesto en las celdas RAM al instante de bootear el proceso como Windows Service.
### D. Ataques de Fuerza Bruta y Limitación Web
* **Severidad:** Moderado 🛡️
* **Conflicto:** Ausencia de salvaguardas limitadoras de estrangulación perimetral (Rate Limiting). Endpoints neurálgicos como autenticación permiten colisiones y testeo masivo robótico para vulnerar administradores principales.
* **Solución Próxima:** Implantar barreras arquitectónicas de Capa 7, ya sea desde el Gateway API Traefik o insertando Middleware Axum Rate Limit anclado a **Redis** por IPs limitadas a ráfagas.

Binary file not shown.

After

Width:  |  Height:  |  Size: 901 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 518 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 550 KiB

View File

@@ -0,0 +1,114 @@
# 🏗️ Arquitectura de Datos Enterprise - OmniOil
Este documento detalla el ecosistema de datos de **OmniOil**, diseñado para operaciones de alta disponibilidad en el sector Oil & Gas.
## 📈 Diagrama Entidad-Relación Detallado
El siguiente diagrama representa la complejidad técnica del sistema, incluyendo tipos de datos y restricciones clave.
```mermaid
erDiagram
ROLES {
int id PK
varchar name
jsonb permissions
}
USERS {
uuid id PK
varchar email
varchar password_hash
int role_id FK
boolean is_active
}
EDGE_PROJECTS {
uuid id PK
varchar name
varchar client
varchar installer_status
text installer_url
jsonb metadata
}
EDGE_ASSETS {
uuid id PK
uuid project_id FK
varchar code
varchar asset_type
}
EDGE_DEVICES {
uuid id PK
uuid asset_id FK
varchar protocol
varchar host
int port
}
EDGE_VARIABLES {
uuid id PK
uuid device_id FK
varchar alias
varchar data_type
int polling_ms
}
TELEMETRY_RAW {
timestamptz time PK
uuid asset_id PK
jsonb data
}
OPERATION_MOVEMENTS {
uuid id PK
uuid asset_id FK
varchar movement_type
decimal volumen_neto
timestamptz start_time
timestamptz end_time
}
MQTT_USERS {
varchar username PK
varchar password_hash
boolean is_admin
}
MQTT_ACLS {
int id PK
varchar username FK
varchar topic
int rw
}
ROLES ||--o{ USERS : "define"
USERS ||--o{ EDGE_PROJECTS : "accede a"
EDGE_PROJECTS ||--o{ EDGE_ASSETS : "contiene"
EDGE_ASSETS ||--o{ EDGE_DEVICES : "conecta"
EDGE_DEVICES ||--o{ EDGE_VARIABLES : "mapea"
EDGE_ASSETS ||--o{ TELEMETRY_RAW : "registra historial"
EDGE_ASSETS ||--o{ OPERATION_MOVEMENTS : "audita volumen"
MQTT_USERS ||--o{ MQTT_ACLS : "autoriza"
```
## 🖼️ Visualización de Arquitectura de Alta Escala
![Arquitectura Técnica OmniOil](./assets/omnioil_high_res_schema.png)
---
## 🔬 Análisis de Capas de Datos
### 🏢 Capa de Estructura Organizativa
Gestiona la jerarquía desde el cliente comercial hasta la variable física capturada en campo. Permite la multi-tenencia mediante el filtrado por `project_id`.
### ⚡ Capa de Tiempo Real (TimescaleDB)
La tabla `telemetry_raw` es una **Hypertable** optimizada para millones de registros.
- **Compresión**: Los datos se almacenan en formato JSONB para flexibilidad total de protocolos.
- **Rendimiento**: Consultas instantáneas de promedios, máximos y mínimos mediante funciones nativas de Timescale.
### 🛡️ Capa de Ciberseguridad Industrial
Control total del flujo MQTT mediante el plugin **Go-Auth**.
- **RBAC**: Los administradores tienen acceso total (`#`), los agentes solo a sus tópicos específicos.
- **Persistencia**: Las credenciales se inyectan dinámicamente desde el entorno para evitar fugas de seguridad.
### 📊 Capa de Cumplimiento (ANH)
La tabla `operation_movements` está diseñada para el reporting normativo:
- Clasificación de tipos de movimiento (Venta, Quema, Transferencia).
- Cálculos de volumen neto con precisión decimal de 4 dígitos.
---
> [!IMPORTANT]
> Esta arquitectura soporta escalado horizontal. Los microservicios como `events-relay` actúan como puentes entre la telemetría viva (MQTT) y la persistencia (Postgres).

View File

@@ -0,0 +1,65 @@
# Propuesta Técnica: Telemetría en Tiempo Real y Cumplimiento Normativo ANH
## 1. Objetivo
Lograr una visualización de datos de dispositivos en tiempo real (estilo SCADA/Grafana) con latencia mínima, garantizando al mismo tiempo que el almacenamiento histórico cumpla estrictamente con la norma **ANH 0651** (registro cada 10 minutos) sin saturar la base de datos.
---
## 2. Arquitectura de Doble Camino (Dual-Path)
### A. Camino Caliente (Hot Path) - Visualización Live
Este camino está diseñado para que el usuario "vea" qué está pasando ahora mismo sin necesidad de consultar la base de datos.
* **Protocolo**: MQTT sobre WebSockets (WSS) en el puerto 443.
* **Flujo**:
1. El **Edge Agent** publica datos cada 1-5 segundos (o por cambio significativo).
2. El **Broker (Mosquitto)** recibe el dato.
3. El **Frontend (React)**, conectado vía WSS, recibe la actualización instantáneamente.
* **Ventaja**: Latencia < 200ms. Carga de base de datos: **Cero**.
### B. Camino Frío (Cold Path) - Cumplimiento ANH 0651
Este camino se encarga de la persistencia legal y auditoría.
* **Frecuencia**: Cada 10 minutos (según norma).
* **Flujo**:
1. El **Ingestor** recibe todos los mensajes MQTT del Camino Caliente.
2. El **Ingestor** mantiene el "Último Valor Conocido" (Last Value Buffer) en memoria.
3. Cada **10 minutos**, un cron-job o timer en el Ingestor toma ese valor y lo inserta en **TimescaleDB**.
* **Ventaja**: Almacenamiento optimizado. Cumplimiento legal garantizado.
---
## 3. Estrategias de Optimización
### Deadbanding (Reporte por Excepción)
Para no saturar la red con datos que no cambian (ej: una temperatura estable), el **Edge Agent** implementará un umbral de cambio:
- Si el valor de la variable cambia menos de un **0.5%**, el agente no publica.
- Si el cambio es mayor al umbral, se publica inmediatamente para actualizar la gráfica.
### Retención de Datos (TTL)
TimescaleDB permite configurar políticas de retención automáticas:
- **Datos de Alta Resolución**: Se pueden guardar por 24 horas para permitir al usuario ver "qué pasó hace una hora" con detalle.
- **Datos de Norma (10 min)**: Se guardan de forma permanente para el historial anual.
---
## 4. Integración Edge Agent & Node-RED (Persistence Layer)
La inteligencia de reporte se delega a Node-RED, mientras que la seguridad del dato se delega al Agente de Borde.
### A. Lógica en Node-RED (Origen)
* **Tópico `/telemetry`**: Node-RED envía datos por cambio (Deadbanding) para alimentar el Hot Path.
* **Tópico `/compliance`**: Node-RED envía un "snapshot" consolidado exactamente cada 10 minutos (ANH 0651) para el Cold Path.
### B. Gestión en el Agente de Borde (Filtro)
El Agente actúa como un **Gateway Inteligente** entre Node-RED y la Nube:
1. **Tráfico `/telemetry`**: Se reenvía a la nube con prioridad de tiempo real. **No se persiste en disco local** (evitando desgaste innecesario del SSD/HDD y optimizando espacio).
2. **Tráfico `/compliance`**: Se cifra y se guarda en la base de datos local (**Store & Forward**) antes de intentarse enviar. Esto garantiza que el registro normativo nunca se pierda, incluso ante caídas de internet prolongadas.
---
## 5. Beneficios del Enfoque
1. **Escalabilidad**: Soporta cientos de agentes enviando datos sin degradar el rendimiento del dashboard.
2. **Cuidado del Hardware**: Protege la vida útil del almacenamiento en el PC de borde al evitar escrituras constantes de datos en tiempo real.
3. **Seguridad Normativa**: Garantiza al 100% que los datos de la ANH siempre tienen un respaldo encriptado local mediante Store & Forward.
4. **Experiencia de Usuario**: Sensación de control total "en vivo" sobre los activos industriales con latencia SCADA.

View File

@@ -0,0 +1,205 @@
-- =============================================================================
-- OMNIOIL: FULL DATABASE SCHEMA (Consolidated)
-- FECHA: 2026-04-10
-- DESCRIPCIÓN: Esquema maestro que integra Mantenimiento de Roles, Usuarios,
-- Gestion de Agentes, Activos, Telemetría (TimescaleDB) y Seguridad MQTT.
-- =============================================================================
-- EXTENSIONES
CREATE EXTENSION IF NOT EXISTS "uuid-ossp";
CREATE EXTENSION IF NOT EXISTS "pgcrypto";
-- =============================================================================
-- MÓDULO 1: SEGURIDAD Y ACCESO (DASHBOARD)
-- =============================================================================
CREATE TABLE IF NOT EXISTS roles (
id SERIAL PRIMARY KEY,
name VARCHAR(50) UNIQUE NOT NULL,
permissions JSONB NOT NULL,
created_at TIMESTAMPTZ DEFAULT NOW()
);
-- Semilla de roles
INSERT INTO roles (name, permissions) VALUES
('admin', '{"SYNC_WRITE": true, "DATA_READ": true, "CAMPO_READ": true, "USERS_ADMIN": true}'::jsonb),
('supervisor', '{"SYNC_WRITE": true, "DATA_READ": true, "CAMPO_READ": true, "USERS_ADMIN": false}'::jsonb),
('operador', '{"SYNC_WRITE": true, "DATA_READ": true, "CAMPO_READ": false, "USERS_ADMIN": false}'::jsonb),
('anh_reader', '{"DATA_READ": true, "CAMPO_READ": true}'::jsonb)
ON CONFLICT (name) DO NOTHING;
CREATE TABLE IF NOT EXISTS users (
id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
email VARCHAR(100) UNIQUE NOT NULL,
full_name VARCHAR(200),
password_hash VARCHAR(255) NOT NULL,
role_id INT NOT NULL REFERENCES roles(id),
is_active BOOLEAN DEFAULT true,
last_login TIMESTAMPTZ,
created_at TIMESTAMPTZ DEFAULT NOW(),
updated_at TIMESTAMPTZ DEFAULT NOW()
);
-- Soporte para sesiones seguras (JWT Refresh)
CREATE TABLE IF NOT EXISTS refresh_tokens (
id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE,
token_hash TEXT NOT NULL UNIQUE,
expires_at TIMESTAMPTZ NOT NULL,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
revoked_at TIMESTAMPTZ
);
CREATE INDEX IF NOT EXISTS idx_refresh_tokens_user_id ON refresh_tokens(user_id);
-- =============================================================================
-- MÓDULO 2: JERARQUÍA OPERATIVA (EDGE)
-- =============================================================================
CREATE TABLE IF NOT EXISTS edge_projects (
id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
name VARCHAR(100) NOT NULL UNIQUE,
client VARCHAR(150) NOT NULL,
description TEXT,
metadata JSONB DEFAULT '{}'::jsonb,
is_active BOOLEAN DEFAULT true,
installer_status VARCHAR(50) DEFAULT 'NOT_STARTED',
installer_url TEXT,
last_health_report JSONB DEFAULT '{}'::jsonb,
created_at TIMESTAMPTZ DEFAULT NOW(),
updated_at TIMESTAMPTZ DEFAULT NOW(),
CONSTRAINT check_installer_status CHECK (installer_status IN ('NOT_STARTED', 'QUEUED', 'BUILDING', 'READY', 'COMPLETED', 'FAILED'))
);
CREATE TABLE IF NOT EXISTS edge_assets (
id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
project_id UUID NOT NULL REFERENCES edge_projects(id) ON DELETE CASCADE,
code VARCHAR(50) UNIQUE NOT NULL,
name VARCHAR(100) NOT NULL,
asset_type VARCHAR(20) NOT NULL
CHECK (asset_type IN ('POZO', 'TANQUE', 'MEDIDOR_GAS', 'AGUA', 'OTRO')),
is_active BOOLEAN DEFAULT true,
metadata JSONB DEFAULT '{}'::jsonb,
created_at TIMESTAMPTZ DEFAULT NOW(),
updated_at TIMESTAMPTZ DEFAULT NOW()
);
CREATE TABLE IF NOT EXISTS edge_devices (
id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
asset_id UUID NOT NULL REFERENCES edge_assets(id) ON DELETE CASCADE,
name VARCHAR(100) NOT NULL,
protocol VARCHAR(30) NOT NULL
CHECK (protocol IN ('MODBUS_TCP', 'MODBUS_RTU', 'OPC_UA', 'MQTT', 'ETHERNET_IP', 'PROFINET')),
host VARCHAR(255) NOT NULL,
port INTEGER NOT NULL,
protocol_config JSONB DEFAULT '{}'::jsonb,
is_enabled BOOLEAN DEFAULT true,
created_at TIMESTAMPTZ DEFAULT NOW(),
updated_at TIMESTAMPTZ DEFAULT NOW()
);
CREATE TABLE IF NOT EXISTS edge_variables (
id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
device_id UUID NOT NULL REFERENCES edge_devices(id) ON DELETE CASCADE,
address VARCHAR(255) NOT NULL,
data_type VARCHAR(30) NOT NULL
CHECK (data_type IN ('bool', 'int16', 'uint16', 'int32', 'uint32', 'float32', 'float64', 'string')),
alias VARCHAR(100) NOT NULL,
unit VARCHAR(30),
polling_interval_ms INTEGER DEFAULT 5000,
byte_order VARCHAR(10) DEFAULT 'BE'
CHECK (byte_order IN ('BE', 'LE', 'BE_SWAP', 'LE_SWAP')),
is_enabled BOOLEAN DEFAULT true,
created_at TIMESTAMPTZ DEFAULT NOW(),
updated_at TIMESTAMPTZ DEFAULT NOW()
);
-- =============================================================================
-- MÓDULO 3: TELEMETRÍA (TimescaleDB)
-- =============================================================================
CREATE TABLE IF NOT EXISTS telemetry_raw (
time TIMESTAMPTZ NOT NULL,
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,
PRIMARY KEY (time, project_id, asset_id)
);
-- Convertir a Hipertabla (TimescaleDB Optimize)
SELECT create_hypertable('telemetry_raw', 'time', if_not_exists => TRUE);
CREATE TABLE IF NOT EXISTS agent_logs (
id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
project_id UUID NOT NULL REFERENCES edge_projects(id) ON DELETE CASCADE,
agent_hostname VARCHAR(255),
level VARCHAR(20),
category VARCHAR(50),
message TEXT,
context JSONB,
timestamp TIMESTAMPTZ DEFAULT NOW()
);
-- =============================================================================
-- MÓDULO 4: MQTT AUTH & ACL (Mosquitto Integration)
-- =============================================================================
CREATE TABLE IF NOT EXISTS mqtt_users (
username VARCHAR(100) PRIMARY KEY,
password_hash VARCHAR(255) NOT NULL,
is_admin BOOLEAN DEFAULT false,
created_at TIMESTAMPTZ DEFAULT NOW()
);
CREATE TABLE IF NOT EXISTS mqtt_acls (
id SERIAL PRIMARY KEY,
username VARCHAR(100) NOT NULL REFERENCES mqtt_users(username) ON DELETE CASCADE,
topic VARCHAR(500) NOT NULL,
rw INT DEFAULT 1, -- 1 = read, 2 = write, 3 = readwrite
created_at TIMESTAMPTZ DEFAULT NOW(),
UNIQUE(username, topic)
);
-- =============================================================================
-- MÓDULO 5: INFRAESTRUCTURA DE DESPLIEGUE Y EVENTOS
-- =============================================================================
CREATE TABLE IF NOT EXISTS edge_deployments (
id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
project_id UUID NOT NULL REFERENCES edge_projects(id) ON DELETE CASCADE,
image_tag VARCHAR(500) NOT NULL,
image_hash VARCHAR(100),
status VARCHAR(30) NOT NULL DEFAULT 'BUILDING',
previous_image_tag VARCHAR(500),
previous_image_hash VARCHAR(100),
config_snapshot JSONB,
build_metadata JSONB NOT NULL DEFAULT '{}'::jsonb,
error_message TEXT,
deployed_at TIMESTAMPTZ,
created_at TIMESTAMPTZ DEFAULT NOW()
);
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: MOVIMIENTOS OPERATIVOS (ANH 0651)
-- =============================================================================
CREATE TABLE IF NOT EXISTS operation_movements (
id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
asset_id UUID NOT NULL REFERENCES edge_assets(id) ON DELETE CASCADE,
movement_type VARCHAR(30) NOT NULL
CHECK (movement_type IN ('VENTA', 'RECIBO', 'TRANSFERENCIA', 'QUEMA', 'CONSUMO')),
start_time TIMESTAMPTZ NOT NULL,
end_time TIMESTAMPTZ NOT NULL,
volumen_neto DECIMAL(18, 4),
details JSONB DEFAULT '{}'::jsonb,
created_at TIMESTAMPTZ DEFAULT NOW(),
CONSTRAINT check_movement_times CHECK (end_time >= start_time)
);

34
docs/repair.md Normal file
View File

@@ -0,0 +1,34 @@
# Repair Plan — Registro de Usuarios (Resumen)
## Contexto
- Registro fallaba en producción desde la PWA y vía API pública.
- Repositorio ya ajustado: reactivado endpoint de registro, mapeado `mobile.omnioil.app` y CORS mejorado (lista múltiple).
## Problemas Detectados
- 502 en PWA: Nginx de la PWA intentaba proxy interno a `backend-api` (no resolvible en Dokploy).
- 500 en API pública: gateway externo exige “key/túnel” para rutas de auth (bloquea `/api/auth/register`).
- CORS desalineado: backend devolvía origen por defecto (localhost) en prod.
## Cambios Requeridos (Producción)
- PWA: construir con `VITE_API_BASE=https://api.omnioil.app` (consumo por dominio público, no proxy interno).
- Backend API: `ALLOWED_ORIGINS=https://mobile.omnioil.app,https://app.omnioil.app` y redeploy.
- Gateway `api.omnioil.app`: permitir sin secreto/mTLS estas rutas públicas:
- `/api/auth/register`, `/api/auth/login`, `/api/auth/refresh`
- Mantener protección para el resto de rutas.
- Desplegar: rebuild y restart de `backend-api`, `frontend-pwa` y `nginx` (si aplica).
## Validación
- PWA Registro: `POST https://mobile.omnioil.app/api/auth/register` → 201 `{id,email}`; repetido → 409.
- PWA Login: `POST https://mobile.omnioil.app/api/auth/login` → 200 con tokens.
- API directa: `POST https://api.omnioil.app/api/auth/register` → 201/409 (si gateway ajustado).
- Salud: `GET https://api.omnioil.app/` devuelve banner de API.
## Endurecimiento (Opcional)
- Flag `ENABLE_PUBLIC_REGISTER=false` en prod (registro solo por invitación).
- CORS estricto (solo domininios productivos).
- Mantener gateway con protección para rutas no públicas.
## Cambios ya aplicados en repo
- Rehabilitado `POST /api/auth/register` en backend.
- `mobile.omnioil.app` mapeado a PWA en gateway interno.
- Backend soporta `ALLOWED_ORIGINS` como lista separada por comas.

13
generate_hash.py Normal file
View File

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

@@ -0,0 +1,15 @@
# Usamos la imagen base con el plugin ya compilado
FROM iegomez/mosquitto-go-auth:3.0.0-mosquitto_2.0.18
# Copiamos nuestra configuración y el script de entrada dentro de la imagen
COPY mosquitto.conf.template /etc/mosquitto/mosquitto.conf.template
COPY entrypoint.sh /etc/mosquitto/entrypoint.sh
# Aseguramos permisos de ejecución
RUN chmod +x /etc/mosquitto/entrypoint.sh
# Exponemos los puertos
EXPOSE 1883 9883
# El entrypoint se encarga de inyectar las variables
ENTRYPOINT ["/usr/bin/sh", "/etc/mosquitto/entrypoint.sh"]

View File

@@ -0,0 +1,16 @@
#!/bin/sh
set -e
# Generar el archivo final a partir de la plantilla
TEMPLATE="/etc/mosquitto/mosquitto.conf.template"
CONFIG="/etc/mosquitto/mosquitto.conf"
echo "🔧 [OmniOil] Generating configuration from environment variables..."
# Usamos | como delimitador para soportar caracteres especiales en contraseñas
sed "s|POSTGRES_HOST_PLACEHOLDER|$POSTGRES_HOST|g; \
s|POSTGRES_USER_PLACEHOLDER|$POSTGRES_USER|g; \
s|POSTGRES_PASSWORD_PLACEHOLDER|$POSTGRES_PASSWORD|g; \
s|POSTGRES_DB_PLACEHOLDER|$POSTGRES_DB|g" $TEMPLATE > $CONFIG
echo "🚀 Starting Mosquitto..."
exec /usr/sbin/mosquitto -c $CONFIG

View File

@@ -1,24 +0,0 @@
# =============================================================================
# Eclipse Mosquitto MQTT Broker - OmniOil Configuration
# =============================================================================
# Listener
listener 1883
protocol mqtt
# WebSocket listener (for frontend)
listener 9883
protocol websockets
# Security
allow_anonymous true
# password_file /mosquitto/config/passwd
# Persistence
persistence true
persistence_location /mosquitto/data/
# Logging
log_dest stdout
log_type all
connection_messages true

View File

@@ -0,0 +1,44 @@
# =============================================================================
# Eclipse Mosquitto MQTT Broker - OmniOil Production Configuration
# =============================================================================
# Listeners
listener 1883
protocol mqtt
# WebSocket listener (for agents and frontend)
listener 9883
protocol websockets
# Security (Go-Auth Plugin)
auth_plugin /mosquitto/go-auth.so
auth_opt_backends postgres
allow_anonymous false
# PostgreSQL Connection Settings
# Replaced by entrypoint.sh at runtime
auth_opt_pg_host POSTGRES_HOST_PLACEHOLDER
auth_opt_pg_port 5432
auth_opt_pg_user POSTGRES_USER_PLACEHOLDER
auth_opt_pg_password POSTGRES_PASSWORD_PLACEHOLDER
auth_opt_pg_dbname POSTGRES_DB_PLACEHOLDER
# Auth Queries
# Verify user and password hash
auth_opt_pg_userquery SELECT password_hash FROM mqtt_users WHERE username = $1
# Superuser Query (Grant total access to admins without checking ACL table)
auth_opt_pg_superquery SELECT count(*) FROM mqtt_users WHERE username = $1 AND is_admin = true
# Standard ACL Query (For regular non-admin users/agents)
auth_opt_pg_aclquery SELECT topic FROM mqtt_acls WHERE username = $1 AND rw >= $2
# General Plugin Options
auth_opt_pg_sslmode disable
auth_opt_check_interval 60
auth_opt_hasher bcrypt
auth_opt_log_level info
# Persistence
persistence true
persistence_location /mosquitto/data/

View File

@@ -10,12 +10,32 @@ server {
} }
} }
server {
listen 80;
server_name app.omnioil.app;
location / {
proxy_pass http://frontend-admin:80;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
}
}
server { server {
listen 80; listen 80;
server_name admin.omnioil.app; server_name admin.omnioil.app;
location / { location / {
proxy_pass http://frontend:80; return 308 https://app.omnioil.app$request_uri;
}
}
server {
listen 80;
server_name mobile.omnioil.app;
location / {
proxy_pass http://frontend-pwa:80;
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;
} }
@@ -26,9 +46,7 @@ server {
server_name campo.omnioil.app; server_name campo.omnioil.app;
location / { location / {
proxy_pass http://frontend-pwa:80; return 308 https://mobile.omnioil.app$request_uri;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
} }
} }

View File

@@ -1,40 +0,0 @@
-- =============================================================================
-- OMNIOIL — MÓDULO 4: DOMAIN EVENTS & OUTBOX PATTERN
-- Garantía de entrega asíncrona y resiliencia
-- =============================================================================
-- 1. Tabla de Outbox (Pendientes de procesar)
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'
CHECK (status IN ('PENDING', 'PROCESSED', 'FAILED')),
attempts INT DEFAULT 0,
last_error TEXT,
created_at TIMESTAMPTZ DEFAULT NOW(),
processed_at TIMESTAMPTZ
);
-- Índices para optimizar el polling del Relay
CREATE INDEX IF NOT EXISTS idx_outbox_pending
ON domain_outbox(status, created_at)
WHERE status = 'PENDING';
-- 2. Histórico de Eventos (Auditoría/Traceability ANH)
-- Moveremos aquí los eventos procesados para mantener la tabla outbox ligera
CREATE TABLE IF NOT EXISTS domain_events_history (
id UUID PRIMARY KEY,
event_type VARCHAR(100) NOT NULL,
payload JSONB NOT NULL,
created_at TIMESTAMPTZ NOT NULL,
processed_at TIMESTAMPTZ NOT NULL,
metadata JSONB DEFAULT '{}'::jsonb
);
CREATE INDEX IF NOT EXISTS idx_events_history_type_time
ON domain_events_history(event_type, processed_at DESC);
-- 3. Comentarios de documentación técnica
COMMENT ON TABLE domain_outbox IS 'Reserva de eventos de dominio para asegurar entrega atómica (Outbox Pattern).';
COMMENT ON TABLE domain_events_history IS 'Rastro inmutable de eventos procesados para auditoría de fiscalización.';

View File

@@ -1,128 +0,0 @@
-- =============================================================================
-- OMNIOIL — ESQUEMA MAESTRO V4.0 — PARTE 2: EDGE + AUDITORÍA
-- Edge Agent, Reportes ANH y Triggers
-- Ejecutar después de init.sql (02_edge.sql)
-- =============================================================================
-- ╔═══════════════════════════════════════════════════════════════════════════╗
-- ║ MÓDULO 4: EDGE AGENT — DESPLIEGUE Y MONITOREO ║
-- ╚═══════════════════════════════════════════════════════════════════════════╝
-- Historial de despliegues Node-RED
CREATE TABLE IF NOT EXISTS edge_deployments (
id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
project_id UUID NOT NULL REFERENCES edge_projects(id) ON DELETE CASCADE,
image_tag VARCHAR(500) NOT NULL,
image_hash VARCHAR(100),
previous_image_tag VARCHAR(500),
previous_image_hash VARCHAR(100),
status VARCHAR(30) NOT NULL DEFAULT 'BUILDING'
CHECK (status IN ('BUILDING', 'BUILT', 'PUSHING', 'PUSHED', 'DEPLOYING', 'DEPLOYED', 'FAILED', 'ROLLED_BACK')),
config_snapshot JSONB,
build_metadata JSONB DEFAULT '{}'::jsonb,
error_message TEXT,
deployed_at TIMESTAMPTZ,
created_at TIMESTAMPTZ DEFAULT NOW()
);
CREATE INDEX IF NOT EXISTS idx_edge_deployments_project ON edge_deployments(project_id);
CREATE INDEX IF NOT EXISTS idx_edge_deployments_status ON edge_deployments(status);
-- Logs centralizados del Edge Agent
CREATE TABLE IF NOT EXISTS edge_agent_logs (
id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
project_id UUID NOT NULL REFERENCES edge_projects(id) ON DELETE CASCADE,
level VARCHAR(10) NOT NULL
CHECK (level IN ('DEBUG', 'INFO', 'WARN', 'ERROR', 'FATAL')),
category VARCHAR(50) NOT NULL,
message TEXT NOT NULL,
context JSONB DEFAULT '{}'::jsonb,
agent_hostname VARCHAR(255),
agent_ip INET,
created_at TIMESTAMPTZ DEFAULT NOW()
);
CREATE INDEX IF NOT EXISTS idx_edge_agent_logs_project ON edge_agent_logs(project_id);
CREATE INDEX IF NOT EXISTS idx_edge_agent_logs_level ON edge_agent_logs(level);
CREATE INDEX IF NOT EXISTS idx_edge_agent_logs_created ON edge_agent_logs(created_at DESC);
-- ╔═══════════════════════════════════════════════════════════════════════════╗
-- ║ MÓDULO 5: AUDITORÍA Y REPORTES ANH ║
-- ╚═══════════════════════════════════════════════════════════════════════════╝
-- Auditoría forense
CREATE TABLE IF NOT EXISTS audit_logs (
id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
user_id UUID REFERENCES users(id),
action VARCHAR(50) NOT NULL,
table_name VARCHAR(50) NOT NULL,
record_id UUID NOT NULL,
old_data JSONB,
new_data JSONB,
change_reason TEXT,
ip_address INET,
created_at TIMESTAMPTZ DEFAULT NOW()
);
-- Reportes JSON ANH
CREATE TABLE IF NOT EXISTS reportes_json_anh (
id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
project_id UUID REFERENCES edge_projects(id),
usuario_id UUID REFERENCES users(id),
fecha_operacional DATE NOT NULL,
nombre_archivo VARCHAR(255),
estado_envio VARCHAR(30) DEFAULT 'PENDIENTE'
CHECK (estado_envio IN ('PENDIENTE', 'GENERADO', 'ENVIADO', 'ACEPTADO', 'RECHAZADO', 'ERROR')),
ruta_datalake TEXT,
hash_archivo VARCHAR(100),
created_at TIMESTAMPTZ DEFAULT NOW(),
updated_at TIMESTAMPTZ DEFAULT NOW()
);
-- ╔═══════════════════════════════════════════════════════════════════════════╗
-- ║ TRIGGERS PARA UPDATED_AT ║
-- ╚═══════════════════════════════════════════════════════════════════════════╝
CREATE OR REPLACE FUNCTION trigger_set_timestamp()
RETURNS TRIGGER AS $$
BEGIN
NEW.updated_at = NOW();
RETURN NEW;
END;
$$ LANGUAGE plpgsql;
-- Solo crear triggers si no existen (idempotente)
DO $$
BEGIN
IF NOT EXISTS (SELECT 1 FROM pg_trigger WHERE tgname = 'set_timestamp_users') THEN
CREATE TRIGGER set_timestamp_users BEFORE UPDATE ON users
FOR EACH ROW EXECUTE PROCEDURE trigger_set_timestamp();
END IF;
IF NOT EXISTS (SELECT 1 FROM pg_trigger WHERE tgname = 'set_timestamp_projects') THEN
CREATE TRIGGER set_timestamp_projects BEFORE UPDATE ON edge_projects
FOR EACH ROW EXECUTE PROCEDURE trigger_set_timestamp();
END IF;
IF NOT EXISTS (SELECT 1 FROM pg_trigger WHERE tgname = 'set_timestamp_assets') THEN
CREATE TRIGGER set_timestamp_assets BEFORE UPDATE ON edge_assets
FOR EACH ROW EXECUTE PROCEDURE trigger_set_timestamp();
END IF;
IF NOT EXISTS (SELECT 1 FROM pg_trigger WHERE tgname = 'set_timestamp_devices') THEN
CREATE TRIGGER set_timestamp_devices BEFORE UPDATE ON edge_devices
FOR EACH ROW EXECUTE PROCEDURE trigger_set_timestamp();
END IF;
IF NOT EXISTS (SELECT 1 FROM pg_trigger WHERE tgname = 'set_timestamp_variables') THEN
CREATE TRIGGER set_timestamp_variables BEFORE UPDATE ON edge_variables
FOR EACH ROW EXECUTE PROCEDURE trigger_set_timestamp();
END IF;
IF NOT EXISTS (SELECT 1 FROM pg_trigger WHERE tgname = 'set_timestamp_reportes') THEN
CREATE TRIGGER set_timestamp_reportes BEFORE UPDATE ON reportes_json_anh
FOR EACH ROW EXECUTE PROCEDURE trigger_set_timestamp();
END IF;
END $$;

View File

@@ -0,0 +1,56 @@
# OmniOil - Marco de Pruebas de Infraestructura (SCADA)
Este documento describe la suite de pruebas diseñada para validar la comunicación, seguridad y persistencia de datos entre los **Agentes de Borde** y el **Servidor Central**.
## 1. Misión de las Pruebas
Garantizar que el flujo de datos desde el sensor (simulado) hasta la base de datos central sea íntegro, seguro y autenticado.
## 2. Inventario de Pruebas
| Script | Módulo Probado | Descripción | Validación Esperada |
| :--- | :--- | :--- | :--- |
| `simulate_agent.py` | Edge Agent -> MQTT | Simula un agente de campo enviando telemetría viva. | El servidor MQTT debe aceptar la conexión y el backend debe loguear el mensaje. |
| `test_mqtt_auth.py` | Mosquitto Auth Plugin | Intenta conexiones con/sin credenciales. | Debe rechazar conexiones `Anonymous` y aceptar `omnioil_admin`. |
## 📋 Reporte de Resultados (Sesión Actual)
### 1. Validación de Seguridad (MQTT Auth)
**Script**: `test_mqtt_auth.py`
**Resultado**: ✅ PASADO
**Detalles**:
- Conexión con `omnioil_admin`: **ESTABLECIDA** (CONNACK 0, 0)
- Conexión Anónima: **DENEGADA** (CONNACK 0, 5) - *Seguridad OK*
### 2. Simulación de Flujo de Datos (Agente de Campo)
**Script**: `simulate_agent.py`
**Resultado**: ✅ PASADO
**Logs de salida**:
```text
✅ Simulación de Agente Conectada a Central.
🚀 Enviando telemetría POZO-01: {"timestamp": "2026-04-06T21:19:04", "asset_id": "POZO-01", "telemetry": {...}}
🚀 Enviando telemetría POZO-01: {"timestamp": "2026-04-06T21:19:09", "asset_id": "POZO-01", "telemetry": {...}}
⏹️ Simulación detenida por el usuario.
```
---
*Reporte generado automáticamente por Antigravity*
## 3. Guía de Ejecución
### Prerrequisitos
- Tener `python3` instalado localmente.
- Instalar cliente MQTT para Python: `pip install paho-mqtt`.
- La infraestructura de Docker debe estar corriendo (`docker compose up -d`).
### Ejecución de Pruebas
Ejecuta los scripts desde la carpeta raíz del proyecto para que puedan leer el puerto configurado:
```powershell
pip install paho-mqtt
python infrastructure/tests/test_mqtt_auth.py
python infrastructure/tests/simulate_agent.py
```
---
> [!IMPORTANT]
> Estas pruebas son esenciales después de cualquier cambio en la configuración de `mosquitto-go-auth` o en las tablas de ACLs de la base de datos.

View File

@@ -0,0 +1,61 @@
import paho.mqtt.client as mqtt
import os
import json
import time
from datetime import datetime
from dotenv import load_dotenv
# Cargar variables de entorno del archivo .env
load_dotenv(".env")
# Simular agente de campo
MQTT_HOST = "localhost" # Puerto expuesto localmente
MQTT_PORT = 1883
MQTT_USER = os.getenv("MQTT_USER")
MQTT_PASSWORD = os.getenv("MQTT_PASSWORD")
TOPIC = "telemetry/POZO-01"
def on_connect(client, userdata, flags, rc):
if rc == 0:
print("✅ Simulación de Agente Conectada a Central.")
else:
print("❌ Error de conexión al broker MQTT.")
def start_simulation():
client = mqtt.Client()
client.username_pw_set(MQTT_USER, MQTT_PASSWORD)
client.on_connect = on_connect
try:
client.connect(MQTT_HOST, MQTT_PORT, 60)
client.loop_start()
while True:
# Generar datos simulando un sensor
data = {
"timestamp": datetime.now().isoformat(),
"asset_id": "POZO-01",
"telemetry": {
"presion_psig": 120.5 + (0.5 * time.time() % 10),
"temperatura_c": 45.2 + (0.2 * time.time() % 5),
"flujo_blpd": 850.0 + (10 * time.time() % 20)
}
}
payload = json.dumps(data)
print("🚀 Enviando telemetría POZO-01: {}".format(payload))
client.publish(TOPIC, payload)
time.sleep(5) # Enviar cada 5 segundos
except KeyboardInterrupt:
print("\n⏹️ Simulación detenida por el usuario.")
except Exception as e:
print("❌ ERROR EN SIMULACIÓN: {}".format(e))
finally:
client.loop_stop()
client.disconnect()
if __name__ == "__main__":
start_simulation()

View File

@@ -0,0 +1,46 @@
import paho.mqtt.client as mqtt
import os
from dotenv import load_dotenv
# Cargar variables de entorno del archivo .env que está en la raíz
load_dotenv(".env")
MQTT_HOST = "localhost" # Puerto expuesto para pruebas externas es el 1883 en localhost
MQTT_PORT = 1883
MQTT_USER = os.getenv("MQTT_USER")
MQTT_PASSWORD = os.getenv("MQTT_PASSWORD")
def on_connect(client, userdata, flags, rc):
if rc == 0:
print("[OK] EXITO: Conexion establecida correctamente.")
elif rc == 4:
print("[ERROR] FALLO ESPERADO: Credenciales incorrectas (rc=4).")
elif rc == 5:
print("[SECURITY] SEGURIDAD ACTIVA: Acceso denegado/No autorizado (rc=5).")
else:
print("[?] RESULTADO: Codigo de retorno: {}".format(rc))
client.disconnect()
def test_auth():
print("--- 🧪 Prueba 1: Conexión con CREDENCIALES CORRECTAS ({}) ---".format(MQTT_USER))
client = mqtt.Client()
client.username_pw_set(MQTT_USER, MQTT_PASSWORD)
client.on_connect = on_connect
try:
client.connect(MQTT_HOST, MQTT_PORT, 60)
client.loop_forever()
except Exception as e:
print("❌ ERROR DE CONEXIÓN: {}".format(e))
print("\n--- 🧪 Prueba 2: Conexión ANONYMOUS (Sin credenciales) ---")
client_anon = mqtt.Client()
client_anon.on_connect = on_connect
try:
client_anon.connect(MQTT_HOST, MQTT_PORT, 60)
client_anon.loop_forever()
except Exception as e:
# En Mosquitto 2.0+, la conexión puede fallar antes de rc si no hay auth
print("✅ COMPORTAMIENTO ESPERADO: Conexión denegada o error de socket.")
if __name__ == "__main__":
test_auth()

View File

@@ -7,7 +7,7 @@ edition = "2024"
shared-lib = { path = "../shared-lib" } shared-lib = { path = "../shared-lib" }
axum = { version = "0.8.8", features = ["macros"] } axum = { version = "0.8.8", features = ["macros"] }
tokio = { version = "1.50.0", features = ["full"] } tokio = { version = "1.50.0", features = ["full"] }
sqlx = { version = "0.8.6", features = ["runtime-tokio-native-tls", "postgres", "uuid", "chrono", "json"] } sqlx = { version = "0.8.6", features = ["runtime-tokio-native-tls", "postgres", "uuid", "chrono", "json", "bigdecimal"] }
tower = { version = "0.5.3", features = ["util"] } tower = { version = "0.5.3", features = ["util"] }
tower-http = { version = "0.6.8", features = ["cors", "trace"] } tower-http = { version = "0.6.8", features = ["cors", "trace"] }
serde = { version = "1.0.228", features = ["derive"] } serde = { version = "1.0.228", features = ["derive"] }
@@ -23,4 +23,10 @@ axum-extra = { version = "0.12.5", features = ["typed-header"] }
uuid = { version = "1.23.0", features = ["v4", "serde"] } uuid = { version = "1.23.0", features = ["v4", "serde"] }
anyhow = "1.0.102" anyhow = "1.0.102"
rumqttc = "0.25.1" rumqttc = "0.25.1"
bigdecimal = { version = "0.4.7", features = ["serde"] }
aws-config = "1.5"
aws-sdk-s3 = "1.50"
tokio-util = { version = "0.7.18", features = ["io"] }
tower_governor = "0.8.0"
governor = "0.10.4"
sha2 = "0.10.8"

View File

@@ -17,7 +17,7 @@ ENV CARGO_INCREMENTAL=0
# ============================================================================= # =============================================================================
FROM base AS planner FROM base AS planner
# Copiamos solo los archivos que definen dependencias (workspace y miembros) # Copiamos solo los archivos que definen dependencias (workspace y miembros)
COPY Cargo.toml Cargo.loc[k] ./ COPY Cargo.toml Cargo.lock ./
COPY services/shared-lib/Cargo.toml services/shared-lib/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/flow-generator/Cargo.toml services/flow-generator/Cargo.toml
COPY services/backend-api/Cargo.toml services/backend-api/Cargo.toml COPY services/backend-api/Cargo.toml services/backend-api/Cargo.toml
@@ -26,6 +26,7 @@ COPY services/json-generator/Cargo.toml services/json-generator/Cargo.toml
COPY services/events-relay/Cargo.toml services/events-relay/Cargo.toml COPY services/events-relay/Cargo.toml services/events-relay/Cargo.toml
COPY services/build-orchestrator/Cargo.toml services/build-orchestrator/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/edge-agent/Cargo.toml services/edge-agent/Cargo.toml
COPY services/telemetry-ingestor/Cargo.toml services/telemetry-ingestor/Cargo.toml
# Creamos estructuras dummy para que cargo metadata no falle (muy importante para la caché) # Creamos estructuras dummy para que cargo metadata no falle (muy importante para la caché)
RUN mkdir -p services/shared-lib/src && touch services/shared-lib/src/lib.rs && \ RUN mkdir -p services/shared-lib/src && touch services/shared-lib/src/lib.rs && \
@@ -35,7 +36,8 @@ RUN mkdir -p services/shared-lib/src && touch services/shared-lib/src/lib.rs &&
mkdir -p services/json-generator/src && echo "fn main() {}" > services/json-generator/src/main.rs && \ mkdir -p services/json-generator/src && echo "fn main() {}" > services/json-generator/src/main.rs && \
mkdir -p services/events-relay/src && echo "fn main() {}" > services/events-relay/src/main.rs && \ mkdir -p services/events-relay/src && echo "fn main() {}" > services/events-relay/src/main.rs && \
mkdir -p services/build-orchestrator/src && echo "fn main() {}" > services/build-orchestrator/src/main.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/edge-agent/src && echo "fn main() {}" > services/edge-agent/src/main.rs && \
mkdir -p services/telemetry-ingestor/src && echo "fn main() {}" > services/telemetry-ingestor/src/main.rs
# Generamos la receta solo para este binario # Generamos la receta solo para este binario
RUN cargo chef prepare --recipe-path recipe.json --bin backend-api RUN cargo chef prepare --recipe-path recipe.json --bin backend-api

View File

@@ -1,15 +1,10 @@
-- ============================================================================= -- =============================================================================
-- OMNIOIL — ESQUEMA MAESTRO V4.0 — PARTE 1: BASE -- OMNIOIL — CONSOLIDATED INITIAL SCHEMA
-- Seguridad, Jerarquía Operativa y Telemetría
-- Ejecutar primero (01_base.sql)
-- ============================================================================= -- =============================================================================
CREATE EXTENSION IF NOT EXISTS "uuid-ossp"; CREATE EXTENSION IF NOT EXISTS "uuid-ossp";
-- ╔═══════════════════════════════════════════════════════════════════════════╗ -- MÓDULO 1: SEGURIDAD Y ACCESO
-- ║ MÓDULO 1: SEGURIDAD Y ACCESO ║
-- ╚═══════════════════════════════════════════════════════════════════════════╝
CREATE TABLE IF NOT EXISTS roles ( CREATE TABLE IF NOT EXISTS roles (
id SERIAL PRIMARY KEY, id SERIAL PRIMARY KEY,
name VARCHAR(50) UNIQUE NOT NULL, name VARCHAR(50) UNIQUE NOT NULL,
@@ -36,39 +31,37 @@ CREATE TABLE IF NOT EXISTS users (
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(),
usuario_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE, user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE,
token_hash VARCHAR(255) NOT NULL, token_hash TEXT NOT NULL UNIQUE,
expires_at TIMESTAMPTZ NOT NULL, expires_at TIMESTAMPTZ NOT NULL,
revoked_at TIMESTAMPTZ, created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
created_at TIMESTAMPTZ DEFAULT NOW() revoked_at TIMESTAMPTZ
); );
CREATE INDEX IF NOT EXISTS idx_refresh_tokens_user_id ON refresh_tokens(user_id);
CREATE INDEX IF NOT EXISTS idx_refresh_tokens_token_hash ON refresh_tokens(token_hash);
-- ╔═══════════════════════════════════════════════════════════════════════════╗ -- MÓDULO 2: JERARQUÍA OPERATIVA
-- ║ MÓDULO 2: JERARQUÍA OPERATIVA ║
-- ║ PROYECTO → ASSET → DISPOSITIVO → VARIABLE ║
-- ╚═══════════════════════════════════════════════════════════════════════════╝
-- NIVEL 1: PROYECTOS / CAMPOS
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, name VARCHAR(100) NOT NULL UNIQUE,
client VARCHAR(150) NOT NULL, client VARCHAR(150) NOT NULL,
description TEXT, description TEXT,
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',
CHECK (installer_status IN ('NOT_STARTED', 'QUEUED', 'BUILDING', 'COMPLETED', 'FAILED')),
installer_url TEXT, installer_url TEXT,
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'))
); );
CREATE INDEX IF NOT EXISTS idx_edge_projects_client ON edge_projects(client); CREATE INDEX IF NOT EXISTS idx_edge_projects_client ON edge_projects(client);
-- Acceso granular (Usuario → Proyecto)
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,
@@ -79,7 +72,6 @@ CREATE TABLE IF NOT EXISTS user_project_access (
PRIMARY KEY (user_id, project_id) PRIMARY KEY (user_id, project_id)
); );
-- NIVEL 2: ACTIVOS FÍSICOS (Pozos, Tanques, Medidores)
CREATE TABLE IF NOT EXISTS edge_assets ( CREATE TABLE IF NOT EXISTS edge_assets (
id UUID PRIMARY KEY DEFAULT uuid_generate_v4(), id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
project_id UUID NOT NULL REFERENCES edge_projects(id) ON DELETE CASCADE, project_id UUID NOT NULL REFERENCES edge_projects(id) ON DELETE CASCADE,
@@ -93,13 +85,8 @@ CREATE TABLE IF NOT EXISTS edge_assets (
updated_at TIMESTAMPTZ DEFAULT NOW() updated_at TIMESTAMPTZ DEFAULT NOW()
); );
COMMENT ON COLUMN edge_assets.code IS 'Código ANH: TK-5001, POZO-X1, Medidor-Gas-01';
COMMENT ON COLUMN edge_assets.asset_type IS 'POZO | TANQUE | MEDIDOR_GAS | AGUA | OTRO';
CREATE INDEX IF NOT EXISTS idx_edge_assets_project ON edge_assets(project_id); CREATE INDEX IF NOT EXISTS idx_edge_assets_project ON edge_assets(project_id);
CREATE INDEX IF NOT EXISTS idx_edge_assets_type ON edge_assets(asset_type);
-- NIVEL 3: DISPOSITIVOS INDUSTRIALES (PLC, RTU, Gateway)
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,
@@ -114,9 +101,6 @@ CREATE TABLE IF NOT EXISTS edge_devices (
updated_at TIMESTAMPTZ DEFAULT NOW() updated_at TIMESTAMPTZ DEFAULT NOW()
); );
CREATE INDEX IF NOT EXISTS idx_edge_devices_asset ON edge_devices(asset_id);
-- NIVEL 4: VARIABLES SCADA (Registros Modbus / Nodos OPC UA)
CREATE TABLE IF NOT EXISTS edge_variables ( 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,
@@ -126,73 +110,90 @@ CREATE TABLE IF NOT EXISTS edge_variables (
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,
alarm_min DOUBLE PRECISION,
alarm_max DOUBLE PRECISION,
alarm_enabled BOOLEAN DEFAULT false,
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')),
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()
); );
CREATE INDEX IF NOT EXISTS idx_edge_variables_device ON edge_variables(device_id); -- MÓDULO 3: TELEMETRÍA (TimescaleDB)
-- ╔═══════════════════════════════════════════════════════════════════════════╗
-- ║ MÓDULO 3: TELEMETRÍA Y DATOS OPERATIVOS ║
-- ╚═══════════════════════════════════════════════════════════════════════════╝
-- Alta Frecuencia (TimescaleDB Hypertable)
CREATE TABLE IF NOT EXISTS telemetry_raw ( CREATE TABLE IF NOT EXISTS telemetry_raw (
time TIMESTAMPTZ NOT NULL, time TIMESTAMPTZ NOT NULL DEFAULT NOW(),
asset_id UUID NOT NULL REFERENCES edge_assets(id) ON DELETE CASCADE, project_id UUID NOT NULL REFERENCES edge_projects(id) ON DELETE CASCADE,
data JSONB NOT NULL, data JSONB NOT NULL
PRIMARY KEY (time, asset_id) );
CREATE TABLE IF NOT EXISTS agent_logs (
id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
project_id UUID NOT NULL REFERENCES edge_projects(id) ON DELETE CASCADE,
agent_hostname VARCHAR(255),
level VARCHAR(20),
category VARCHAR(50),
message TEXT,
context JSONB,
timestamp TIMESTAMPTZ DEFAULT NOW()
); );
-- Convertir a Hypertable
SELECT create_hypertable('telemetry_raw', 'time', if_not_exists => TRUE); SELECT create_hypertable('telemetry_raw', 'time', if_not_exists => TRUE);
-- Compresión automática -- MÓDULO 4: EDGE AGENT DEPLOYMENTS
ALTER TABLE telemetry_raw SET ( CREATE TABLE IF NOT EXISTS edge_deployments (
timescaledb.compress,
timescaledb.compress_segmentby = 'asset_id'
);
SELECT add_compression_policy('telemetry_raw', INTERVAL '7 days');
-- Movimientos en tanque
CREATE TABLE IF NOT EXISTS operations_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), project_id UUID NOT NULL REFERENCES edge_projects(id) ON DELETE CASCADE,
movement_type VARCHAR(50) NOT NULL image_tag VARCHAR(500) NOT NULL,
image_hash VARCHAR(100),
status VARCHAR(30) NOT NULL DEFAULT 'BUILDING',
previous_image_tag VARCHAR(500),
previous_image_hash VARCHAR(100),
config_snapshot JSONB,
build_metadata JSONB NOT NULL DEFAULT '{}'::jsonb,
error_message TEXT,
deployed_at TIMESTAMPTZ,
created_at TIMESTAMPTZ DEFAULT NOW()
);
-- MÓDULO 5: OUTBOX (Patrón de Mensajería)
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 (
username VARCHAR(100) PRIMARY KEY,
password_hash VARCHAR(255) NOT NULL,
is_admin BOOLEAN DEFAULT false,
created_at TIMESTAMPTZ DEFAULT NOW()
);
CREATE TABLE IF NOT EXISTS mqtt_acls (
id SERIAL PRIMARY KEY,
username VARCHAR(100) NOT NULL REFERENCES mqtt_users(username) ON DELETE CASCADE,
topic VARCHAR(500) NOT NULL,
rw INT DEFAULT 1, -- 1 = read, 2 = write, 3 = readwrite
created_at TIMESTAMPTZ DEFAULT NOW()
);
-- MÓDULO 7: MOVIMIENTOS OPERATIVOS (ANH)
CREATE TABLE IF NOT EXISTS operation_movements (
id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
asset_id UUID NOT NULL REFERENCES edge_assets(id) ON DELETE CASCADE,
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 NUMERIC(10, 2), volumen_neto DECIMAL(18, 4),
details JSONB DEFAULT '{}'::jsonb, details JSONB DEFAULT '{}'::jsonb,
created_at TIMESTAMPTZ DEFAULT NOW() created_at TIMESTAMPTZ DEFAULT NOW(),
CONSTRAINT check_movement_times CHECK (end_time >= start_time)
); );
-- Paradas de pozo CREATE INDEX IF NOT EXISTS idx_operation_movements_asset ON operation_movements(asset_id);
CREATE TABLE IF NOT EXISTS operations_downtime ( CREATE INDEX IF NOT EXISTS idx_operation_movements_type ON operation_movements(movement_type);
id UUID PRIMARY KEY DEFAULT uuid_generate_v4(), CREATE INDEX IF NOT EXISTS idx_operation_movements_time ON operation_movements(start_time);
asset_id UUID NOT NULL REFERENCES edge_assets(id),
start_time TIMESTAMPTZ NOT NULL,
end_time TIMESTAMPTZ,
is_planned BOOLEAN DEFAULT false,
reason VARCHAR(255) NOT NULL,
comments TEXT,
created_at TIMESTAMPTZ DEFAULT NOW()
);
-- Resultados de laboratorio
CREATE TABLE IF NOT EXISTS lab_results (
id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
asset_id UUID NOT NULL REFERENCES edge_assets(id),
sample_time TIMESTAMPTZ NOT NULL,
results JSONB NOT NULL,
lab_technician VARCHAR(100),
created_at TIMESTAMPTZ DEFAULT NOW()
);

View File

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

@@ -2,21 +2,15 @@
// Second comment for extra verification // Second comment for extra verification
// Third comment for unified build verification // Third comment for unified build verification
use axum::{ use axum::{
http::StatusCode, extract::FromRequestParts,
http::{StatusCode, header, request::Parts},
response::{IntoResponse, Json, Response}, response::{IntoResponse, Json, Response},
}; };
use chrono::{Duration, Utc}; use chrono::{Duration, Utc};
use jsonwebtoken::{decode, encode, DecodingKey, EncodingKey, Header, TokenData, Validation}; use jsonwebtoken::{DecodingKey, EncodingKey, Header, TokenData, Validation, decode, encode};
use serde::{Deserialize, Serialize}; use serde::{Deserialize, Serialize};
use std::env; use std::env;
#[derive(Debug, Serialize, Deserialize)]
pub struct Claims {
pub sub: String, // User ID
pub role: String,
pub exp: usize,
}
#[derive(Debug)] #[derive(Debug)]
pub enum AuthError { pub enum AuthError {
InvalidCredentials, InvalidCredentials,
@@ -29,13 +23,22 @@ pub enum AuthError {
impl IntoResponse for AuthError { impl IntoResponse for AuthError {
fn into_response(self) -> Response { fn into_response(self) -> Response {
let (status, error_message) = match self { let (status, error_message) = match self {
AuthError::InvalidCredentials => (StatusCode::UNAUTHORIZED, "Credenciales inválidas".to_string()), AuthError::InvalidCredentials => (
StatusCode::UNAUTHORIZED,
"Credenciales inválidas".to_string(),
),
AuthError::EmailAlreadyExists => (StatusCode::CONFLICT, "El correo ya se encuentra registrado".to_string()), AuthError::EmailAlreadyExists => (StatusCode::CONFLICT, "El correo ya se encuentra registrado".to_string()),
AuthError::AccountDisabled => (StatusCode::FORBIDDEN, "Tu cuenta ha sido desactivada. Contacta al administrador.".to_string()), AuthError::AccountDisabled => (
StatusCode::FORBIDDEN,
"Tu cuenta ha sido desactivada. Contacta al administrador.".to_string(),
),
AuthError::InvalidInput(msg) => (StatusCode::BAD_REQUEST, msg), AuthError::InvalidInput(msg) => (StatusCode::BAD_REQUEST, msg),
AuthError::Internal(ref e) => { AuthError::Internal(ref e) => {
tracing::error!("Auth internal error: {:?}", e); tracing::error!("Auth internal error: {:?}", e);
(StatusCode::INTERNAL_SERVER_ERROR, "Error interno del servidor".to_string()) (
StatusCode::INTERNAL_SERVER_ERROR,
"Error interno del servidor".to_string(),
)
} }
}; };
@@ -49,27 +52,75 @@ impl IntoResponse for AuthError {
} }
} }
pub fn create_jwt(uid: &str, role: &str) -> Result<String, jsonwebtoken::errors::Error> { #[derive(Debug, Serialize, Deserialize, Clone)]
pub struct Claims {
pub sub: String, // User ID
pub role: String,
pub projects: Vec<uuid::Uuid>, // Project IDs the user has access to
pub exp: usize,
}
#[derive(Debug, Serialize, Deserialize)]
pub struct RefreshClaims {
pub sub: String,
pub exp: usize,
}
#[derive(Debug, Serialize, Deserialize)]
pub struct TokenResponse {
pub access_token: String,
pub refresh_token: String,
pub expires_in: usize,
}
pub fn create_jwt_tokens(uid: &str, role: &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");
let expiration = Utc::now()
.checked_add_signed(Duration::hours(24)) // Access Token (15 minutos para mayor seguridad)
let access_exp = Utc::now()
.checked_add_signed(Duration::minutes(15))
.expect("valid timestamp") .expect("valid timestamp")
.timestamp(); .timestamp();
let claims = Claims { let access_claims = Claims {
sub: uid.to_owned(), sub: uid.to_owned(),
role: role.to_owned(), role: role.to_owned(),
exp: expiration as usize, projects,
exp: access_exp as usize,
}; };
encode( let access_token = encode(
&Header::default(), &Header::default(),
&claims, &access_claims,
&EncodingKey::from_secret(secret.as_bytes()), &EncodingKey::from_secret(secret.as_bytes()),
) )?;
// Refresh Token (7 días)
let refresh_exp = Utc::now()
.checked_add_signed(Duration::days(7))
.expect("valid timestamp")
.timestamp();
let refresh_claims = RefreshClaims {
sub: uid.to_owned(),
exp: refresh_exp as usize,
};
let refresh_token = encode(
&Header::default(),
&refresh_claims,
&EncodingKey::from_secret(secret.as_bytes()),
)?;
Ok(TokenResponse {
access_token,
refresh_token,
expires_in: 900, // 15 minutos en segundos
})
} }
#[allow(dead_code)] 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(
@@ -78,3 +129,39 @@ pub fn verify_jwt(token: &str) -> Result<TokenData<Claims>, jsonwebtoken::errors
&Validation::default(), &Validation::default(),
) )
} }
pub fn verify_refresh_jwt(token: &str) -> Result<TokenData<RefreshClaims>, jsonwebtoken::errors::Error> {
let secret = env::var("JWT_SECRET").expect("JWT_SECRET must be set");
decode(
token,
&DecodingKey::from_secret(secret.as_bytes()),
&Validation::default(),
)
}
impl<S> FromRequestParts<S> for Claims
where
S: Send + Sync,
{
type Rejection = AuthError;
async fn from_request_parts(parts: &mut Parts, _state: &S) -> Result<Self, Self::Rejection> {
// Extraer Bearer token del header Authorization
let auth_header = parts
.headers
.get(header::AUTHORIZATION)
.and_then(|value| value.to_str().ok())
.ok_or(AuthError::InvalidCredentials)?;
if !auth_header.starts_with("Bearer ") {
return Err(AuthError::InvalidCredentials);
}
let token = &auth_header[7..];
// Validar el token
let token_data = verify_jwt(token).map_err(|_| AuthError::InvalidCredentials)?;
Ok(token_data.claims)
}
}

View File

@@ -6,8 +6,25 @@ pub type DbPool = PgPool;
pub async fn establish_connection() -> Result<DbPool, sqlx::Error> { pub async fn establish_connection() -> Result<DbPool, sqlx::Error> {
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");
PgPoolOptions::new() let mut retries = 0;
.max_connections(10) let max_retries = 10;
.connect(&database_url)
.await loop {
match PgPoolOptions::new()
.max_connections(10)
.acquire_timeout(std::time::Duration::from_secs(3))
.connect(&database_url)
.await
{
Ok(pool) => return Ok(pool),
Err(e) => {
retries += 1;
if retries >= max_retries {
return Err(e);
}
tracing::warn!("⏳ Fallo conexión a DB (intento {}/{}): {}. Reintentando en 5s...", retries, max_retries, e);
tokio::time::sleep(std::time::Duration::from_secs(5)).await;
}
}
}
} }

View File

@@ -0,0 +1,63 @@
use axum::{
http::StatusCode,
response::{IntoResponse, Response, Json},
};
use serde_json::json;
use tracing::error;
use uuid::Uuid;
pub enum AppError {
Database(sqlx::Error),
Internal(anyhow::Error),
NotFound(String),
Forbidden(String),
BadRequest(String),
}
impl IntoResponse for AppError {
fn into_response(self) -> Response {
let correlation_id = Uuid::new_v4();
let (status, message) = match self {
AppError::Database(ref e) => {
error!(correlation_id = %correlation_id, "Database Error: {:?}", e);
(StatusCode::INTERNAL_SERVER_ERROR, "Error interno de base de datos".to_string())
}
AppError::Internal(ref e) => {
error!(correlation_id = %correlation_id, "Internal Error: {:?}", e);
(StatusCode::INTERNAL_SERVER_ERROR, "Error interno del servidor".to_string())
}
AppError::NotFound(entity) => {
(StatusCode::NOT_FOUND, format!("{} no encontrado", entity))
}
AppError::Forbidden(msg) => {
(StatusCode::FORBIDDEN, msg)
}
AppError::BadRequest(msg) => {
(StatusCode::BAD_REQUEST, msg)
}
};
(
status,
Json(json!({
"message": message,
"correlation_id": correlation_id
})),
)
.into_response()
}
}
// Convert common errors to AppError
impl From<sqlx::Error> for AppError {
fn from(err: sqlx::Error) -> Self {
AppError::Database(err)
}
}
impl From<anyhow::Error> for AppError {
fn from(err: anyhow::Error) -> Self {
AppError::Internal(err)
}
}

View File

@@ -1,5 +1,5 @@
use crate::{ use crate::{
auth::{AuthError, create_jwt}, auth::{AuthError, create_jwt_tokens},
db::DbPool, db::DbPool,
}; };
use argon2::{ use argon2::{
@@ -32,6 +32,12 @@ struct UserRow {
is_active: bool, is_active: bool,
} }
#[derive(sqlx::FromRow, serde::Serialize)]
struct ProjectSummary {
id: uuid::Uuid,
name: String,
}
pub async fn login( pub async fn login(
State(pool): State<DbPool>, State(pool): State<DbPool>,
Json(payload): Json<LoginPayload>, Json(payload): Json<LoginPayload>,
@@ -75,12 +81,52 @@ pub async fn login(
.await; .await;
}); });
let token = create_jwt(&user.id.to_string(), &user.role_name) let projects = sqlx::query_as::<sqlx::Postgres, ProjectSummary>(
.map_err(|_| AuthError::Internal(anyhow::anyhow!("Error al crear token")))?; 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 AND ep.is_active = true
ORDER BY ep.name ASC
"#,
)
.bind(user_id)
.fetch_all(&pool)
.await
.map_err(|e| {
tracing::error!("Projects query error: {}", e);
AuthError::Internal(anyhow::anyhow!("Error al obtener proyectos"))
})?;
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)
.map_err(|_| AuthError::Internal(anyhow::anyhow!("Error al crear tokens")))?;
// 3. Almacenar el hash del refresh token en la base de datos
use sha2::{Digest, Sha256};
let mut hasher = Sha256::new();
hasher.update(tokens.refresh_token.as_bytes());
let refresh_hash = format!("{:x}", hasher.finalize());
sqlx::query(
"INSERT INTO refresh_tokens (user_id, token_hash, expires_at) VALUES ($1, $2, $3)",
)
.bind(user.id)
.bind(refresh_hash)
.bind(chrono::Utc::now() + chrono::Duration::days(7))
.execute(&pool)
.await
.map_err(|e| {
tracing::error!("Error storing refresh token: {}", e);
AuthError::Internal(anyhow::anyhow!("Error de sesión"))
})?;
return Ok(Json(json!({ return Ok(Json(json!({
"token": token, "access_token": tokens.access_token,
"role": user.role_name "refresh_token": tokens.refresh_token,
"token": tokens.access_token, // Compatibilidad
"role": user.role_name,
"projects": projects
}))); })));
} }
} }
@@ -88,6 +134,80 @@ pub async fn login(
Err(AuthError::InvalidCredentials) Err(AuthError::InvalidCredentials)
} }
#[derive(Deserialize)]
pub struct RefreshPayload {
pub refresh_token: String,
}
pub async fn refresh(
State(pool): State<DbPool>,
Json(payload): Json<RefreshPayload>,
) -> Result<impl IntoResponse, AuthError> {
use crate::auth::verify_refresh_jwt;
use sha2::{Digest, Sha256};
// 1. Validar el JWT del refresh token (estructura y expiración básica)
let token_data =
verify_refresh_jwt(&payload.refresh_token).map_err(|_| AuthError::InvalidCredentials)?;
// 2. Verificar el hash contra la base de datos
let mut hasher = Sha256::new();
hasher.update(payload.refresh_token.as_bytes());
let refresh_hash = format!("{:x}", hasher.finalize());
let stored = sqlx::query("SELECT user_id FROM refresh_tokens WHERE token_hash = $1 AND revoked_at IS NULL AND expires_at > NOW()")
.bind(&refresh_hash)
.fetch_optional(&pool)
.await
.map_err(|e| {
tracing::error!("Refresh token DB error: {}", e);
AuthError::Internal(anyhow::anyhow!("Error de validación"))
})?;
if let Some(_) = stored {
// En un flujo real, obtendríamos los datos frescos del usuario y sus proyectos
let user_id = uuid::Uuid::parse_str(&token_data.claims.sub)
.map_err(|_| AuthError::InvalidCredentials)?;
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"
)
.bind(user_id)
.fetch_one(&pool)
.await
.map_err(|_| AuthError::InvalidCredentials)?;
let projects = sqlx::query_as::<sqlx::Postgres, ProjectSummary>(
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 AND ep.is_active = true
ORDER BY ep.name ASC
"#,
)
.bind(user_id)
.fetch_all(&pool)
.await
.map_err(|_| AuthError::Internal(anyhow::anyhow!("Error de proyectos")))?;
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)
.map_err(|_| AuthError::Internal(anyhow::anyhow!("Error al refrescar")))?;
// Opcional: Rotar el refresh token aquí si se desea mayor seguridad
return Ok(Json(json!({
"access_token": tokens.access_token,
"token": tokens.access_token,
"role": user.role_name,
"projects": projects
})));
}
Err(AuthError::InvalidCredentials)
}
#[derive(sqlx::FromRow)] #[derive(sqlx::FromRow)]
struct RegisterResult { struct RegisterResult {
id: uuid::Uuid, id: uuid::Uuid,
@@ -99,15 +219,21 @@ pub async fn register(
) -> Result<impl IntoResponse, AuthError> { ) -> Result<impl IntoResponse, AuthError> {
// 1. Validaciones de entrada // 1. Validaciones de entrada
if payload.email.is_empty() || !payload.email.contains('@') { if payload.email.is_empty() || !payload.email.contains('@') {
return Err(AuthError::InvalidInput("Formato de correo electrónico inválido".to_string())); return Err(AuthError::InvalidInput(
"Formato de correo electrónico inválido".to_string(),
));
} }
if payload.password.len() < 8 { if payload.password.len() < 8 {
return Err(AuthError::InvalidInput("La contraseña debe tener al menos 8 caracteres".to_string())); return Err(AuthError::InvalidInput(
"La contraseña debe tener al menos 8 caracteres".to_string(),
));
} }
if payload.full_name.trim().is_empty() { if payload.full_name.trim().is_empty() {
return Err(AuthError::InvalidInput("El nombre completo es obligatorio".to_string())); return Err(AuthError::InvalidInput(
"El nombre completo es obligatorio".to_string(),
));
} }
// 2. Cifrar la contraseña // 2. Cifrar la contraseña
@@ -122,11 +248,12 @@ pub async fn register(
.to_string(); .to_string();
// 2. Insert the user // 2. Insert the user
// We default to role 'operador' (id 3 as per init.sql) if not specified // We default to role 'admin' (id 3 as per init.sql) if not specified
//El operador es solo para la pwa.
let register_result: RegisterResult = sqlx::query_as::<sqlx::Postgres, RegisterResult>( let register_result: RegisterResult = sqlx::query_as::<sqlx::Postgres, RegisterResult>(
r#" r#"
INSERT INTO users (email, password_hash, full_name, role_id) INSERT INTO users (email, password_hash, full_name, role_id)
VALUES ($1, $2, $3, (SELECT id FROM roles WHERE name = 'operador')) VALUES ($1, $2, $3, (SELECT id FROM roles WHERE name = 'admin'))
RETURNING id RETURNING id
"#, "#,
) )

View File

@@ -1,7 +1,9 @@
use crate::db::DbPool;
use axum::{extract::State, response::IntoResponse, Json}; use axum::{extract::State, response::IntoResponse, Json};
use chrono::{DateTime, Utc}; use chrono::{DateTime, Utc};
use serde_json::{json, Value}; use serde_json::{json, Value};
use crate::db::DbPool;
use crate::auth::Claims;
use uuid::Uuid;
#[derive(sqlx::FromRow)] #[derive(sqlx::FromRow)]
struct TelemetryRow { struct TelemetryRow {
@@ -10,7 +12,12 @@ struct TelemetryRow {
data: Value, data: Value,
} }
pub async fn get_summary(State(pool): State<DbPool>) -> impl IntoResponse { pub async fn get_summary(
State(pool): State<DbPool>,
claims: Claims,
) -> impl IntoResponse {
let user_id = Uuid::parse_str(&claims.sub).unwrap();
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)
@@ -19,9 +26,12 @@ pub async fn get_summary(State(pool): State<DbPool>) -> impl IntoResponse {
t.data t.data
FROM telemetry_raw t FROM telemetry_raw t
JOIN edge_assets a ON t.asset_id = a.id JOIN edge_assets a ON t.asset_id = a.id
JOIN user_project_access upa ON a.project_id = upa.project_id
WHERE upa.user_id = $1 AND upa.is_active = true
ORDER BY t.asset_id, t.time DESC ORDER BY t.asset_id, t.time DESC
"#, "#,
) )
.bind(user_id)
.fetch_all(&pool) .fetch_all(&pool)
.await; .await;

View File

@@ -11,6 +11,7 @@ use serde::Deserialize;
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;
#[derive(Debug, Deserialize)] #[derive(Debug, Deserialize)]
pub struct LogQueryParams { pub struct LogQueryParams {
@@ -19,40 +20,58 @@ pub struct LogQueryParams {
pub limit: Option<i64>, pub limit: Option<i64>,
} }
use crate::errors::AppError;
/// GET /api/edge/agents/logs /// GET /api/edge/agents/logs
///
/// Lista de logs históricos centralizados. Permite filtrar por nivel (INFO/ERROR)
/// y categoría (DOCKER/SYSTEM/MQTT).
pub async fn list_agent_logs( pub async fn list_agent_logs(
State(pool): State<PgPool>, State(pool): State<PgPool>,
claims: Claims,
Query(params): Query<LogQueryParams>, Query(params): Query<LogQueryParams>,
) -> Result<Json<Vec<EdgeAgentLog>>, (StatusCode, String)> { ) -> Result<Json<Vec<EdgeAgentLog>>, AppError> {
let limit = params.limit.unwrap_or(100).min(500); let limit = params.limit.unwrap_or(100).min(500);
let logs = sqlx::query_as::<_, EdgeAgentLog>( let logs = if claims.role == "admin" {
r#"SELECT * FROM edge_agent_logs sqlx::query_as::<_, EdgeAgentLog>(
WHERE ($1::VARCHAR IS NULL OR level = $1) r#"SELECT * FROM edge_agent_logs
AND ($2::VARCHAR IS NULL OR category = $2) WHERE ($1::VARCHAR IS NULL OR level = $1)
ORDER BY created_at DESC AND ($2::VARCHAR IS NULL OR category = $2)
LIMIT $3"#, ORDER BY created_at DESC
) LIMIT $3"#,
.bind(&params.level) )
.bind(&params.category) .bind(&params.level)
.bind(limit) .bind(&params.category)
.fetch_all(&pool) .bind(limit)
.await .fetch_all(&pool)
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?; .await?
} else {
let user_id = Uuid::parse_str(&claims.sub)
.map_err(|_| AppError::BadRequest("User ID inválido".to_string()))?;
sqlx::query_as::<_, EdgeAgentLog>(
r#"SELECT l.* FROM edge_agent_logs l
JOIN user_project_access upa ON l.project_id = upa.project_id
WHERE upa.user_id = $1 AND upa.is_active = true
AND ($2::VARCHAR IS NULL OR l.level = $2)
AND ($3::VARCHAR IS NULL OR l.category = $3)
ORDER BY l.created_at DESC
LIMIT $4"#,
)
.bind(user_id)
.bind(&params.level)
.bind(&params.category)
.bind(limit)
.fetch_all(&pool)
.await?
};
Ok(Json(logs)) Ok(Json(logs))
} }
/// POST /api/edge/agents/logs /// POST /api/edge/agents/logs
///
/// Endpoint que consumen los agentes para reportar su estado y errores internos.
pub async fn create_agent_log( pub async fn create_agent_log(
State(pool): State<PgPool>, State(pool): State<PgPool>,
Json(req): Json<CreateAgentLogRequest>, Json(req): Json<CreateAgentLogRequest>,
) -> Result<StatusCode, (StatusCode, String)> { ) -> 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 edge_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::INET)"#
@@ -65,20 +84,32 @@ pub async fn create_agent_log(
.bind(&req.agent_hostname) .bind(&req.agent_hostname)
.bind(&req.agent_ip) .bind(&req.agent_ip)
.execute(&pool) .execute(&pool)
.await .await?;
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?;
Ok(StatusCode::CREATED) Ok(StatusCode::CREATED)
} }
/// GET /api/edge/agents/:project_id/health /// GET /api/edge/agents/:project_id/health
///
/// Retorna un resumen rápido de si el agente está online basado en su último log de HEALTH,
/// junto con la versión actual desplegada.
pub async fn get_agent_health( pub async fn get_agent_health(
State(pool): State<PgPool>, State(pool): State<PgPool>,
claims: Claims,
Path(project_id): Path<Uuid>, Path(project_id): Path<Uuid>,
) -> Result<Json<serde_json::Value>, (StatusCode, String)> { ) -> Result<Json<serde_json::Value>, AppError> {
// Validar acceso al proyecto
if claims.role != "admin" {
let user_id = Uuid::parse_str(&claims.sub)
.map_err(|_| AppError::BadRequest("User ID inválido".to_string()))?;
let access = sqlx::query("SELECT 1 FROM user_project_access WHERE user_id = $1 AND project_id = $2 AND is_active = true")
.bind(user_id)
.bind(project_id)
.fetch_optional(&pool)
.await?;
if access.is_none() {
return Err(AppError::Forbidden("Sin acceso al estado de este proyecto".to_string()));
}
}
// 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 edge_agent_logs
@@ -87,8 +118,7 @@ pub async fn get_agent_health(
) )
.bind(project_id) .bind(project_id)
.fetch_optional(&pool) .fetch_optional(&pool)
.await .await?;
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?;
// Obtener metadatos del último despliegue para saber qué versión tiene // Obtener metadatos del último despliegue para saber qué versión tiene
let last_deploy = sqlx::query_as::<_, EdgeDeployment>( let last_deploy = sqlx::query_as::<_, EdgeDeployment>(
@@ -96,8 +126,7 @@ pub async fn get_agent_health(
) )
.bind(project_id) .bind(project_id)
.fetch_optional(&pool) .fetch_optional(&pool)
.await .await?;
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?;
let health_status = serde_json::json!({ let health_status = serde_json::json!({
"project_id": project_id, "project_id": project_id,

View File

@@ -10,6 +10,7 @@ use uuid::Uuid;
use serde::Deserialize; use serde::Deserialize;
use serde_json::Value; use serde_json::Value;
use shared_lib::models::{EdgeAsset, CreateEdgeAssetRequest}; use shared_lib::models::{EdgeAsset, CreateEdgeAssetRequest};
use crate::auth::Claims;
#[derive(Deserialize)] #[derive(Deserialize)]
pub struct UpdateAssetRequest { pub struct UpdateAssetRequest {
@@ -18,18 +19,34 @@ pub struct UpdateAssetRequest {
pub metadata: Option<Value>, pub metadata: Option<Value>,
} }
use crate::errors::AppError;
/// GET /api/edge/projects/:project_id/assets — Listar activos de un proyecto. /// GET /api/edge/projects/:project_id/assets — Listar activos de un proyecto.
pub async fn list_assets_by_project( pub async fn list_assets_by_project(
State(pool): State<PgPool>, State(pool): State<PgPool>,
claims: Claims,
Path(project_id): Path<Uuid>, Path(project_id): Path<Uuid>,
) -> Result<Json<Vec<EdgeAsset>>, (StatusCode, String)> { ) -> Result<Json<Vec<EdgeAsset>>, AppError> {
// Validar acceso al proyecto
let user_id = Uuid::parse_str(&claims.sub)
.map_err(|_| AppError::BadRequest("User ID inválido".to_string()))?;
let access = sqlx::query("SELECT 1 FROM user_project_access WHERE user_id = $1 AND project_id = $2 AND is_active = true")
.bind(user_id)
.bind(project_id)
.fetch_optional(&pool)
.await?;
if access.is_none() {
return Err(AppError::Forbidden("Sin acceso a los activos de este proyecto".to_string()));
}
let assets = sqlx::query_as::<_, EdgeAsset>( let assets = sqlx::query_as::<_, EdgeAsset>(
"SELECT * FROM edge_assets WHERE project_id = $1 ORDER BY created_at DESC" "SELECT * FROM edge_assets WHERE project_id = $1 ORDER BY created_at DESC"
) )
.bind(project_id) .bind(project_id)
.fetch_all(&pool) .fetch_all(&pool)
.await .await?;
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?;
Ok(Json(assets)) Ok(Json(assets))
} }
@@ -37,8 +54,23 @@ pub async fn list_assets_by_project(
/// POST /api/edge/assets — Crear un activo. /// POST /api/edge/assets — Crear un activo.
pub async fn create_asset( pub async fn create_asset(
State(pool): State<PgPool>, State(pool): State<PgPool>,
claims: Claims,
Json(req): Json<CreateEdgeAssetRequest>, Json(req): Json<CreateEdgeAssetRequest>,
) -> Result<(StatusCode, Json<EdgeAsset>), (StatusCode, String)> { ) -> Result<(StatusCode, Json<EdgeAsset>), AppError> {
// Validar acceso al proyecto donde se crea el activo
let user_id = Uuid::parse_str(&claims.sub)
.map_err(|_| AppError::BadRequest("User ID inválido".to_string()))?;
let access = sqlx::query("SELECT 1 FROM user_project_access WHERE user_id = $1 AND project_id = $2 AND is_active = true")
.bind(user_id)
.bind(req.project_id)
.fetch_optional(&pool)
.await?;
if access.is_none() {
return Err(AppError::Forbidden("Sin permiso para crear activos en este proyecto".to_string()));
}
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)
VALUES ($1, $2, $3, $4, $5) VALUES ($1, $2, $3, $4, $5)
@@ -50,8 +82,7 @@ pub async fn create_asset(
.bind(req.asset_type) .bind(req.asset_type)
.bind(req.metadata.unwrap_or(serde_json::json!({}))) .bind(req.metadata.unwrap_or(serde_json::json!({})))
.fetch_one(&pool) .fetch_one(&pool)
.await .await?;
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?;
Ok((StatusCode::CREATED, Json(asset))) Ok((StatusCode::CREATED, Json(asset)))
} }
@@ -59,23 +90,60 @@ pub async fn create_asset(
/// GET /api/edge/assets/:id /// GET /api/edge/assets/:id
pub async fn get_asset( pub async fn get_asset(
State(pool): State<PgPool>, State(pool): State<PgPool>,
claims: Claims,
Path(id): Path<Uuid>, Path(id): Path<Uuid>,
) -> Result<Json<EdgeAsset>, (StatusCode, String)> { ) -> Result<Json<EdgeAsset>, AppError> {
let asset = sqlx::query_as::<_, EdgeAsset>("SELECT * FROM edge_assets WHERE id = $1") let asset = sqlx::query_as::<_, EdgeAsset>("SELECT * FROM edge_assets WHERE id = $1")
.bind(id) .bind(id)
.fetch_optional(&pool) .fetch_optional(&pool)
.await .await?
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))? .ok_or(AppError::NotFound("Activo".to_string()))?;
.ok_or((StatusCode::NOT_FOUND, "Asset not found".to_string()))?;
// Validar acceso al proyecto del activo
let user_id = Uuid::parse_str(&claims.sub)
.map_err(|_| AppError::BadRequest("User ID inválido".to_string()))?;
let access = sqlx::query("SELECT 1 FROM user_project_access WHERE user_id = $1 AND project_id = $2 AND is_active = true")
.bind(user_id)
.bind(asset.project_id)
.fetch_optional(&pool)
.await?;
if access.is_none() {
return Err(AppError::Forbidden("Sin acceso a este activo".to_string()));
}
Ok(Json(asset)) Ok(Json(asset))
} }
/// PUT /api/edge/assets/:id /// PUT /api/edge/assets/:id
pub async fn update_asset( pub async fn update_asset(
State(pool): State<PgPool>, State(pool): State<PgPool>,
claims: Claims,
Path(id): Path<Uuid>, Path(id): Path<Uuid>,
Json(req): Json<UpdateAssetRequest>, Json(req): Json<UpdateAssetRequest>,
) -> Result<Json<EdgeAsset>, (StatusCode, String)> { ) -> Result<Json<EdgeAsset>, AppError> {
// Primero obtenemos el activo para saber a qué proyecto pertenece
let asset = sqlx::query_as::<_, EdgeAsset>("SELECT * FROM edge_assets WHERE id = $1")
.bind(id)
.fetch_optional(&pool)
.await?
.ok_or(AppError::NotFound("Activo".to_string()))?;
// Validar acceso
let user_id = Uuid::parse_str(&claims.sub)
.map_err(|_| AppError::BadRequest("User ID inválido".to_string()))?;
let access = sqlx::query("SELECT 1 FROM user_project_access WHERE user_id = $1 AND project_id = $2 AND is_active = true")
.bind(user_id)
.bind(asset.project_id)
.fetch_optional(&pool)
.await?;
if access.is_none() {
return Err(AppError::Forbidden("Sin permiso para actualizar este activo".to_string()));
}
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, updated_at = NOW()
@@ -87,35 +155,46 @@ pub async fn update_asset(
.bind(req.metadata.unwrap_or(serde_json::json!({}))) .bind(req.metadata.unwrap_or(serde_json::json!({})))
.bind(id) .bind(id)
.fetch_optional(&pool) .fetch_optional(&pool)
.await .await?
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))? .ok_or(AppError::NotFound("Activo".to_string()))?;
.ok_or((StatusCode::NOT_FOUND, "Asset not found".to_string()))?;
Ok(Json(updated)) Ok(Json(updated))
} }
/// GET /api/tanks — List tanks (edge_assets WHERE asset_type = 'TANQUE') for a project.
#[derive(serde::Deserialize)] #[derive(serde::Deserialize)]
pub struct TankQueryParams { pub struct TankQueryParams {
pub project_id: Option<Uuid>, pub project_id: Option<Uuid>,
} }
/// GET /api/tanks — List tanks (edge_assets WHERE asset_type = 'TANQUE') for a project.
pub async fn list_tanks_by_project( pub async fn list_tanks_by_project(
State(pool): State<PgPool>, State(pool): State<PgPool>,
claims: Claims,
Query(params): Query<TankQueryParams>, Query(params): Query<TankQueryParams>,
) -> Result<Json<Vec<EdgeAsset>>, (StatusCode, String)> { ) -> Result<Json<Vec<EdgeAsset>>, AppError> {
let project_id = params.project_id.ok_or(( let project_id = params.project_id
StatusCode::BAD_REQUEST, .ok_or(AppError::BadRequest("project_id es obligatorio".to_string()))?;
"project_id query parameter is required".to_string(),
))?; // Validar acceso al proyecto
let user_id = Uuid::parse_str(&claims.sub)
.map_err(|_| AppError::BadRequest("User ID inválido".to_string()))?;
let access = sqlx::query("SELECT 1 FROM user_project_access WHERE user_id = $1 AND project_id = $2 AND is_active = true")
.bind(user_id)
.bind(project_id)
.fetch_optional(&pool)
.await?;
if access.is_none() {
return Err(AppError::Forbidden("Sin acceso a los tanques de este proyecto".to_string()));
}
let tanks = sqlx::query_as::<_, EdgeAsset>( let tanks = sqlx::query_as::<_, EdgeAsset>(
"SELECT * FROM edge_assets WHERE asset_type = 'TANQUE' AND project_id = $1 ORDER BY created_at DESC" "SELECT * FROM edge_assets WHERE asset_type = 'TANQUE' AND project_id = $1 ORDER BY created_at DESC"
) )
.bind(project_id) .bind(project_id)
.fetch_all(&pool) .fetch_all(&pool)
.await .await?;
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?;
Ok(Json(tanks)) Ok(Json(tanks))
} }
@@ -123,12 +202,34 @@ pub async fn list_tanks_by_project(
/// DELETE /api/edge/assets/:id /// DELETE /api/edge/assets/:id
pub async fn delete_asset( pub async fn delete_asset(
State(pool): State<PgPool>, State(pool): State<PgPool>,
claims: Claims,
Path(id): Path<Uuid>, Path(id): Path<Uuid>,
) -> Result<StatusCode, (StatusCode, String)> { ) -> Result<StatusCode, AppError> {
// Primero obtenemos el activo para saber a qué proyecto pertenece
let asset = sqlx::query_as::<_, EdgeAsset>("SELECT * FROM edge_assets WHERE id = $1")
.bind(id)
.fetch_optional(&pool)
.await?
.ok_or(AppError::NotFound("Activo".to_string()))?;
// Validar acceso
let user_id = Uuid::parse_str(&claims.sub)
.map_err(|_| AppError::BadRequest("User ID inválido".to_string()))?;
let access = sqlx::query("SELECT 1 FROM user_project_access WHERE user_id = $1 AND project_id = $2 AND is_active = true")
.bind(user_id)
.bind(asset.project_id)
.fetch_optional(&pool)
.await?;
if access.is_none() {
return Err(AppError::Forbidden("Sin permiso para eliminar este activo".to_string()));
}
sqlx::query("DELETE FROM edge_assets WHERE id = $1") sqlx::query("DELETE FROM edge_assets WHERE id = $1")
.bind(id) .bind(id)
.execute(&pool) .execute(&pool)
.await .await?;
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?;
Ok(StatusCode::NO_CONTENT) Ok(StatusCode::NO_CONTENT)
} }

View File

@@ -11,14 +11,28 @@ 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;
/// GET /api/edge/projects/:project_id/deployments /// GET /api/edge/projects/:project_id/deployments
/// ///
/// Retorna los últimos 50 registros de despliegue para un proyecto específico. /// Retorna los últimos 50 registros de despliegue para un proyecto específico.
pub async fn list_deployments( pub async fn list_deployments(
State(pool): State<PgPool>, State(pool): State<PgPool>,
claims: Claims,
Path(project_id): Path<Uuid>, Path(project_id): Path<Uuid>,
) -> Result<Json<Vec<EdgeDeployment>>, (StatusCode, String)> { ) -> Result<Json<Vec<EdgeDeployment>>, (StatusCode, String)> {
// Validar acceso al proyecto
let access = sqlx::query("SELECT 1 FROM user_project_access WHERE user_id = $1 AND project_id = $2 AND is_active = true")
.bind(Uuid::parse_str(&claims.sub).unwrap())
.bind(project_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 these project deployments".to_string()));
}
let deployments = sqlx::query_as::<_, EdgeDeployment>( let deployments = sqlx::query_as::<_, EdgeDeployment>(
"SELECT * FROM edge_deployments WHERE project_id = $1 ORDER BY created_at DESC LIMIT 50", "SELECT * FROM edge_deployments WHERE project_id = $1 ORDER BY created_at DESC LIMIT 50",
) )
@@ -37,8 +51,20 @@ pub async fn list_deployments(
pub async fn trigger_build( pub async fn trigger_build(
State(pool): State<PgPool>, State(pool): State<PgPool>,
State(mqtt): State<rumqttc::AsyncClient>, State(mqtt): State<rumqttc::AsyncClient>,
claims: Claims,
Path(project_id): Path<Uuid>, Path(project_id): Path<Uuid>,
) -> Result<(StatusCode, Json<EdgeDeployment>), (StatusCode, String)> { ) -> Result<(StatusCode, Json<EdgeDeployment>), (StatusCode, String)> {
// Validar acceso al proyecto (Solo dueños autorizados pueden disparar builds)
let access = sqlx::query("SELECT 1 FROM user_project_access WHERE user_id = $1 AND project_id = $2 AND is_active = true")
.bind(Uuid::parse_str(&claims.sub).unwrap())
.bind(project_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 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")
.bind(project_id) .bind(project_id)

View File

@@ -8,12 +8,32 @@ use axum::{
use sqlx::PgPool; use sqlx::PgPool;
use uuid::Uuid; use uuid::Uuid;
use shared_lib::edge_models::*; use shared_lib::edge_models::*;
use crate::auth::Claims;
/// GET /api/edge/assets/:asset_id/devices — Listar dispositivos de un activo. /// GET /api/edge/assets/:asset_id/devices — Listar dispositivos de un activo.
pub async fn list_devices( pub async fn list_devices(
State(pool): State<PgPool>, State(pool): State<PgPool>,
claims: Claims,
Path(asset_id): Path<Uuid>, Path(asset_id): Path<Uuid>,
) -> Result<Json<Vec<EdgeDevice>>, (StatusCode, String)> { ) -> Result<Json<Vec<EdgeDevice>>, (StatusCode, String)> {
// Validar acceso al proyecto vía el activo
if claims.role != "admin" {
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(asset_id)
.bind(Uuid::parse_str(&claims.sub).unwrap())
.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 this asset's devices".to_string()));
}
}
let devices = sqlx::query_as::<_, EdgeDevice>( let devices = sqlx::query_as::<_, EdgeDevice>(
"SELECT * FROM edge_devices WHERE asset_id = $1 ORDER BY name" "SELECT * FROM edge_devices WHERE asset_id = $1 ORDER BY name"
) )
@@ -28,9 +48,28 @@ pub async fn list_devices(
/// POST /api/edge/assets/:asset_id/devices — Crear un dispositivo vinculado a un activo. /// POST /api/edge/assets/:asset_id/devices — Crear un dispositivo vinculado a un activo.
pub async fn create_device( pub async fn create_device(
State(pool): State<PgPool>, State(pool): State<PgPool>,
claims: Claims,
Path(asset_id): Path<Uuid>, Path(asset_id): Path<Uuid>,
Json(req): Json<CreateDeviceRequest>, Json(req): Json<CreateDeviceRequest>,
) -> Result<(StatusCode, Json<EdgeDevice>), (StatusCode, String)> { ) -> Result<(StatusCode, Json<EdgeDevice>), (StatusCode, String)> {
// Validar acceso al proyecto vía el activo
if claims.role != "admin" {
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(asset_id)
.bind(Uuid::parse_str(&claims.sub).unwrap())
.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 create devices for this asset".to_string()));
}
}
let device = sqlx::query_as::<_, EdgeDevice>( let device = sqlx::query_as::<_, EdgeDevice>(
r#"INSERT INTO edge_devices (asset_id, name, protocol, host, port, protocol_config) r#"INSERT INTO edge_devices (asset_id, name, protocol, host, port, protocol_config)
VALUES ($1, $2, $3, $4, $5, $6) VALUES ($1, $2, $3, $4, $5, $6)
@@ -52,9 +91,29 @@ pub async fn create_device(
/// PUT /api/edge/devices/:id — Actualizar un dispositivo. /// PUT /api/edge/devices/:id — Actualizar un dispositivo.
pub async fn update_device( pub async fn update_device(
State(pool): State<PgPool>, State(pool): State<PgPool>,
claims: Claims,
Path(id): Path<Uuid>, Path(id): Path<Uuid>,
Json(req): Json<UpdateDeviceRequest>, Json(req): Json<UpdateDeviceRequest>,
) -> Result<Json<EdgeDevice>, (StatusCode, String)> { ) -> Result<Json<EdgeDevice>, (StatusCode, String)> {
// Validar acceso al proyecto vía el dispositivo -> activo
if claims.role != "admin" {
let access = sqlx::query(
r#"SELECT 1 FROM edge_devices ed
JOIN edge_assets ea ON ed.asset_id = ea.id
JOIN user_project_access upa ON ea.project_id = upa.project_id
WHERE ed.id = $1 AND upa.user_id = $2 AND upa.is_active = true"#
)
.bind(id)
.bind(Uuid::parse_str(&claims.sub).unwrap())
.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 this device".to_string()));
}
}
let device = sqlx::query_as::<_, EdgeDevice>( let device = sqlx::query_as::<_, EdgeDevice>(
r#"UPDATE edge_devices SET r#"UPDATE edge_devices SET
name = COALESCE($2, name), name = COALESCE($2, name),
@@ -84,8 +143,28 @@ pub async fn update_device(
/// DELETE /api/edge/devices/:id — Eliminar un dispositivo. /// DELETE /api/edge/devices/:id — Eliminar un dispositivo.
pub async fn delete_device( pub async fn delete_device(
State(pool): State<PgPool>, State(pool): State<PgPool>,
claims: Claims,
Path(id): Path<Uuid>, Path(id): Path<Uuid>,
) -> Result<StatusCode, (StatusCode, String)> { ) -> Result<StatusCode, (StatusCode, String)> {
// Validar acceso
if claims.role != "admin" {
let access = sqlx::query(
r#"SELECT 1 FROM edge_devices ed
JOIN edge_assets ea ON ed.asset_id = ea.id
JOIN user_project_access upa ON ea.project_id = upa.project_id
WHERE ed.id = $1 AND upa.user_id = $2 AND upa.is_active = true"#
)
.bind(id)
.bind(Uuid::parse_str(&claims.sub).unwrap())
.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 delete this device".to_string()));
}
}
let result = sqlx::query("DELETE FROM edge_devices WHERE id = $1") let result = sqlx::query("DELETE FROM edge_devices WHERE id = $1")
.bind(id) .bind(id)
.execute(&pool) .execute(&pool)

View File

@@ -6,18 +6,25 @@ use axum::{
http::StatusCode, http::StatusCode,
}; };
use shared_lib::edge_models::*; use shared_lib::edge_models::*;
use crate::auth::Claims;
use sqlx::PgPool; use sqlx::PgPool;
use uuid::Uuid; use uuid::Uuid;
/// 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(
State(pool): State<PgPool>, State(pool): State<PgPool>,
claims: Claims,
) -> Result<Json<Vec<EdgeProject>>, (StatusCode, String)> { ) -> Result<Json<Vec<EdgeProject>>, (StatusCode, String)> {
let projects = let projects = sqlx::query_as::<_, EdgeProject>(
sqlx::query_as::<_, EdgeProject>("SELECT * FROM edge_projects ORDER BY created_at DESC") r#"SELECT p.* FROM edge_projects p
.fetch_all(&pool) JOIN user_project_access upa ON p.id = upa.project_id
.await WHERE upa.user_id = $1 AND upa.is_active = true
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?; ORDER BY p.created_at DESC"#,
)
.bind(Uuid::parse_str(&claims.sub).map_err(|_| (StatusCode::UNAUTHORIZED, "Invalid user ID".to_string()))?)
.fetch_all(&pool)
.await
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?;
Ok(Json(projects)) Ok(Json(projects))
} }
@@ -26,8 +33,15 @@ pub async fn list_projects(
pub async fn create_project( pub async fn create_project(
State(pool): State<PgPool>, State(pool): State<PgPool>,
State(mqtt): State<rumqttc::AsyncClient>, State(mqtt): State<rumqttc::AsyncClient>,
claims: Claims,
Json(req): Json<CreateProjectRequest>, Json(req): Json<CreateProjectRequest>,
) -> Result<(StatusCode, Json<EdgeProject>), (StatusCode, String)> { ) -> Result<(StatusCode, Json<EdgeProject>), (StatusCode, String)> {
// Solo admins pueden crear proyectos globales
if claims.role != "admin" {
return Err((StatusCode::FORBIDDEN, "Only admins can create projects".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, description, metadata)
VALUES ($1, $2, $3, $4) VALUES ($1, $2, $3, $4)
@@ -37,10 +51,24 @@ pub async fn create_project(
.bind(&req.client) .bind(&req.client)
.bind(&req.description) .bind(&req.description)
.bind(req.metadata.unwrap_or(serde_json::json!({}))) .bind(req.metadata.unwrap_or(serde_json::json!({})))
.fetch_one(&pool) .fetch_one(&mut *tx)
.await .await
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?; .map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?;
// Autolink al creador as owner
let user_id = Uuid::parse_str(&claims.sub).map_err(|_| (StatusCode::UNAUTHORIZED, "Invalid user ID in token".to_string()))?;
sqlx::query(
"INSERT INTO user_project_access (user_id, project_id, project_role) VALUES ($1, $2, 'owner')"
)
.bind(user_id)
.bind(project.id)
.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()))?;
// ─── 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,
@@ -50,11 +78,19 @@ pub async fn create_project(
}; };
if let Ok(payload) = serde_json::to_string(&event) { if let Ok(payload) = serde_json::to_string(&event) {
tracing::info!("📡 Publishing Domain Event via MQTT: project_created ({})", project.id); tracing::info!(
"📡 Publishing Domain Event via MQTT: project_created ({})",
project.id
);
let mqtt_clone = mqtt.clone(); let mqtt_clone = mqtt.clone();
tokio::spawn(async move { tokio::spawn(async move {
if let Err(e) = mqtt_clone if let Err(e) = mqtt_clone
.publish("omnioil/events/project_created", rumqttc::QoS::AtLeastOnce, false, payload) .publish(
"omnioil/events/project_created",
rumqttc::QoS::AtLeastOnce,
false,
payload,
)
.await .await
{ {
tracing::error!("Failed to publish ProjectCreatedEvent to MQTT: {:?}", e); tracing::error!("Failed to publish ProjectCreatedEvent to MQTT: {:?}", e);
@@ -68,8 +104,23 @@ pub async fn create_project(
/// GET /api/edge/projects/:id — Detalle de un proyecto. /// GET /api/edge/projects/:id — Detalle de un proyecto.
pub async fn get_project( pub async fn get_project(
State(pool): State<PgPool>, State(pool): State<PgPool>,
claims: Claims,
Path(id): Path<Uuid>, Path(id): Path<Uuid>,
) -> Result<Json<EdgeProject>, (StatusCode, String)> { ) -> Result<Json<EdgeProject>, (StatusCode, String)> {
// 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")
.bind(Uuid::parse_str(&claims.sub).unwrap())
.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 this project".to_string()));
}
}
let project = sqlx::query_as::<_, EdgeProject>("SELECT * FROM edge_projects WHERE id = $1") let project = sqlx::query_as::<_, EdgeProject>("SELECT * FROM edge_projects WHERE id = $1")
.bind(id) .bind(id)
.fetch_optional(&pool) .fetch_optional(&pool)
@@ -83,9 +134,22 @@ pub async fn get_project(
/// PUT /api/edge/projects/:id — Actualizar un proyecto. /// PUT /api/edge/projects/:id — Actualizar un proyecto.
pub async fn update_project( pub async fn update_project(
State(pool): State<PgPool>, State(pool): State<PgPool>,
claims: Claims,
Path(id): Path<Uuid>, Path(id): Path<Uuid>,
Json(req): Json<UpdateProjectRequest>, Json(req): Json<UpdateProjectRequest>,
) -> Result<Json<EdgeProject>, (StatusCode, String)> { ) -> Result<Json<EdgeProject>, (StatusCode, String)> {
// Validar acceso antes de actualizar
let access = sqlx::query("SELECT 1 FROM user_project_access WHERE user_id = $1 AND project_id = $2 AND is_active = true")
.bind(Uuid::parse_str(&claims.sub).unwrap())
.bind(id)
.fetch_optional(&pool)
.await
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?;
if access.is_none() {
return Err((StatusCode::FORBIDDEN, "Only project owners or authorized admins can update project details".to_string()));
}
let project = sqlx::query_as::<_, EdgeProject>( let project = sqlx::query_as::<_, EdgeProject>(
r#"UPDATE edge_projects SET r#"UPDATE edge_projects SET
name = COALESCE($2, name), name = COALESCE($2, name),
@@ -113,8 +177,21 @@ pub async fn update_project(
/// DELETE /api/edge/projects/:id — Eliminar un proyecto. /// DELETE /api/edge/projects/:id — Eliminar un proyecto.
pub async fn delete_project( pub async fn delete_project(
State(pool): State<PgPool>, State(pool): State<PgPool>,
claims: Claims,
Path(id): Path<Uuid>, Path(id): Path<Uuid>,
) -> Result<StatusCode, (StatusCode, String)> { ) -> Result<StatusCode, (StatusCode, String)> {
// Validar acceso antes de eliminar
let access = sqlx::query("SELECT 1 FROM user_project_access WHERE user_id = $1 AND project_id = $2 AND is_active = true")
.bind(Uuid::parse_str(&claims.sub).unwrap())
.bind(id)
.fetch_optional(&pool)
.await
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?;
if access.is_none() {
return Err((StatusCode::FORBIDDEN, "Only project owners can delete projects".to_string()));
}
let result = sqlx::query("DELETE FROM edge_projects WHERE id = $1") let result = sqlx::query("DELETE FROM edge_projects WHERE id = $1")
.bind(id) .bind(id)
.execute(&pool) .execute(&pool)
@@ -131,8 +208,22 @@ pub async fn delete_project(
/// GET /api/edge/projects/:id/full — Configuración completa del proyecto (Jerarquía: Project -> Asset -> Device -> Variable). /// GET /api/edge/projects/:id/full — Configuración completa del proyecto (Jerarquía: Project -> Asset -> Device -> Variable).
pub async fn get_project_full_config( pub async fn get_project_full_config(
State(pool): State<PgPool>, State(pool): State<PgPool>,
claims: Claims,
Path(id): Path<Uuid>, Path(id): Path<Uuid>,
) -> Result<Json<ProjectConfig>, (StatusCode, String)> { ) -> Result<Json<ProjectConfig>, (StatusCode, String)> {
// 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")
.bind(Uuid::parse_str(&claims.sub).unwrap())
.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 this project".to_string()));
}
}
use shared_lib::models::EdgeAsset; use shared_lib::models::EdgeAsset;
let project = sqlx::query_as::<_, EdgeProject>("SELECT * FROM edge_projects WHERE id = $1") let project = sqlx::query_as::<_, EdgeProject>("SELECT * FROM edge_projects WHERE id = $1")
@@ -185,3 +276,118 @@ pub async fn get_project_full_config(
assets: asset_configs, assets: asset_configs,
})) }))
} }
use axum::http::header;
use axum::response::{IntoResponse, Response};
/// GET /api/edge/projects/:id/installer — Stream minio MSI through backend
pub async fn download_installer(
State(pool): State<PgPool>,
claims: Claims,
Path(id): Path<Uuid>,
) -> Result<Response, (StatusCode, String)> {
// Validar acceso (Solo si el proyecto le pertenece)
let access = sqlx::query("SELECT 1 FROM user_project_access WHERE user_id = $1 AND project_id = $2 AND is_active = true")
.bind(Uuid::parse_str(&claims.sub).unwrap())
.bind(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 this project installer".to_string()));
}
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 installer_url = project.installer_url.as_ref().ok_or((
StatusCode::NOT_FOUND,
"Installer not generated yet".to_string(),
))?;
// Asumimos que installer_url es algo como "http://localhost:9000/installers/edge-agent-OmniOil-358cee99.msi"
let path_segments: Vec<&str> = installer_url.split("installers/").collect();
if path_segments.len() < 2 {
return Err((
StatusCode::INTERNAL_SERVER_ERROR,
"Invalid bucket path".to_string(),
));
}
let bucket_name = "installers";
let key_name = path_segments[1];
// Dado que el Backend hace el fetch, usamos el dominio interno de Docker, no el publico!
let endpoint_url =
std::env::var("MINIO_ENDPOINT_URL").unwrap_or_else(|_| "http://minio:9000".to_string());
// Configurar credenciales explícitas
let access_key = std::env::var("AWS_ACCESS_KEY_ID")
.or_else(|_| std::env::var("MINIO_USER"))
.unwrap_or_else(|_| "admin_s3".to_string());
let secret_key = std::env::var("AWS_SECRET_ACCESS_KEY")
.or_else(|_| std::env::var("MINIO_PASSWORD"))
.unwrap_or_else(|_| "otra_clave_segura_minio".to_string());
let credentials = aws_sdk_s3::config::Credentials::new(
access_key,
secret_key,
None,
None,
"FromEnvOrFallback",
);
let config = aws_config::defaults(aws_config::BehaviorVersion::latest())
.region(aws_sdk_s3::config::Region::new("us-east-1"))
.endpoint_url(&endpoint_url)
.credentials_provider(credentials)
.load()
.await;
let s3_config = aws_sdk_s3::config::Builder::from(&config)
.force_path_style(true)
.build();
let client = aws_sdk_s3::Client::from_conf(s3_config);
tracing::info!("📥 Intentando descargar desde S3 bucket: {}, key: {} en {}", bucket_name, key_name, endpoint_url);
let get_res = client
.get_object()
.bucket(bucket_name)
.key(key_name)
.send()
.await;
let get_req = match get_res {
Ok(res) => res,
Err(e) => {
tracing::error!("❌ Error de S3 al recuperar archivo (bucket: {}, key: {}): {:?}", bucket_name, key_name, e);
return Err((
StatusCode::INTERNAL_SERVER_ERROR,
format!("Failed to retrieve file from S3 ({}): {}", key_name, e),
));
}
};
let reader = get_req.body.into_async_read();
let stream = tokio_util::io::ReaderStream::new(reader);
let body = axum::body::Body::from_stream(stream);
let mut response = body.into_response();
response.headers_mut().insert(
header::CONTENT_TYPE,
header::HeaderValue::from_static("application/octet-stream"),
);
let content_disposition = format!("attachment; filename=\"{}\"", key_name);
if let Ok(value) = header::HeaderValue::from_str(&content_disposition) {
response
.headers_mut()
.insert(header::CONTENT_DISPOSITION, value);
}
Ok(response)
}

View File

@@ -8,19 +8,43 @@ use axum::{
use sqlx::PgPool; use sqlx::PgPool;
use uuid::Uuid; use uuid::Uuid;
use shared_lib::edge_models::*; use shared_lib::edge_models::*;
use crate::auth::Claims;
use crate::errors::AppError;
/// GET /api/edge/devices/:device_id/variables — Listar variables de un dispositivo. /// GET /api/edge/devices/:device_id/variables — Listar variables de un dispositivo.
pub async fn list_variables( pub async fn list_variables(
State(pool): State<PgPool>, State(pool): State<PgPool>,
claims: Claims,
Path(device_id): Path<Uuid>, Path(device_id): Path<Uuid>,
) -> Result<Json<Vec<EdgeVariable>>, (StatusCode, String)> { ) -> Result<Json<Vec<EdgeVariable>>, AppError> {
// Validar acceso jerárquico
if claims.role != "admin" {
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_devices ed
JOIN edge_assets ea ON ed.asset_id = ea.id
JOIN user_project_access upa ON ea.project_id = upa.project_id
WHERE ed.id = $1 AND upa.user_id = $2 AND upa.is_active = true"#
)
.bind(device_id)
.bind(user_id)
.fetch_optional(&pool)
.await?;
if access.is_none() {
return Err(AppError::Forbidden("Sin acceso a las variables de este dispositivo".to_string()));
}
}
let variables = sqlx::query_as::<_, EdgeVariable>( let variables = sqlx::query_as::<_, EdgeVariable>(
"SELECT * FROM edge_variables WHERE device_id = $1 ORDER BY alias" "SELECT * FROM edge_variables WHERE device_id = $1 ORDER BY alias"
) )
.bind(device_id) .bind(device_id)
.fetch_all(&pool) .fetch_all(&pool)
.await .await?;
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?;
Ok(Json(variables)) Ok(Json(variables))
} }
@@ -28,9 +52,31 @@ pub async fn list_variables(
/// POST /api/edge/devices/:device_id/variables — Crear una variable. /// POST /api/edge/devices/:device_id/variables — Crear una variable.
pub async fn create_variable( pub async fn create_variable(
State(pool): State<PgPool>, State(pool): State<PgPool>,
claims: Claims,
Path(device_id): Path<Uuid>, Path(device_id): Path<Uuid>,
Json(req): Json<CreateVariableRequest>, Json(req): Json<CreateVariableRequest>,
) -> Result<(StatusCode, Json<EdgeVariable>), (StatusCode, String)> { ) -> Result<(StatusCode, Json<EdgeVariable>), AppError> {
// Validar acceso
if claims.role != "admin" {
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_devices ed
JOIN edge_assets ea ON ed.asset_id = ea.id
JOIN user_project_access upa ON ea.project_id = upa.project_id
WHERE ed.id = $1 AND upa.user_id = $2 AND upa.is_active = true"#
)
.bind(device_id)
.bind(user_id)
.fetch_optional(&pool)
.await?;
if access.is_none() {
return Err(AppError::Forbidden("Sin permiso para crear variables en este dispositivo".to_string()));
}
}
let variable = sqlx::query_as::<_, EdgeVariable>( let variable = sqlx::query_as::<_, EdgeVariable>(
r#"INSERT INTO edge_variables ( r#"INSERT INTO edge_variables (
device_id, address, data_type, alias, unit, device_id, address, data_type, alias, unit,
@@ -52,8 +98,7 @@ pub async fn create_variable(
.bind(req.byte_order.as_deref().unwrap_or("BE")) .bind(req.byte_order.as_deref().unwrap_or("BE"))
.bind(req.metadata.clone().unwrap_or(serde_json::json!({}))) .bind(req.metadata.clone().unwrap_or(serde_json::json!({})))
.fetch_one(&pool) .fetch_one(&pool)
.await .await?;
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?;
Ok((StatusCode::CREATED, Json(variable))) Ok((StatusCode::CREATED, Json(variable)))
} }
@@ -61,9 +106,32 @@ pub async fn create_variable(
/// PUT /api/edge/variables/:id — Actualizar una variable. /// PUT /api/edge/variables/:id — Actualizar una variable.
pub async fn update_variable( pub async fn update_variable(
State(pool): State<PgPool>, State(pool): State<PgPool>,
claims: Claims,
Path(id): Path<Uuid>, Path(id): Path<Uuid>,
Json(req): Json<UpdateVariableRequest>, Json(req): Json<UpdateVariableRequest>,
) -> Result<Json<EdgeVariable>, (StatusCode, String)> { ) -> Result<Json<EdgeVariable>, AppError> {
// Validar acceso jerárquico total
if claims.role != "admin" {
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_variables ev
JOIN edge_devices ed ON ev.device_id = ed.id
JOIN edge_assets ea ON ed.asset_id = ea.id
JOIN user_project_access upa ON ea.project_id = upa.project_id
WHERE ev.id = $1 AND upa.user_id = $2 AND upa.is_active = true"#
)
.bind(id)
.bind(user_id)
.fetch_optional(&pool)
.await?;
if access.is_none() {
return Err(AppError::Forbidden("Sin acceso a esta variable".to_string()));
}
}
let variable = sqlx::query_as::<_, EdgeVariable>( let variable = sqlx::query_as::<_, EdgeVariable>(
r#"UPDATE edge_variables SET r#"UPDATE edge_variables SET
address = COALESCE($2, address), address = COALESCE($2, address),
@@ -93,9 +161,8 @@ pub async fn update_variable(
.bind(&req.metadata) .bind(&req.metadata)
.bind(req.is_enabled) .bind(req.is_enabled)
.fetch_optional(&pool) .fetch_optional(&pool)
.await .await?
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))? .ok_or(AppError::NotFound("Variable".to_string()))?;
.ok_or((StatusCode::NOT_FOUND, "Variable not found".to_string()))?;
Ok(Json(variable)) Ok(Json(variable))
} }
@@ -103,16 +170,38 @@ pub async fn update_variable(
/// DELETE /api/edge/variables/:id — Eliminar una variable. /// DELETE /api/edge/variables/:id — Eliminar una variable.
pub async fn delete_variable( pub async fn delete_variable(
State(pool): State<PgPool>, State(pool): State<PgPool>,
claims: Claims,
Path(id): Path<Uuid>, Path(id): Path<Uuid>,
) -> Result<StatusCode, (StatusCode, String)> { ) -> Result<StatusCode, AppError> {
// Validar acceso
if claims.role != "admin" {
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_variables ev
JOIN edge_devices ed ON ev.device_id = ed.id
JOIN edge_assets ea ON ed.asset_id = ea.id
JOIN user_project_access upa ON ea.project_id = upa.project_id
WHERE ev.id = $1 AND upa.user_id = $2 AND upa.is_active = true"#
)
.bind(id)
.bind(user_id)
.fetch_optional(&pool)
.await?;
if access.is_none() {
return Err(AppError::Forbidden("Sin permiso para eliminar esta variable".to_string()));
}
}
let result = sqlx::query("DELETE FROM edge_variables WHERE id = $1") let result = sqlx::query("DELETE FROM edge_variables WHERE id = $1")
.bind(id) .bind(id)
.execute(&pool) .execute(&pool)
.await .await?;
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?;
if result.rows_affected() == 0 { if result.rows_affected() == 0 {
return Err((StatusCode::NOT_FOUND, "Variable not found".to_string())); return Err(AppError::NotFound("Variable".to_string()));
} }
Ok(StatusCode::NO_CONTENT) Ok(StatusCode::NO_CONTENT)

View File

@@ -1,9 +1,10 @@
pub mod auth; pub mod auth;
pub mod dashboard; pub mod dashboard;
pub mod edge_projects;
pub mod edge_assets;
pub mod edge_devices;
pub mod edge_variables;
pub mod edge_deployments;
pub mod edge_agents; pub mod edge_agents;
pub mod edge_assets;
pub mod edge_deployments;
pub mod edge_devices;
pub mod edge_projects;
pub mod edge_variables;
pub mod operations_movements;
pub mod telemetry; pub mod telemetry;

View File

@@ -0,0 +1,110 @@
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::{MovementType, OperationMovement};
use sqlx::{PgPool, Postgres};
use uuid::Uuid;
#[derive(Debug, Deserialize)]
pub struct CreateMovementRequest {
pub asset_id: Uuid,
pub movement_type: MovementType,
pub start_time: chrono::DateTime<chrono::Utc>,
pub end_time: chrono::DateTime<chrono::Utc>,
pub volumen_neto: Option<BigDecimal>,
pub observaciones: Option<String>,
}
#[derive(Debug, Deserialize)]
pub struct MovementQueryParams {
pub asset_id: Uuid,
}
/// GET /api/movements — Listar movimientos de un activo.
pub async fn list_movements(
State(pool): State<PgPool>,
claims: Claims,
Query(params): Query<MovementQueryParams>,
) -> Result<Json<Vec<OperationMovement>>, AppError> {
// Validar acceso al proyecto vía el activo
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 movimientos de este activo".to_string()));
}
let movements: Vec<OperationMovement> = sqlx::query_as::<Postgres, OperationMovement>(
"SELECT * FROM operation_movements WHERE asset_id = $1 ORDER BY start_time DESC",
)
.bind(params.asset_id)
.fetch_all(&pool)
.await?;
Ok(Json(movements))
}
/// POST /api/movements — Registrar un nuevo movimiento operativo.
pub async fn create_movement(
State(pool): State<PgPool>,
claims: Claims,
Json(req): Json<CreateMovementRequest>,
) -> Result<(StatusCode, Json<OperationMovement>), AppError> {
// Validar acceso al activo
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 movimientos en este activo".to_string()));
}
let details = serde_json::json!({
"observaciones": req.observaciones,
"created_by": claims.sub
});
let movement: OperationMovement = sqlx::query_as::<Postgres, OperationMovement>(
r#"INSERT INTO operation_movements (
asset_id, movement_type, start_time, end_time, volumen_neto, details
)
VALUES ($1, $2, $3, $4, $5, $6)
RETURNING *"#,
)
.bind(req.asset_id)
.bind(req.movement_type)
.bind(req.start_time)
.bind(req.end_time)
.bind(req.volumen_neto)
.bind(details)
.fetch_one(&pool)
.await?;
Ok((StatusCode::CREATED, Json(movement)))
}

View File

@@ -10,6 +10,7 @@ use serde::Deserialize;
use sqlx::PgPool; use sqlx::PgPool;
use uuid::Uuid; use uuid::Uuid;
use shared_lib::models::{PostTelemetryRequest, TelemetryRecord, TelemetryResponse}; use shared_lib::models::{PostTelemetryRequest, TelemetryRecord, TelemetryResponse};
use crate::auth::Claims;
/// POST /api/telemetry — Ingresar una medición de campo. /// POST /api/telemetry — Ingresar una medición de campo.
/// ///
@@ -60,9 +61,25 @@ pub struct TelemetryQueryParams {
/// HTTP 400 automático si `asset_id` no es un UUID válido (Axum extractor). /// HTTP 400 automático si `asset_id` no es un UUID válido (Axum extractor).
pub async fn get_asset_telemetry( pub async fn get_asset_telemetry(
State(pool): State<PgPool>, State(pool): State<PgPool>,
claims: Claims,
Path(asset_id): Path<Uuid>, Path(asset_id): Path<Uuid>,
Query(params): Query<TelemetryQueryParams>, Query(params): Query<TelemetryQueryParams>,
) -> Result<Json<Vec<TelemetryRecord>>, (StatusCode, String)> { ) -> Result<Json<Vec<TelemetryRecord>>, (StatusCode, String)> {
// Validar acceso al proyecto vía el activo
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(asset_id)
.bind(Uuid::parse_str(&claims.sub).unwrap())
.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 this asset's telemetry".to_string()));
}
let limit = params.limit.unwrap_or(10).min(100); let limit = params.limit.unwrap_or(10).min(100);
let records = sqlx::query_as::<_, TelemetryRecord>( let records = sqlx::query_as::<_, TelemetryRecord>(

View File

@@ -1,6 +1,7 @@
mod auth; mod auth;
mod db; mod db;
mod handlers; mod handlers;
mod errors;
use axum::{ use axum::{
Router, Router,
@@ -45,7 +46,60 @@ async fn main() {
tracing::info!("Starting Backend API..."); tracing::info!("Starting Backend API...");
// Conectar al MQTT broker // 1. Conectar a DB
let pool = db::establish_connection()
.await
.expect("Failed to connect to database");
tracing::info!("Connected to database.");
// 2. Correr migraciones automáticamente
sqlx::migrate!("./migrations")
.run(&pool)
.await
.expect("Failed to run database migrations");
tracing::info!("Database migrations applied successfully.");
// 3. Configurar usuarios MQTT dinámicamente desde el entorno antes de conectar
if let (Ok(u), Ok(p)) = (std::env::var("MQTT_USER"), std::env::var("MQTT_PASSWORD")) {
// Asegurar extensión pgcrypto
let _ = sqlx::query("CREATE EXTENSION IF NOT EXISTS pgcrypto")
.execute(&pool)
.await;
// Sincronizar usuario y contraseña (Bcrypt via pgcrypto)
let sync_user = sqlx::query(
r#"
INSERT INTO mqtt_users (username, password_hash, is_admin)
VALUES ($1, crypt($2, gen_salt('bf')), true)
ON CONFLICT (username) DO UPDATE
SET password_hash = crypt($2, gen_salt('bf'))
"#,
)
.bind(&u)
.bind(&p)
.execute(&pool)
.await;
// Asegurar permisos ACL
let sync_acl = sqlx::query(
r#"
INSERT INTO mqtt_acls (username, topic, rw)
VALUES ($1, '#', 3)
ON CONFLICT DO NOTHING
"#,
)
.bind(&u)
.execute(&pool)
.await;
if sync_user.is_ok() && sync_acl.is_ok() {
tracing::info!("MQTT system user '{}' synchronized successfully (DB).", u);
} else {
tracing::error!("Failed to synchronize MQTT user or ACLs");
}
}
// 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").unwrap_or_else(|_| "localhost".to_string());
let mqtt_port: u16 = std::env::var("MQTT_PORT") let mqtt_port: u16 = std::env::var("MQTT_PORT")
.unwrap_or_else(|_| "1883".to_string()) .unwrap_or_else(|_| "1883".to_string())
@@ -55,6 +109,10 @@ async fn main() {
let mut mqtt_options = rumqttc::MqttOptions::new("omnioil-backend-api", &mqtt_host, mqtt_port); let mut mqtt_options = rumqttc::MqttOptions::new("omnioil-backend-api", &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")) {
mqtt_options.set_credentials(u, p);
}
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 // Spawn MQTT event loop para mantener la conexión viva
@@ -67,11 +125,6 @@ async fn main() {
} }
}); });
// Conectar a DB
let pool = db::establish_connection()
.await
.expect("Failed to connect to database");
tracing::info!("Connected to database.");
let state = AppState { pool, mqtt_client }; let state = AppState { pool, mqtt_client };
@@ -93,6 +146,10 @@ async fn main() {
"/projects/{id}/full", "/projects/{id}/full",
get(handlers::edge_projects::get_project_full_config), get(handlers::edge_projects::get_project_full_config),
) )
.route(
"/projects/{id}/installer",
get(handlers::edge_projects::download_installer),
)
// Assets per project // Assets per project
.route( .route(
"/projects/{project_id}/assets", "/projects/{project_id}/assets",
@@ -102,6 +159,7 @@ async fn main() {
.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("/assets/{id}", get(handlers::edge_assets::get_asset))
.route("/assets/{id}", put(handlers::edge_assets::update_asset)) .route("/assets/{id}", put(handlers::edge_assets::update_asset))
.route("/health", get(|| async { "OK" }))
.route("/assets/{id}", delete(handlers::edge_assets::delete_asset)) .route("/assets/{id}", delete(handlers::edge_assets::delete_asset))
// Devices (bajo assets) // Devices (bajo assets)
.route( .route(
@@ -154,23 +212,70 @@ async fn main() {
get(handlers::edge_agents::get_agent_health), get(handlers::edge_agents::get_agent_health),
); );
// Configurar CORS // Configurar CORS (lista separada por comas en ALLOWED_ORIGINS)
let cors = tower_http::cors::CorsLayer::permissive(); use tower_http::cors::AllowOrigin;
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());
let allowed_list: Vec<axum::http::HeaderValue> = allowed_origins_env
.split(',')
.filter_map(|s| axum::http::HeaderValue::from_str(s.trim()).ok())
.collect();
let allow_origin = if allowed_list.is_empty() {
// Fallback para evitar panics: localhost dev
AllowOrigin::list(vec![
axum::http::HeaderValue::from_static("http://localhost:3000"),
])
} else {
AllowOrigin::list(allowed_list)
};
let cors = tower_http::cors::CorsLayer::new()
.allow_origin(allow_origin)
.allow_methods([
axum::http::Method::GET,
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
// causa 500 silencioso dentro de Docker al no poder extraer ConnectInfo.
// Reactivar cuando se migre a una versión compatible o se use un extractor custom.
// use tower_governor::{GovernorLayer, governor::GovernorConfigBuilder};
// Sub-router de auth
let auth_routes = Router::new()
.route("/login", post(handlers::auth::login))
.route("/refresh", post(handlers::auth::refresh))
.route("/register", post(handlers::auth::register));
// Rutas app // Rutas app
let app = Router::new() let app = Router::new()
.route("/", get(root)) .route("/", get(root))
.route("/api/auth/login", post(handlers::auth::login)) .nest("/api/auth", auth_routes)
.route("/api/auth/register", post(handlers::auth::register))
.route( .route(
"/api/dashboard/summary", "/api/dashboard/summary",
get(handlers::dashboard::get_summary), get(handlers::dashboard::get_summary),
) )
// Telemetría de campo // Telemetría de campo
.route("/api/telemetry", post(handlers::telemetry::post_telemetry)) .route("/api/telemetry", post(handlers::telemetry::post_telemetry))
.route("/api/telemetry/{asset_id}", get(handlers::telemetry::get_asset_telemetry)) .route(
"/api/telemetry/{asset_id}",
get(handlers::telemetry::get_asset_telemetry),
)
// Tank inventory // Tank inventory
.route("/api/tanks", get(handlers::edge_assets::list_tanks_by_project)) .route(
"/api/tanks",
get(handlers::edge_assets::list_tanks_by_project),
)
// Movimientos operativos (ANH)
.route("/api/movements", get(handlers::operations_movements::list_movements))
.route("/api/movements", post(handlers::operations_movements::create_movement))
// Edge System API // Edge System API
.nest("/api/edge", edge_routes) .nest("/api/edge", edge_routes)
// Estado compartido y Middleware // Estado compartido y Middleware

View File

@@ -7,7 +7,7 @@ edition = "2024"
shared-lib = { path = "../shared-lib" } shared-lib = { path = "../shared-lib" }
flow-generator = { path = "../flow-generator" } flow-generator = { path = "../flow-generator" }
tokio = { version = "1.50.0", features = ["full"] } tokio = { version = "1.50.0", features = ["full"] }
sqlx = { version = "0.8.6", features = ["runtime-tokio-rustls", "postgres", "uuid", "chrono", "json"] } sqlx = { version = "0.8.6", features = ["runtime-tokio-rustls", "postgres", "uuid", "chrono", "json", "bigdecimal"] }
serde = { version = "1.0.228", features = ["derive"] } serde = { version = "1.0.228", features = ["derive"] }
serde_json = "1.0.149" serde_json = "1.0.149"
uuid = { version = "1.23.0", features = ["v4"] } uuid = { version = "1.23.0", features = ["v4"] }
@@ -26,3 +26,4 @@ bytes = "1.10.0"
http-body-util = "0.1.2" 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"

View File

@@ -16,7 +16,7 @@ ENV CARGO_INCREMENTAL=0
# ============================================================================= # =============================================================================
FROM base AS planner FROM base AS planner
# Copiamos solo los archivos que definen dependencias (workspace y miembros) # Copiamos solo los archivos que definen dependencias (workspace y miembros)
COPY Cargo.toml Cargo.loc[k] ./ COPY Cargo.toml Cargo.lock ./
COPY services/shared-lib/Cargo.toml services/shared-lib/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/flow-generator/Cargo.toml services/flow-generator/Cargo.toml
COPY services/backend-api/Cargo.toml services/backend-api/Cargo.toml COPY services/backend-api/Cargo.toml services/backend-api/Cargo.toml
@@ -25,6 +25,7 @@ COPY services/json-generator/Cargo.toml services/json-generator/Cargo.toml
COPY services/events-relay/Cargo.toml services/events-relay/Cargo.toml COPY services/events-relay/Cargo.toml services/events-relay/Cargo.toml
COPY services/build-orchestrator/Cargo.toml services/build-orchestrator/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/edge-agent/Cargo.toml services/edge-agent/Cargo.toml
COPY services/telemetry-ingestor/Cargo.toml services/telemetry-ingestor/Cargo.toml
# Creamos estructuras dummy para que cargo metadata no falle (muy importante para la caché) # Creamos estructuras dummy para que cargo metadata no falle (muy importante para la caché)
RUN mkdir -p services/shared-lib/src && touch services/shared-lib/src/lib.rs && \ RUN mkdir -p services/shared-lib/src && touch services/shared-lib/src/lib.rs && \
@@ -34,7 +35,8 @@ RUN mkdir -p services/shared-lib/src && touch services/shared-lib/src/lib.rs &&
mkdir -p services/json-generator/src && echo "fn main() {}" > services/json-generator/src/main.rs && \ mkdir -p services/json-generator/src && echo "fn main() {}" > services/json-generator/src/main.rs && \
mkdir -p services/events-relay/src && echo "fn main() {}" > services/events-relay/src/main.rs && \ mkdir -p services/events-relay/src && echo "fn main() {}" > services/events-relay/src/main.rs && \
mkdir -p services/build-orchestrator/src && echo "fn main() {}" > services/build-orchestrator/src/main.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/edge-agent/src && echo "fn main() {}" > services/edge-agent/src/main.rs && \
mkdir -p services/telemetry-ingestor/src && echo "fn main() {}" > services/telemetry-ingestor/src/main.rs
# Generamos la receta solo para este binario # Generamos la receta solo para este binario
RUN cargo chef prepare --recipe-path recipe.json --bin build-orchestrator RUN cargo chef prepare --recipe-path recipe.json --bin build-orchestrator

View File

@@ -3,6 +3,7 @@
//! Este módulo encapsula la lógica de creación de archivos de configuración //! Este módulo encapsula la lógica de creación de archivos de configuración
//! dinámicos necesarios para el despliegue de contenedores. //! dinámicos necesarios para el despliegue de contenedores.
#[allow(dead_code)]
/// 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.
@@ -47,6 +48,7 @@ HEALTHCHECK --interval=30s --timeout=10s --retries=3 \
) )
} }
#[allow(dead_code)]
/// Genera el contenido de settings.js para Node-RED con branding de OmniOil. /// Genera el contenido de settings.js para Node-RED con branding de OmniOil.
pub fn generate_settings_js(project_name: &str) -> String { pub fn generate_settings_js(project_name: &str) -> String {
format!( format!(

View File

@@ -2,6 +2,7 @@
use sha2::{Digest, Sha256}; use sha2::{Digest, Sha256};
#[allow(dead_code)]
/// Crea un tar archive en memoria con el contexto de build para Docker. /// Crea un tar archive en memoria con el contexto de build para Docker.
pub fn create_build_context( pub fn create_build_context(
dockerfile: &str, dockerfile: &str,
@@ -21,6 +22,7 @@ pub fn create_build_context(
Ok(tar_bytes) Ok(tar_bytes)
} }
#[allow(dead_code)]
/// Añade un buffer de datos a un tar archive con permisos estándar de lectura (0644). /// Añade un buffer de datos a un tar archive con permisos estándar de lectura (0644).
fn add_file_to_tar( fn add_file_to_tar(
archive: &mut tar::Builder<Vec<u8>>, archive: &mut tar::Builder<Vec<u8>>,
@@ -35,6 +37,7 @@ fn add_file_to_tar(
Ok(()) Ok(())
} }
#[allow(dead_code)]
/// Genera un hash hexadecimal de identificación rápida para comparaciones de cambios de flujos. /// Genera un hash hexadecimal de identificación rápida para comparaciones de cambios de flujos.
pub fn calculate_image_hash(flows_json: &str, dockerfile: &str) -> String { pub fn calculate_image_hash(flows_json: &str, dockerfile: &str) -> String {
let mut hasher = Sha256::new(); let mut hasher = Sha256::new();

View File

@@ -1,5 +1,5 @@
use aws_config::meta::region::RegionProviderChain; use aws_config::meta::region::RegionProviderChain;
use aws_sdk_s3::{config::Region, Client}; use aws_sdk_s3::{Client, config::Region};
use serde_json::json; use serde_json::json;
use shared_lib::edge_models::EdgeProject; use shared_lib::edge_models::EdgeProject;
use shared_lib::mqtt_messages::ProjectCreatedEvent; use shared_lib::mqtt_messages::ProjectCreatedEvent;
@@ -12,7 +12,10 @@ pub async fn handle_project_created(
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;
tracing::info!("🚀 Recibido evento de ProjectCreated. Iniciando worker MSI para {}", project_id); tracing::info!(
"🚀 Recibido evento de ProjectCreated. Iniciando worker MSI para {}",
project_id
);
// 1. Actualizar DB: Marcamos el estado en BUILDING // 1. Actualizar DB: Marcamos el estado en BUILDING
let _ = sqlx::query("UPDATE edge_projects SET installer_status = 'BUILDING' WHERE id = $1") let _ = sqlx::query("UPDATE edge_projects SET installer_status = 'BUILDING' WHERE id = $1")
@@ -20,6 +23,45 @@ pub async fn handle_project_created(
.execute(&pool) .execute(&pool)
.await?; .await?;
// 1b. Generar Credenciales MQTT para este Agente (Project Isolation)
let mqtt_user = project_id.to_string();
let mqtt_password = Uuid::new_v4().to_string(); // Contraseña aleatoria única para este instalador
// 1c. Registrar en Infraestructura (Postgres con Auto-Hashing)
tracing::info!("🔑 Registrando identidad MQTT dinámica en DB para el proyecto: {}", project_id);
// Insertamos o actualizamos el usuario con hasheo Bcrypt (bf) nativo
sqlx::query(
"INSERT INTO mqtt_users (username, password_hash)
VALUES ($1, crypt($2, gen_salt('bf', 10)))
ON CONFLICT (username)
DO UPDATE SET password_hash = EXCLUDED.password_hash"
)
.bind(&mqtt_user)
.bind(&mqtt_password)
.execute(&pool)
.await?;
// Asignamos los permisos (ACL) dinámicos:
// Todo bajo el prefijo omnioil/{id}/# para simplificar, o específicos:
let topics = vec![
format!("omnioil/health/{}", project_id),
format!("omnioil/logs/{}", project_id),
format!("omnioil/telemetry/{}", project_id),
format!("omnioil/deploy/{}", project_id),
format!("omnioil/commands/{}", project_id),
];
for t in topics {
sqlx::query(
"INSERT INTO mqtt_acls (username, topic, rw) VALUES ($1, $2, 3) ON CONFLICT (username, topic) DO UPDATE SET rw = 3"
)
.bind(&mqtt_user)
.bind(&t)
.execute(&pool)
.await?;
}
// Elaboración del payload JSON embebido (INYECTADO AL BINARIO) // Elaboración del payload JSON embebido (INYECTADO AL BINARIO)
let agent_config = json!({ let agent_config = json!({
"agent": { "agent": {
@@ -28,27 +70,51 @@ pub async fn handle_project_created(
"health_check_interval_secs": 30 "health_check_interval_secs": 30
}, },
"mqtt": { "mqtt": {
"host": "mqtt.omnioil.com", "host": std::env::var("MQTT_PUBLIC_HOST").unwrap_or_else(|_| "localhost".to_string()),
"port": 1883 "port": std::env::var("MQTT_PORT").unwrap_or_else(|_| "1883".to_string()).parse::<u16>().unwrap_or(1883),
"use_ws": std::env::var("MQTT_USE_WS").unwrap_or_else(|_| "false".to_string()).parse::<bool>().unwrap_or(false),
"username": mqtt_user,
"password": mqtt_password
}, },
"docker": { "docker": {
"container_name": format!("omnioil-scada-{}", &project_id.to_string()[..8]) "container_name": "omnioil-scada",
}, "data_volume_path": "C:/ProgramData/OmniOil/data"
"server": {
"api_url": "https://api.omnioil.com"
} }
}); });
// ─── Proceso de Inyección (Overlay) ─────── // ─── Proceso de Inyección (Overlay) ───────
let template_path = std::path::Path::new("target/x86_64-pc-windows-gnu/release/edge-agent.exe"); let template_path = std::path::Path::new("target/x86_64-pc-windows-gnu/release/edge-agent.exe");
let output_name = format!(
"edge-agent-{}-{}.msi", // Extraer dinámicamente la versión del edge-agent desde su Cargo.toml
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();
for line in edge_agent_toml.lines() {
if line.starts_with("version =") {
version = line.replace("version =", "").replace("\"", "").trim().to_string();
break;
}
}
let exe_name = format!(
"edge-agent-v{}-{}-{}.exe",
version,
event.client.replace(' ', "_"),
&project_id.to_string()[..8]
);
let msi_name = format!(
"edge-agent-v{}-{}-{}.msi",
version,
event.client.replace(' ', "_"), event.client.replace(' ', "_"),
&project_id.to_string()[..8] &project_id.to_string()[..8]
); );
let output_path = std::path::Path::new("services/edge-agent/incoming").join(&output_name);
if let Err(e) = generate_custom_binary(template_path, &output_path, &agent_config) { let incoming_dir = std::path::Path::new("services/edge-agent/incoming");
let output_dir = std::path::Path::new("services/edge-agent/output");
let exe_path = incoming_dir.join(&exe_name);
let msi_path = output_dir.join(&msi_name);
if let Err(e) = generate_custom_binary(template_path, &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 _ = sqlx::query("UPDATE edge_projects SET installer_status = 'FAILED' WHERE id = $1")
.bind(project_id) .bind(project_id)
@@ -57,12 +123,31 @@ pub async fn handle_project_created(
return Err(e.into()); return Err(e.into());
} }
tracing::info!("✅ Overlay COMPLETADO. Iniciando subida a S3/MinIO."); tracing::info!("✅ Overlay COMPLETADO. Esperando que el worker de WiX genere el MSI...");
// Esperar a que el worker MSI procese el archivo (máx 60 segundos)
let mut retries = 0;
while !msi_path.exists() {
if retries > 30 {
tracing::error!("❌ Timeout esperando el MSI desde msi_worker.");
let _ =
sqlx::query("UPDATE edge_projects SET installer_status = 'FAILED' WHERE id = $1")
.bind(project_id)
.execute(&pool)
.await;
return Err("Timeout esperando MSI".into());
}
tokio::time::sleep(std::time::Duration::from_secs(2)).await;
retries += 1;
}
tracing::info!("✅ MSI detectado en el output. Iniciando subida a S3/MinIO.");
// ─── 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 // En Docker Compose normalmente se usa http://minio:9000 internamente
let endpoint_url = std::env::var("MINIO_ENDPOINT_URL").unwrap_or_else(|_| "http://minio:9000".to_string()); let endpoint_url =
std::env::var("MINIO_ENDPOINT_URL").unwrap_or_else(|_| "http://minio:9000".to_string());
// Cargo la config basándome en AWS_ACCESS_KEY_ID local. // 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 // Asumimos que entorno tiene AWS_ACCESS_KEY_ID = admin_s3, AWS_SECRET_ACCESS_KEY = otra_clave_segura_minio
@@ -84,11 +169,12 @@ pub async fn handle_project_created(
// 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(&output_path).await?; let body = aws_sdk_s3::primitives::ByteStream::from_path(&msi_path).await?;
match client.put_object() match client
.put_object()
.bucket(bucket_name) .bucket(bucket_name)
.key(&output_name) .key(&msi_name)
.body(body) .body(body)
.send() .send()
.await .await
@@ -97,8 +183,9 @@ pub async fn handle_project_created(
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) // Link externo para el frontend (generalmente localhost para pruebas locales, cambiar en prod)
let external_url = std::env::var("MINIO_PUBLIC_URL").unwrap_or_else(|_| "http://localhost:9000".to_string()); let external_url = std::env::var("MINIO_PUBLIC_URL")
let download_url = format!("{}/{}/{}", external_url, bucket_name, output_name); .unwrap_or_else(|_| "http://localhost:9000".to_string());
let download_url = format!("{}/{}/{}", external_url, bucket_name, msi_name);
// 3. Actualizamos DB a READY con la URL // 3. Actualizamos DB a READY con la URL
let _ = sqlx::query( let _ = sqlx::query(
@@ -111,10 +198,11 @@ 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 _ = sqlx::query("UPDATE edge_projects SET installer_status = 'FAILED' WHERE id = $1") let _ =
.bind(project_id) sqlx::query("UPDATE edge_projects SET installer_status = 'FAILED' WHERE id = $1")
.execute(&pool) .bind(project_id)
.await; .execute(&pool)
.await;
} }
} }

View File

@@ -55,6 +55,10 @@ async fn main() -> Result<(), Box<dyn std::error::Error>> {
rumqttc::MqttOptions::new("omnioil-build-orchestrator", &mqtt_host, mqtt_port); rumqttc::MqttOptions::new("omnioil-build-orchestrator", &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")) {
mqtt_options.set_credentials(u, p);
}
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

View File

@@ -5,6 +5,7 @@ use shared_lib::mqtt_messages::{DeploySignal, PortMapping, VolumeMount, EnvVar};
use uuid::Uuid; use uuid::Uuid;
use chrono::Utc; use chrono::Utc;
#[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.
pub async fn publish_deploy_signal( pub async fn publish_deploy_signal(
mqtt_client: &AsyncClient, mqtt_client: &AsyncClient,
@@ -14,6 +15,7 @@ pub async fn publish_deploy_signal(
image_hash: &str, image_hash: &str,
registry_url: &str, registry_url: &str,
) -> Result<(), Box<dyn std::error::Error + Send + Sync>> { ) -> Result<(), Box<dyn std::error::Error + Send + Sync>> {
// ... (rest of function unchanged)
let signal = DeploySignal { let signal = DeploySignal {
project_id, project_id,
deployment_id, deployment_id,
@@ -21,6 +23,7 @@ pub async fn publish_deploy_signal(
image_hash: image_hash.to_string(), image_hash: image_hash.to_string(),
registry_url: registry_url.to_string(), registry_url: registry_url.to_string(),
timestamp: Utc::now(), timestamp: Utc::now(),
// 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,
@@ -58,6 +61,7 @@ pub async fn publish_deploy_signal(
Ok(()) Ok(())
} }
#[allow(dead_code)]
/// Publica un evento de estado del build (progress updates). /// Publica un evento de estado del build (progress updates).
pub async fn publish_build_status( pub async fn publish_build_status(
mqtt_client: &AsyncClient, mqtt_client: &AsyncClient,
@@ -83,6 +87,7 @@ pub async fn publish_build_status(
Ok(()) Ok(())
} }
#[allow(dead_code)]
/// Workflow completo de build + push + publicar señal de deploy. /// Workflow completo de build + push + publicar señal de deploy.
pub async fn execute_build_pipeline( pub async fn execute_build_pipeline(
state: &super::OrchestratorState, state: &super::OrchestratorState,

View File

@@ -6,7 +6,7 @@ edition = "2024"
[dependencies] [dependencies]
shared-lib = { path = "../shared-lib" } shared-lib = { path = "../shared-lib" }
tokio = { version = "1.50.0", features = ["full"] } tokio = { version = "1.50.0", features = ["full"] }
sqlx = { version = "0.8.6", features = ["runtime-tokio-native-tls", "postgres", "uuid", "chrono", "json"] } sqlx = { version = "0.8.6", features = ["runtime-tokio-native-tls", "postgres", "uuid", "chrono", "json", "bigdecimal"] }
serde = { version = "1.0.228", features = ["derive"] } serde = { version = "1.0.228", features = ["derive"] }
serde_json = "1.0.149" serde_json = "1.0.149"
chrono = "0.4.44" chrono = "0.4.44"

View File

@@ -16,7 +16,7 @@ ENV CARGO_INCREMENTAL=0
# ============================================================================= # =============================================================================
FROM base AS planner FROM base AS planner
# Copiamos solo los archivos que definen dependencias (workspace y miembros) # Copiamos solo los archivos que definen dependencias (workspace y miembros)
COPY Cargo.toml Cargo.loc[k] ./ COPY Cargo.toml Cargo.lock ./
COPY services/shared-lib/Cargo.toml services/shared-lib/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/flow-generator/Cargo.toml services/flow-generator/Cargo.toml
COPY services/backend-api/Cargo.toml services/backend-api/Cargo.toml COPY services/backend-api/Cargo.toml services/backend-api/Cargo.toml
@@ -25,6 +25,7 @@ COPY services/json-generator/Cargo.toml services/json-generator/Cargo.toml
COPY services/events-relay/Cargo.toml services/events-relay/Cargo.toml COPY services/events-relay/Cargo.toml services/events-relay/Cargo.toml
COPY services/build-orchestrator/Cargo.toml services/build-orchestrator/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/edge-agent/Cargo.toml services/edge-agent/Cargo.toml
COPY services/telemetry-ingestor/Cargo.toml services/telemetry-ingestor/Cargo.toml
# Creamos estructuras dummy para que cargo metadata no falle (muy importante para la caché) # Creamos estructuras dummy para que cargo metadata no falle (muy importante para la caché)
RUN mkdir -p services/shared-lib/src && touch services/shared-lib/src/lib.rs && \ RUN mkdir -p services/shared-lib/src && touch services/shared-lib/src/lib.rs && \
@@ -34,7 +35,8 @@ RUN mkdir -p services/shared-lib/src && touch services/shared-lib/src/lib.rs &&
mkdir -p services/json-generator/src && echo "fn main() {}" > services/json-generator/src/main.rs && \ mkdir -p services/json-generator/src && echo "fn main() {}" > services/json-generator/src/main.rs && \
mkdir -p services/events-relay/src && echo "fn main() {}" > services/events-relay/src/main.rs && \ mkdir -p services/events-relay/src && echo "fn main() {}" > services/events-relay/src/main.rs && \
mkdir -p services/build-orchestrator/src && echo "fn main() {}" > services/build-orchestrator/src/main.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/edge-agent/src && echo "fn main() {}" > services/edge-agent/src/main.rs && \
mkdir -p services/telemetry-ingestor/src && echo "fn main() {}" > services/telemetry-ingestor/src/main.rs
# Generamos la receta solo para este binario # Generamos la receta solo para este binario
RUN cargo chef prepare --recipe-path recipe.json --bin data-collector RUN cargo chef prepare --recipe-path recipe.json --bin data-collector

View File

@@ -29,30 +29,25 @@ impl TelemetryFetcher {
} }
pub async fn fetch_telemetry(&self) -> Result<HashMap<String, (DateTime<Utc>, Value)>, Box<dyn Error>> { pub async fn fetch_telemetry(&self) -> Result<HashMap<String, (DateTime<Utc>, Value)>, Box<dyn Error>> {
// En un escenario real, haríamos: // Validación de configuración
// let resp = self.client.get(&self.api_url) if self.api_url.is_empty() {
// .header("Authorization", &self.api_key) return Ok(HashMap::new());
// .send() }
// .await?
// .json::<Vec<TagReading>>()
// .await?;
// MOCK TEMPORAL: Generamos datos simulados // Realizamos la petición real a la API configurada
let now = Utc::now(); let resp = self.client.get(&self.api_url)
let timestamp = now.timestamp(); .header("X-API-KEY", &self.api_key)
.send()
let mock_response = vec![ .await?
TagReading { tag_id: "POZO-TEST-01:PRESION".into(), value: 150.5, quality: "GOOD".into(), timestamp }, .json::<Vec<TagReading>>()
TagReading { tag_id: "POZO-TEST-01:TEMPERATURA".into(), value: 65.2, quality: "GOOD".into(), timestamp }, .await?;
TagReading { tag_id: "TANQUE-01:NIVEL".into(), value: 85.0, quality: "GOOD".into(), timestamp },
];
// Agrupar por Activo // Agrupar por Activo
// Asumimos convención: CODIGO_ACTIVO:VARIABLE // Asumimos convención: CODIGO_ACTIVO:VARIABLE (ej: POZO-X1:PRESION)
let mut grouped_data: HashMap<String, serde_json::Map<String, Value>> = HashMap::new(); let mut grouped_data: HashMap<String, serde_json::Map<String, Value>> = HashMap::new();
let mut asset_timestamps: HashMap<String, DateTime<Utc>> = HashMap::new(); let mut asset_timestamps: HashMap<String, DateTime<Utc>> = HashMap::new();
for reading in mock_response { for reading in resp {
let parts: Vec<&str> = reading.tag_id.split(':').collect(); let parts: Vec<&str> = reading.tag_id.split(':').collect();
if parts.len() < 2 { continue; } if parts.len() < 2 { continue; }

View File

@@ -1,13 +1,13 @@
[package] [package]
name = "edge-agent" name = "edge-agent"
version = "0.1.1" version = "0.1.2"
edition = "2024" edition = "2024"
[dependencies] [dependencies]
shared-lib = { path = "../shared-lib" } shared-lib = { path = "../shared-lib" }
tokio = { version = "1.50.0", features = ["full"] } tokio = { version = "1.50.0", features = ["full"] }
bollard = "0.20.2" bollard = "0.20.2"
rumqttc = "0.25.1" rumqttc = { version = "0.25.1", features = ["websocket", "use-rustls"] }
serde = { version = "1.0.228", features = ["derive"] } serde = { version = "1.0.228", features = ["derive"] }
serde_json = "1.0.149" serde_json = "1.0.149"
anyhow = "1.0.102" anyhow = "1.0.102"
@@ -30,6 +30,11 @@ tray-icon = "0.22.0"
winit = "0.30.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"] }
aes-gcm = "0.10.3"
pbkdf2 = "0.12.2"
sha2 = "0.10.8"
rand = "0.8.5"
[build-dependencies] [build-dependencies]
winres = "0.1.12" winres = "0.1.12"

View File

@@ -6,7 +6,7 @@
FROM lukemathwalker/cargo-chef:latest-rust-1.93-slim-bookworm AS planner FROM lukemathwalker/cargo-chef:latest-rust-1.93-slim-bookworm AS planner
WORKDIR /app WORKDIR /app
# Copiamos solo los archivos que definen dependencias (workspace y miembros) # Copiamos solo los archivos que definen dependencias (workspace y miembros)
COPY Cargo.toml Cargo.loc[k] ./ COPY Cargo.toml Cargo.lock ./
COPY services/shared-lib/Cargo.toml services/shared-lib/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/flow-generator/Cargo.toml services/flow-generator/Cargo.toml
COPY services/backend-api/Cargo.toml services/backend-api/Cargo.toml COPY services/backend-api/Cargo.toml services/backend-api/Cargo.toml
@@ -15,6 +15,7 @@ COPY services/json-generator/Cargo.toml services/json-generator/Cargo.toml
COPY services/events-relay/Cargo.toml services/events-relay/Cargo.toml COPY services/events-relay/Cargo.toml services/events-relay/Cargo.toml
COPY services/build-orchestrator/Cargo.toml services/build-orchestrator/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/edge-agent/Cargo.toml services/edge-agent/Cargo.toml
COPY services/telemetry-ingestor/Cargo.toml services/telemetry-ingestor/Cargo.toml
# Creamos estructuras dummy para que cargo metadata no falle (muy importante para la caché) # Creamos estructuras dummy para que cargo metadata no falle (muy importante para la caché)
RUN mkdir -p services/shared-lib/src && touch services/shared-lib/src/lib.rs && \ RUN mkdir -p services/shared-lib/src && touch services/shared-lib/src/lib.rs && \
@@ -24,7 +25,8 @@ RUN mkdir -p services/shared-lib/src && touch services/shared-lib/src/lib.rs &&
mkdir -p services/json-generator/src && echo "fn main() {}" > services/json-generator/src/main.rs && \ mkdir -p services/json-generator/src && echo "fn main() {}" > services/json-generator/src/main.rs && \
mkdir -p services/events-relay/src && echo "fn main() {}" > services/events-relay/src/main.rs && \ mkdir -p services/events-relay/src && echo "fn main() {}" > services/events-relay/src/main.rs && \
mkdir -p services/build-orchestrator/src && echo "fn main() {}" > services/build-orchestrator/src/main.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/edge-agent/src && echo "fn main() {}" > services/edge-agent/src/main.rs && \
mkdir -p services/telemetry-ingestor/src && echo "fn main() {}" > services/telemetry-ingestor/src/main.rs
RUN cargo chef prepare --recipe-path recipe.json --bin edge-agent RUN cargo chef prepare --recipe-path recipe.json --bin edge-agent
@@ -75,8 +77,8 @@ ENV CC_x86_64_pc_windows_gnu=x86_64-w64-mingw32-gcc
ENV CXX_x86_64_pc_windows_gnu=x86_64-w64-mingw32-g++ ENV CXX_x86_64_pc_windows_gnu=x86_64-w64-mingw32-g++
ENV CARGO_TARGET_X86_64_PC_WINDOWS_GNU_LINKER=x86_64-w64-mingw32-gcc ENV CARGO_TARGET_X86_64_PC_WINDOWS_GNU_LINKER=x86_64-w64-mingw32-gcc
# Compilar binarios de producción para Windows GNU (solo toma segundos/minutos ahora) # Compilar binarios de producción para Windows GNU (Estático total)
RUN cargo build --release --bin edge-agent --bin edge-tray --target x86_64-pc-windows-gnu RUN RUSTFLAGS="-C target-feature=+crt-static -C link-args=-static" cargo build --release --bin edge-agent --bin edge-tray --target x86_64-pc-windows-gnu
# --- ETAPA 4: EMPAQUETADOR (MSI) --- # --- ETAPA 4: EMPAQUETADOR (MSI) ---
FROM debian:bookworm-slim FROM debian:bookworm-slim
@@ -94,11 +96,13 @@ RUN mkdir -p /app/services/edge-agent/target/x86_64-pc-windows-msvc/release/
COPY --from=builder /app/target/x86_64-pc-windows-gnu/release/edge-agent.exe /app/services/edge-agent/target/x86_64-pc-windows-msvc/release/ COPY --from=builder /app/target/x86_64-pc-windows-gnu/release/edge-agent.exe /app/services/edge-agent/target/x86_64-pc-windows-msvc/release/
COPY --from=builder /app/target/x86_64-pc-windows-gnu/release/edge-tray.exe /app/services/edge-agent/target/x86_64-pc-windows-msvc/release/ COPY --from=builder /app/target/x86_64-pc-windows-gnu/release/edge-tray.exe /app/services/edge-agent/target/x86_64-pc-windows-msvc/release/
COPY ./services/edge-agent/Cargo.toml /app/services/edge-agent/Cargo.toml
COPY ./services/edge-agent/wix /app/services/edge-agent/wix COPY ./services/edge-agent/wix /app/services/edge-agent/wix
COPY ./services/edge-agent/icon.ico /app/services/edge-agent/ COPY ./services/edge-agent/icon.ico /app/services/edge-agent/
COPY ./services/edge-agent/msi_worker.sh /app/services/edge-agent/msi_worker.sh COPY ./services/edge-agent/msi_worker.sh /app/services/edge-agent/msi_worker.sh
RUN chmod +x /app/services/edge-agent/msi_worker.sh RUN sed -i 's/\r$//' /app/services/edge-agent/msi_worker.sh && \
chmod +x /app/services/edge-agent/msi_worker.sh
WORKDIR /app/services/edge-agent WORKDIR /app/services/edge-agent
ENTRYPOINT ["/app/services/edge-agent/msi_worker.sh"] ENTRYPOINT ["/app/services/edge-agent/msi_worker.sh"]

View File

@@ -1,31 +1,17 @@
# ============================================================================= # OmniOil Edge Agent - Default Configuration
# OmniOil Edge Agent - Configuration File (Local Testing)
# =============================================================================
[agent] [agent]
project_id = "550e8400-e29b-41d4-a716-446655440000" project_id = "00000000-0000-0000-0000-000000000000"
hostname = "edge-device-test" hostname = "edge-device"
health_check_interval_secs = 10
max_restart_retries = 3 [server]
api_url = "http://localhost:3000"
api_token = ""
[mqtt] [mqtt]
host = "localhost" host = "localhost"
port = 1883 port = 1883
client_id = "edge-agent-test"
use_tls = false use_tls = false
[docker] [docker]
container_name = "omnioil-scada-container" socket_path = "npipe:////./pipe/docker_engine"
# socket_path = "//./pipe/docker_engine" data_volume_path = "C:/ProgramData/OmniOil/data"
registry_url = "https://hub.docker.com"
nodered_host_port = 1880
data_volume_path = "test-data"
[server]
api_url = "http://localhost:8080"
api_token = "test-token"
[updater]
gitea_url = "http://localhost:3000"
repository = "omnioil/edge-agent"
enabled = false

View File

@@ -10,9 +10,13 @@ INCOMING_DIR="$WORKDIR/incoming"
OUTPUT_DIR="$WORKDIR/output" OUTPUT_DIR="$WORKDIR/output"
BINARY_PATH="$WORKDIR/target/x86_64-pc-windows-msvc/release/edge-agent.exe" BINARY_PATH="$WORKDIR/target/x86_64-pc-windows-msvc/release/edge-agent.exe"
echo "🔨 OmniOil MSI Builder Worker arrancando..." log() {
echo "📂 Vigilando carpeta: $INCOMING_DIR" echo "$(date '+%Y-%m-%d %H:%M:%S') $1"
echo "📦 Guardando MSIs en: $OUTPUT_DIR" }
log "🔨 OmniOil MSI Builder Worker arrancando..."
log "📂 Vigilando carpeta: $INCOMING_DIR"
log "📦 Guardando MSIs en: $OUTPUT_DIR"
# Asegurar directorios # Asegurar directorios
mkdir -p "$INCOMING_DIR" mkdir -p "$INCOMING_DIR"
@@ -22,6 +26,9 @@ mkdir -p "$OUTPUT_DIR"
while true; do while true; do
# Buscar archivos .exe que no sean el binario final (el que WiX espera) # Buscar archivos .exe que no sean el binario final (el que WiX espera)
# Por ejemplo, archivos con nombres tipo: edge-agent-CLIENTE-123.exe # Por ejemplo, archivos con nombres tipo: edge-agent-CLIENTE-123.exe
# --- Limpieza de archivos antiguos (TTL 24h) ---
find "$OUTPUT_DIR" -name "*.msi" -mmin +1440 -exec rm {} \; 2>/dev/null
CUSTOM_EXES=$(find "$INCOMING_DIR" -name "*.exe" -maxdepth 1) CUSTOM_EXES=$(find "$INCOMING_DIR" -name "*.exe" -maxdepth 1)
if [ -n "$CUSTOM_EXES" ]; then if [ -n "$CUSTOM_EXES" ]; then
@@ -30,21 +37,61 @@ while true; do
VERSION_NAME="${FILENAME%.exe}" VERSION_NAME="${FILENAME%.exe}"
MSI_NAME="$VERSION_NAME.msi" MSI_NAME="$VERSION_NAME.msi"
echo "🚀 Detectado nuevo binario: $FILENAME" log "🚀 Detectado nuevo binario: $FILENAME"
echo "🔧 Preparando empaquetado para: $VERSION_NAME..." log "🔧 Preparando empaquetado para: $VERSION_NAME..."
# 1. Mover el binario personalizado a la ruta que espera WiX # 0. Extraer versión dinámica desde Cargo.toml
cp "$EXE" "$BINARY_PATH" VERSION=$(grep -m 1 "^version =" Cargo.toml | cut -d '"' -f 2)
if [ -z "$VERSION" ]; then
VERSION="0.1.0"
log "⚠️ No se pudo detectar versión en Cargo.toml, usando fallback: $VERSION"
else
log "📌 Versión detectada: $VERSION"
fi
# Aplicar versión a main.wxs dinámicamente (solo a la etiqueta Product)
# Usamos un espacio antes de Version para no tocar InstallerVersion
sed -i "s/ Version='[0-9.]*'/ Version='$VERSION'/" wix/main.wxs
sed -i "s/{{VERSION}}/$VERSION/g" wix/main.wxs
# 1. Asegurar carpeta y mover el binario personalizado
mkdir -p "target/x86_64-pc-windows-msvc/release/"
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" "wix/main.wxs"
if [ $? -eq 0 ]; then if [ $? -eq 0 ]; then
echo "✅ MSI generado con éxito: $OUTPUT_DIR/$MSI_NAME" # 3. Parchar el Summary Information Stream para que Windows lo acepte
# 3. Eliminar el archivo de entrada ya procesado # msibuild -s [Title] [Author] [Template] [UUID]
# PID_TEMPLATE (Property 7) = x64;1033
# PID_PAGECOUNT (Property 14) = 200 (Minimum Installer Version)
log "🔧 Aplicando parches de compatibilidad al MSI..."
# Intentamos setear el Template a x64 y la version a 200
msibuild "$OUTPUT_DIR/$MSI_NAME" -s "OmniOil Edge Agent" "OmniOil" "x64;1033"
log "✅ MSI generado con éxito: $OUTPUT_DIR/$MSI_NAME"
# 4. Eliminar el archivo de entrada ya procesado
rm "$EXE" rm "$EXE"
else else
echo "❌ Error al generar el MSI para $FILENAME" log "❌ Error al generar el MSI para $FILENAME"
fi fi
done done
fi fi

View File

@@ -10,10 +10,6 @@ use super::models::AgentConfig;
// El bloque embebido se mantiene como un fallback robusto. // El bloque embebido se mantiene como un fallback robusto.
include!(concat!(env!("OUT_DIR"), "/baked_config.rs")); include!(concat!(env!("OUT_DIR"), "/baked_config.rs"));
// Constantes globales de seguridad para el overlay
const MAGIC_OVERLAY: &[u8; 8] = b"OMNIOIL\0";
const XOR_KEY: u8 = 0x5A;
impl AgentConfig { impl AgentConfig {
/// Carga la configuración desde un archivo TOML físico. /// Carga la configuración desde un archivo TOML físico.
pub fn load(path: &Path) -> Result<Self> { pub fn load(path: &Path) -> Result<Self> {
@@ -29,17 +25,18 @@ impl AgentConfig {
/// 2. Baked Config (Producción estática) /// 2. Baked Config (Producción estática)
/// 3. Local File (Desarrollo) /// 3. Local File (Desarrollo)
pub fn load_default(external_path: &Path) -> Result<Self> { pub fn load_default(external_path: &Path) -> Result<Self> {
// --- Modo 1: Overlay (Footer de binario) --- // --- Modo 1: Overlay (Footer de binario Encriptado AES-256) ---
if let Ok(exe_path) = std::env::current_exe() { if let Some(content) = shared_lib::overlay::find_and_decrypt_overlay() {
if let Ok(config) = Self::load_from_overlay(&exe_path) { if let Ok(config) = toml::from_str::<AgentConfig>(&content) {
tracing::info!("🚀 [Overlay] Configuración dinámica inyectada detectada."); tracing::info!("🚀 [Overlay] Configuración dinámica AES-256 inyectada detectada.");
return Ok(config); return Ok(config);
} }
} }
// --- Modo 2: Baked (Compilación estática) --- // --- Modo 2: Baked (Compilación estática) ---
// TODO: Migrar Baked a AES también en Fase 3
if !BAKED_CONFIG_XOR.is_empty() { if !BAKED_CONFIG_XOR.is_empty() {
let deobfuscated: Vec<u8> = BAKED_CONFIG_XOR.iter().map(|b| b ^ XOR_KEY).collect(); let deobfuscated: Vec<u8> = BAKED_CONFIG_XOR.iter().map(|b| b ^ 0x5A).collect();
if let Ok(content) = String::from_utf8(deobfuscated) { if let Ok(content) = String::from_utf8(deobfuscated) {
if content.trim().len() > 10 { if content.trim().len() > 10 {
if let Ok(config) = toml::from_str::<AgentConfig>(&content) { if let Ok(config) = toml::from_str::<AgentConfig>(&content) {
@@ -54,32 +51,4 @@ impl AgentConfig {
tracing::info!("📂 [Local] Buscando archivo de configuración externo: {:?}", external_path); tracing::info!("📂 [Local] Buscando archivo de configuración externo: {:?}", external_path);
Self::load(external_path) Self::load(external_path)
} }
/// Lee la configuración desde los últimos bytes del ejecutable.
fn load_from_overlay(exe_path: &PathBuf) -> Result<Self> {
let mut file = std::fs::File::open(exe_path)?;
let file_len = file.metadata()?.len();
if file_len < 12 { return Err(anyhow::anyhow!("Archivo demasiado pequeño para overlay")); }
file.seek(SeekFrom::End(-12))?;
let mut footer = [0u8; 12];
file.read_exact(&mut footer)?;
let magic = &footer[4..12];
if magic != MAGIC_OVERLAY { return Err(anyhow::anyhow!("Magic overlay no encontrado")); }
let config_len = u32::from_le_bytes([footer[0], footer[1], footer[2], footer[3]]) as u64;
if file_len < 12 + config_len { return Err(anyhow::anyhow!("Longitud de overlay inválida")); }
file.seek(SeekFrom::End(-(12 + config_len as i64)))?;
let mut config_bytes = vec![0u8; config_len as usize];
file.read_exact(&mut config_bytes)?;
let deobfuscated: Vec<u8> = config_bytes.iter().map(|b| b ^ XOR_KEY).collect();
let content = String::from_utf8(deobfuscated)?;
let config: AgentConfig = toml::from_str(&content)?;
Ok(config)
}
} }

View File

@@ -8,7 +8,7 @@ pub struct AgentConfig {
pub agent: AgentSettings, pub agent: AgentSettings,
pub mqtt: MqttSettings, pub mqtt: MqttSettings,
pub docker: DockerSettings, pub docker: DockerSettings,
pub server: ServerSettings, pub server: Option<ServerSettings>,
pub updater: Option<UpdaterSettings>, pub updater: Option<UpdaterSettings>,
} }
@@ -25,7 +25,10 @@ pub struct MqttSettings {
pub host: String, pub host: String,
pub port: Option<u16>, pub port: Option<u16>,
pub client_id: Option<String>, pub client_id: Option<String>,
pub username: Option<String>,
pub password: Option<String>,
pub use_tls: Option<bool>, pub use_tls: Option<bool>,
pub use_ws: Option<bool>,
pub ca_cert_path: Option<String>, pub ca_cert_path: Option<String>,
} }

View File

@@ -1,35 +1,58 @@
use crate::persistence_manager::StoreAndForward;
use axum::{ use axum::{
extract::{State, Json}, extract::{Json, State},
routing::post, routing::post,
Router, Router,
}; };
use rumqttc::{AsyncClient, QoS}; use rumqttc::{AsyncClient, QoS};
use serde_json::{Value, json}; use serde_json::{json, Value};
use std::net::SocketAddr; use std::net::SocketAddr;
use std::sync::atomic::{AtomicI64, Ordering};
use std::sync::Arc; use std::sync::Arc;
use tokio::time::{interval, Duration};
use uuid::Uuid; use uuid::Uuid;
use anyhow::Result;
/// 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, project_id: Uuid,
storage: Arc<StoreAndForward>,
last_heartbeat: Arc<AtomicI64>,
} }
/// Inicia el servidor HTTP del gateway de datos. /// Inicia el servidor HTTP del gateway de datos.
/// Este servidor escucha localmente peticiones desde contenedores como Node-RED.
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,
) -> Result<()> { storage: Arc<StoreAndForward>,
) -> anyhow::Result<()> {
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: mqtt_client.clone(),
project_id, project_id,
storage: storage.clone(),
last_heartbeat: last_heartbeat.clone(),
}); });
// 1. Tarea de fondo para drenar la cola (Store & Forward)
let drain_client = mqtt_client.clone();
let drain_storage = storage.clone();
tokio::spawn(async move {
let mut timer = interval(Duration::from_secs(30));
loop {
timer.tick().await;
if let Err(e) = drain_storage.process_queue(&drain_client).await {
tracing::debug!("Store & Forward: Queue is empty or failed: {}", e);
}
}
});
// 2. Servidor Axum para ingesta y watchdog
let app = Router::new() let app = Router::new()
.route("/api/edge/data", post(ingest_data)) .route("/api/edge/data", post(ingest_data))
.route("/api/edge/heartbeat", post(pulse_heartbeat))
.with_state(state); .with_state(state);
let addr = SocketAddr::from(([0, 0, 0, 0], port)); let addr = SocketAddr::from(([0, 0, 0, 0], port));
@@ -44,19 +67,54 @@ pub async fn start_data_gateway(
/// Handler para recibir datos de SCADA (ej. Node-RED). /// Handler para recibir datos de SCADA (ej. Node-RED).
async fn ingest_data( async fn ingest_data(
State(state): State<Arc<GatewayState>>, State(state): State<Arc<GatewayState>>,
Json(payload): Json<Value>, Json(mut payload): Json<Value>,
) -> Json<Value> { ) -> Json<Value> {
let topic = format!("omnioil/telemetry/{}", state.project_id); let topic = format!("omnioil/telemetry/{}", state.project_id);
// Publicar a MQTT // Asegurar que el payload tenga un timestamp de captura (Capture Time)
if let Ok(data_str) = serde_json::to_string(&payload) { // Si Node-RED no lo envió, lo generamos en el momento justo de recibirlo en el borde.
let _ = state.mqtt_client if payload.get("timestamp").is_none() {
.publish(&topic, QoS::AtLeastOnce, false, data_str.as_bytes()) if let Some(obj) = payload.as_object_mut() {
.await; obj.insert(
"timestamp".to_string(),
json!(chrono::Utc::now().to_rfc3339()),
);
obj.insert("is_agent_timestamped".to_string(), json!(true));
}
}
tracing::debug!("📡 Data received and published to MQTT: {}", topic); let data_str = match serde_json::to_string(&payload) {
Json(json!({ "status": "success", "target": "mqtt", "topic": topic })) Ok(s) => s,
} else { Err(e) => return Json(json!({ "status": "error", "message": e.to_string() })),
Json(json!({ "status": "error", "message": "Failed to serialize payload" })) };
// Intentar publicar a MQTT
match state
.mqtt_client
.publish(&topic, QoS::AtLeastOnce, false, data_str.as_bytes())
.await
{
Ok(_) => {
tracing::debug!("📡 Data published to MQTT: {}", topic);
Json(json!({ "status": "success", "target": "mqtt" }))
}
Err(e) => {
// Si falla MQTT (desconexión), guardamos en SQLite local
tracing::warn!("⚠️ MQTT offline. Saving to local storage: {}", e);
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" }))
}
}
} }
} }
/// Handler para watchdog (Heartbeat desde el contenedor).
async fn pulse_heartbeat(State(state): State<Arc<GatewayState>>) -> Json<Value> {
state
.last_heartbeat
.store(chrono::Utc::now().timestamp(), Ordering::SeqCst);
Json(json!({ "status": "alive" }))
}

View File

@@ -42,10 +42,7 @@ pub async fn start_health_checker(
// Inspeccionar estado del contenedor // Inspeccionar estado del contenedor
let status = match docker_manager::inspect_container(&docker, &container_name).await { let status = match docker_manager::inspect_container(&docker, &container_name).await {
Ok(status) => { Ok(status) => status,
log_reporter.log_health(&format!("{:?}", status), "Heartbeat").await;
status
},
Err(e) => { Err(e) => {
log_reporter.error("HEALTH", &format!("Failed to inspect: {}", e)).await; log_reporter.error("HEALTH", &format!("Failed to inspect: {}", e)).await;
ContainerStatus::Error ContainerStatus::Error
@@ -66,6 +63,7 @@ pub async fn start_health_checker(
let report = HealthReport { let report = HealthReport {
project_id: Uuid::parse_str(&project_id).unwrap_or_default(), project_id: Uuid::parse_str(&project_id).unwrap_or_default(),
agent_hostname: hostname.clone(), agent_hostname: hostname.clone(),
agent_version: Some(env!("CARGO_PKG_VERSION").to_string()),
container_status: status.clone(), container_status: status.clone(),
container_id: state.container_id.clone(), container_id: state.container_id.clone(),
image_tag: state.current_image.clone().unwrap_or_default(), image_tag: state.current_image.clone().unwrap_or_default(),
@@ -150,7 +148,7 @@ pub async fn start_health_checker(
"previous_image": state.current_image, "previous_image": state.current_image,
"timestamp": Utc::now() "timestamp": Utc::now()
}); });
let log_topic = format!("omnioil/logs/{}", project_id); let log_topic = format!("telemetry/{}/logs", project_id);
let _ = mqtt_client.publish( let _ = mqtt_client.publish(
&log_topic, QoS::AtLeastOnce, false, &log_topic, QoS::AtLeastOnce, false,
serde_json::to_string(&rollback_event).unwrap_or_default().as_bytes(), serde_json::to_string(&rollback_event).unwrap_or_default().as_bytes(),

View File

@@ -57,15 +57,6 @@ impl LogReporter {
.await; .await;
} }
if let (Some(client), Some(url)) = (&self.http_client, &self.api_url) {
let endpoint = format!("{}/api/edge/agents/logs", url);
let _ = client
.post(&endpoint)
.json(&entry)
.send()
.await;
}
match level { match level {
"ERROR" | "FATAL" => tracing::error!("[{}] {}: {}", category, level, message), "ERROR" | "FATAL" => tracing::error!("[{}] {}: {}", category, level, message),
"WARN" => tracing::warn!("[{}] {}", category, message), "WARN" => tracing::warn!("[{}] {}", category, message),
@@ -94,6 +85,6 @@ impl LogReporter {
} }
pub async fn log_health(&self, container_status: &str, details: &str) { pub async fn log_health(&self, container_status: &str, details: &str) {
self.log("INFO", "HEALTH", &format!("Container {}: {}", container_status, details), None).await; self.log("INFO", "HEALTH", &format!("Sist. Despliegue ({}): {}", container_status, details), None).await;
} }
} }

View File

@@ -10,6 +10,7 @@ mod ipc_protocol;
mod ipc_server; mod ipc_server;
mod log_reporter; mod log_reporter;
mod mqtt_listener; mod mqtt_listener;
mod persistence_manager;
mod service_manager; mod service_manager;
mod system_validator; mod system_validator;
#[cfg(test)] #[cfg(test)]
@@ -121,10 +122,6 @@ pub async fn run_agent(config: AgentConfig) -> Result<()> {
} }
} }
// Conectar al Docker daemon
let docker = docker_manager::connect_docker(config.docker.socket_path.as_deref())
.context("No se pudo conectar al demonio de Docker")?;
// Estado compartido // Estado compartido
let container_state = Arc::new(RwLock::new(ContainerState::default())); let container_state = Arc::new(RwLock::new(ContainerState::default()));
let last_deploy_signal = Arc::new(RwLock::new(None::<shared_lib::mqtt_messages::DeploySignal>)); let last_deploy_signal = Arc::new(RwLock::new(None::<shared_lib::mqtt_messages::DeploySignal>));
@@ -132,24 +129,72 @@ pub async fn run_agent(config: AgentConfig) -> Result<()> {
// Canal para comandos // Canal para comandos
let (cmd_tx, mut cmd_rx) = mpsc::channel::<AgentCommand>(16); let (cmd_tx, mut cmd_rx) = mpsc::channel::<AgentCommand>(16);
// Iniciar MQTT // Conectar al Docker daemon con reintentos (importante para arranque tras reinicio del PC)
let (mqtt_client, _mqtt_handle) = mqtt_listener::start_mqtt_listener(&config, cmd_tx) let mut docker_client = None;
.await let mut retries = 0;
.context("Failed to start MQTT listener")?; 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;
retries = 0;
while retries < MAX_RETRIES {
match mqtt_listener::start_mqtt_listener(&config, cmd_tx.clone()).await {
Ok(res) => {
mqtt_result = Some(res);
break;
}
Err(e) => {
tracing::warn!("⏳ Esperando red/MQTT (intento {}/{}): {}", retries + 1, MAX_RETRIES, e);
tokio::time::sleep(std::time::Duration::from_secs(10)).await;
retries += 1;
}
}
}
let (mqtt_client, _mqtt_handle) = mqtt_result.context("No se pudo conectar al servidor MQTT tras múltiples intentos.")?;
// Iniciar Log Reporter // Iniciar Log Reporter
let (api_url, api_token) = config.server.as_ref()
.map(|s| (s.api_url.clone(), s.api_token.clone()))
.unwrap_or((None, None));
let log_reporter = Arc::new(LogReporter::new( let log_reporter = Arc::new(LogReporter::new(
project_id, project_id,
config.hostname(), config.hostname(),
mqtt_client.clone(), mqtt_client.clone(),
config.server.api_url.clone(), api_url,
config.server.api_token.clone(), api_token,
)); ));
log_reporter log_reporter
.info("SYSTEM", &format!("Edge Agent v{} started", VERSION)) .info("SYSTEM", &format!("Edge Agent v{} started", VERSION))
.await; .await;
// Inicializar Persistencia Local (Store & Forward)
let db_path = config.docker.data_volume_path.clone().unwrap_or_else(|| "C:/ProgramData/OmniOil/data".to_string());
let db_file = std::path::Path::new(&db_path).join("agent_storage.db");
let storage = Arc::new(persistence_manager::StoreAndForward::new(db_file.to_str().unwrap())?);
// 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();
@@ -197,11 +242,12 @@ pub async fn run_agent(config: AgentConfig) -> Result<()> {
// Iniciar Data Gateway (Intake local para Node-RED) // 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_project_id = project_id;
let gateway_storage = storage.clone();
// Usamos el puerto 8081 por defecto si no esta configurado // Usamos el puerto 8081 por defecto si no esta configurado
let gateway_port = 8081; let gateway_port = 8081;
tokio::spawn(async move { tokio::spawn(async move {
if let Err(e) = if let Err(e) =
data_gateway::start_data_gateway(gateway_port, gateway_mqtt, gateway_project_id).await data_gateway::start_data_gateway(gateway_port, gateway_mqtt, gateway_project_id, gateway_storage).await
{ {
tracing::error!("Data Gateway error: {}", e); tracing::error!("Data Gateway error: {}", e);
} }

View File

@@ -31,6 +31,35 @@ pub async fn start_mqtt_listener(
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) {
mqtt_options.set_credentials(u, p);
}
// --- CONFIGURACIÓN LAST WILL (Última Voluntad) ---
// Si el agente pierde conexión contra el broker de forma abrupta,
// 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
if config.mqtt.use_ws.unwrap_or(false) || mqtt_port == 443 {
tracing::info!("🌐 Using WebSockets (WSS) for MQTT connection");
mqtt_options.set_transport(rumqttc::Transport::Wss(rumqttc::TlsConfiguration::default().into()));
}
let (client, eventloop) = AsyncClient::new(mqtt_options, 10); let (client, eventloop) = AsyncClient::new(mqtt_options, 10);
// Suscribirse al topic de deploy para este proyecto // Suscribirse al topic de deploy para este proyecto

View File

@@ -0,0 +1,121 @@
//! Persistence Manager — Almacenamiento local (Store & Forward) para telemetría.
//! Permite que el agente guarde datos en SQLite de forma ENCRIPTADA.
use aes_gcm::{
Aes256Gcm, Nonce,
aead::{Aead, KeyInit},
};
use anyhow::{Context, Result};
use chrono::Utc;
use pbkdf2::pbkdf2_hmac;
use rand::RngCore;
use rumqttc::{AsyncClient, QoS};
use rusqlite::{Connection, params};
use sha2::Sha256;
const SALT: &[u8; 15] = b"omnioil_db_salt";
pub struct StoreAndForward {
db_path: String,
cipher: Aes256Gcm,
}
impl StoreAndForward {
/// Inicializa la base de datos y prepara el cifrado AES-256
pub fn new(db_path: &str) -> Result<Self> {
// 1. Asegurar carpeta
if let Some(parent) = std::path::Path::new(db_path).parent() {
if !parent.exists() {
std::fs::create_dir_all(parent).context("Failed to create DB directory")?;
}
}
// 2. Preparar Cifrado
let master_secret = std::env::var("OMNIOIL_MASTER_SECRET")
.unwrap_or_else(|_| "dev_master_secret_omnioil_2026".to_string());
let mut key = [0u8; 32];
pbkdf2_hmac::<Sha256>(master_secret.as_bytes(), SALT, 1000, &mut key);
let cipher =
Aes256Gcm::new_from_slice(&key).map_err(|e| anyhow::anyhow!("Cipher Error: {}", e))?;
// 3. Abrir DB
let conn = Connection::open(db_path).context("Failed to open SQLite DB")?;
conn.execute(
"CREATE TABLE IF NOT EXISTS telemetry_queue (
id INTEGER PRIMARY KEY AUTOINCREMENT,
topic TEXT NOT NULL,
payload BLOB NOT NULL,
nonce BLOB NOT NULL,
created_at DATETIME NOT NULL
)",
[],
)?;
Ok(Self {
db_path: db_path.to_string(),
cipher,
})
}
/// Guarda un payload ENCRIPTADO
pub fn save(&self, topic: &str, payload: &str) -> Result<()> {
let mut nonce_bytes = [0u8; 12];
rand::thread_rng().fill_bytes(&mut nonce_bytes);
let nonce = Nonce::from_slice(&nonce_bytes);
let ciphertext = self
.cipher
.encrypt(nonce, payload.as_bytes())
.map_err(|e| anyhow::anyhow!("Encryption failed: {}", e))?;
let conn = Connection::open(&self.db_path)?;
conn.execute(
"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()],
)?;
Ok(())
}
/// Desencripta y envía los datos
pub async fn process_queue(&self, mqtt_client: &AsyncClient) -> Result<usize> {
let rows = {
let conn = Connection::open(&self.db_path)?;
let mut stmt = conn.prepare(
"SELECT id, topic, payload, nonce FROM telemetry_queue ORDER BY id ASC LIMIT 50",
)?;
let res: Vec<(i64, String, Vec<u8>, Vec<u8>)> = stmt
.query_map([], |row| {
Ok((row.get(0)?, row.get(1)?, row.get(2)?, row.get(3)?))
})?
.filter_map(|r| r.ok())
.collect();
res
};
let mut sent_count = 0;
for (id, topic, ciphertext, nonce_bytes) in rows {
let nonce = Nonce::from_slice(&nonce_bytes);
if let Ok(plaintext_bytes) = self.cipher.decrypt(nonce, ciphertext.as_slice()) {
if let Ok(payload) = String::from_utf8(plaintext_bytes) {
match mqtt_client
.publish(&topic, QoS::AtLeastOnce, false, payload.as_bytes())
.await
{
Ok(_) => {
let _ = Connection::open(&self.db_path)?
.execute("DELETE FROM telemetry_queue WHERE id = ?1", params![id]);
sent_count += 1;
}
Err(_) => break,
}
}
} else {
// Si no se puede desencriptar (llave cambiada), eliminamos para no bloquear la cola
let _ = Connection::open(&self.db_path)?
.execute("DELETE FROM telemetry_queue WHERE id = ?1", params![id]);
}
}
Ok(sent_count)
}
}

View File

@@ -112,10 +112,20 @@ pub fn install_service() -> Result<()> {
.arg("start= auto") .arg("start= auto")
.arg("DisplayName= \"OmniOil Edge Agent\"") .arg("DisplayName= \"OmniOil Edge Agent\"")
.output() .output()
.context("Failed to execute sc.exe")?; .context("Failed to execute sc create")?;
if output.status.success() { if output.status.success() {
println!("✅ Service '{}' installed successfully.", SERVICE_NAME); println!("✅ Service '{}' installed successfully.", SERVICE_NAME);
// Configurar el servicio para reiniciarse automáticamente tras fallo
// Reiniciar tras 5s (primer fallo), 15s (segundo), 30s (posteriores)
let _ = std::process::Command::new("sc")
.arg("failure")
.arg(SERVICE_NAME)
.arg("reset= 0")
.arg("actions= restart/5000/restart/15000/restart/30000")
.output();
let _ = std::process::Command::new("sc") let _ = std::process::Command::new("sc")
.arg("start") .arg("start")
.arg(SERVICE_NAME) .arg(SERVICE_NAME)

View File

@@ -154,6 +154,38 @@ async fn install_docker_windows() -> Result<()> {
Ok(()) Ok(())
} }
/// Intenta iniciar el demonio de Docker si no está corriendo.
pub async fn try_start_docker_daemon() -> Result<()> {
if cfg!(windows) {
// 1. Intentar vía Net Start (si está como servicio)
let _ = Command::new("net")
.args(["start", "com.docker.service"])
.output();
// 2. Intentar buscar el ejecutable de Docker Desktop
let common_paths = [
r"C:\Program Files\Docker\Docker\Docker Desktop.exe",
r"C:\Program Files\Docker\Docker\resources\bin\docker.exe",
];
for path in common_paths {
if std::path::Path::new(path).exists() {
let _ = Command::new(path).spawn();
break;
}
}
} else {
// En Linux, intentar systemctl
let _ = Command::new("sudo")
.args(["systemctl", "start", "docker"])
.output();
}
// Esperar unos segundos a que levante
tokio::time::sleep(std::time::Duration::from_secs(5)).await;
Ok(())
}
pub(crate) fn check_command_exists(cmd: &str) -> bool { pub(crate) fn check_command_exists(cmd: &str) -> bool {
if cfg!(windows) { if cfg!(windows) {
Command::new("where") Command::new("where")

View File

@@ -0,0 +1,22 @@
@echo off
REM OmniOil Edge Agent - Service Installer
REM Este script se ejecuta durante la instalacion del MSI con priviliegios SYSTEM
set SERVICE_NAME=OmniOilEdgeAgent
set DISPLAY_NAME=OmniOil Edge Agent
set DESCRIPTION=Edge monitoring agent for OmniOil industrial platform
set BINARY_PATH=%~dp0edge-agent.exe
REM Detener y eliminar servicio anterior si existe
sc stop %SERVICE_NAME% >nul 2>&1
sc delete %SERVICE_NAME% >nul 2>&1
timeout /t 2 /nobreak >nul
REM Instalar el servicio
sc create %SERVICE_NAME% binPath= "\"%BINARY_PATH%\"" start= auto DisplayName= "%DISPLAY_NAME%"
sc description %SERVICE_NAME% "%DESCRIPTION%"
REM Iniciar el servicio
sc start %SERVICE_NAME%
exit /b 0

View File

@@ -18,7 +18,7 @@
Compressed='yes' Compressed='yes'
SummaryCodepage='1252' /> SummaryCodepage='1252' />
<MajorUpgrade DowngradeErrorMessage="A newer version of [ProductName] is already installed." /> <MajorUpgrade AllowSameVersionUpgrades="yes" DowngradeErrorMessage="Una versión más reciente de [ProductName] ya está instalada." />
<Media Id='1' Cabinet='Sample.cab' EmbedCab='yes' DiskPrompt="CD-ROM #1" /> <Media Id='1' Cabinet='Sample.cab' EmbedCab='yes' DiskPrompt="CD-ROM #1" />
<Property Id='DiskPrompt' Value="OmniOil Edge Agent Installation [1]" /> <Property Id='DiskPrompt' Value="OmniOil Edge Agent Installation [1]" />
@@ -27,15 +27,16 @@
<Directory Id='OmniOilFolder' Name='OmniOil'> <Directory Id='OmniOilFolder' Name='OmniOil'>
<Directory Id='INSTALLDIR' Name='EdgeAgent'> <Directory Id='INSTALLDIR' Name='EdgeAgent'>
<Component Id='MainExecutable' Guid='*'> <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"
Type="ownProcess" Type="ownProcess"
Name="OmniOilEdgeAgent" Name="OmniOilEdgeAgent"
DisplayName="OmniOil Edge Agent" DisplayName="OmniOil Edge Agent"
Description="Agente de borde para orquestación de contenedores OmniOil." Description="Edge agent for OmniOil container orchestration (v{{VERSION}})."
Start="auto" Start="auto"
Account="LocalSystem" Account="LocalSystem"
ErrorControl="normal" /> ErrorControl="normal" />

View File

@@ -0,0 +1,12 @@
@echo off
REM OmniOil Edge Agent - Service Uninstaller
REM Este script se ejecuta durante la desinstalacion del MSI con privilegios SYSTEM
set SERVICE_NAME=OmniOilEdgeAgent
REM Detener y eliminar el servicio
sc stop %SERVICE_NAME% >nul 2>&1
timeout /t 3 /nobreak >nul
sc delete %SERVICE_NAME% >nul 2>&1
exit /b 0

View File

@@ -6,7 +6,7 @@ edition = "2024"
[dependencies] [dependencies]
shared-lib = { path = "../shared-lib" } shared-lib = { path = "../shared-lib" }
tokio = { version = "1.50.0", features = ["full"] } tokio = { version = "1.50.0", features = ["full"] }
sqlx = { version = "0.8.6", features = ["runtime-tokio-native-tls", "postgres", "uuid", "chrono", "json"] } sqlx = { version = "0.8.6", features = ["runtime-tokio-rustls", "postgres", "uuid", "chrono", "json", "bigdecimal"] }
serde = { version = "1.0.228", features = ["derive"] } serde = { version = "1.0.228", features = ["derive"] }
serde_json = "1.0.149" serde_json = "1.0.149"
chrono = "0.4.44" chrono = "0.4.44"

View File

@@ -16,7 +16,7 @@ ENV CARGO_INCREMENTAL=0
# ============================================================================= # =============================================================================
FROM base AS planner FROM base AS planner
# Copiamos solo los archivos que definen dependencias (workspace y miembros) # Copiamos solo los archivos que definen dependencias (workspace y miembros)
COPY Cargo.toml Cargo.loc[k] ./ COPY Cargo.toml Cargo.lock ./
COPY services/shared-lib/Cargo.toml services/shared-lib/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/flow-generator/Cargo.toml services/flow-generator/Cargo.toml
COPY services/backend-api/Cargo.toml services/backend-api/Cargo.toml COPY services/backend-api/Cargo.toml services/backend-api/Cargo.toml
@@ -25,6 +25,7 @@ COPY services/json-generator/Cargo.toml services/json-generator/Cargo.toml
COPY services/events-relay/Cargo.toml services/events-relay/Cargo.toml COPY services/events-relay/Cargo.toml services/events-relay/Cargo.toml
COPY services/build-orchestrator/Cargo.toml services/build-orchestrator/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/edge-agent/Cargo.toml services/edge-agent/Cargo.toml
COPY services/telemetry-ingestor/Cargo.toml services/telemetry-ingestor/Cargo.toml
# Creamos estructuras dummy para que cargo metadata no falle (muy importante para la caché) # Creamos estructuras dummy para que cargo metadata no falle (muy importante para la caché)
RUN mkdir -p services/shared-lib/src && touch services/shared-lib/src/lib.rs && \ RUN mkdir -p services/shared-lib/src && touch services/shared-lib/src/lib.rs && \
@@ -34,7 +35,8 @@ RUN mkdir -p services/shared-lib/src && touch services/shared-lib/src/lib.rs &&
mkdir -p services/json-generator/src && echo "fn main() {}" > services/json-generator/src/main.rs && \ mkdir -p services/json-generator/src && echo "fn main() {}" > services/json-generator/src/main.rs && \
mkdir -p services/events-relay/src && echo "fn main() {}" > services/events-relay/src/main.rs && \ mkdir -p services/events-relay/src && echo "fn main() {}" > services/events-relay/src/main.rs && \
mkdir -p services/build-orchestrator/src && echo "fn main() {}" > services/build-orchestrator/src/main.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/edge-agent/src && echo "fn main() {}" > services/edge-agent/src/main.rs && \
mkdir -p services/telemetry-ingestor/src && echo "fn main() {}" > services/telemetry-ingestor/src/main.rs
# Generamos la receta solo para este binario # Generamos la receta solo para este binario
RUN cargo chef prepare --recipe-path recipe.json --bin events-relay RUN cargo chef prepare --recipe-path recipe.json --bin events-relay

View File

@@ -424,6 +424,7 @@ mod tests {
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,
created_at: Utc::now(), created_at: Utc::now(),
updated_at: Utc::now(), updated_at: Utc::now(),
}; };

File diff suppressed because it is too large Load Diff

View File

@@ -7,14 +7,14 @@
"dev": "vite", "dev": "vite",
"build": "vite build", "build": "vite build",
"lint": "eslint .", "lint": "eslint .",
"preview": "vite preview" "preview": "vite preview",
"test": "vitest run"
}, },
"dependencies": { "dependencies": {
"@hookform/resolvers": "^5.2.2", "@hookform/resolvers": "^5.2.2",
"@tanstack/react-query": "^5.90.21", "@tanstack/react-query": "^5.90.21",
"class-variance-authority": "^0.7.1", "class-variance-authority": "^0.7.1",
"clsx": "^2.1.1", "clsx": "^2.1.1",
"dexie": "^4.4.2",
"geist": "^1.7.0", "geist": "^1.7.0",
"lucide-react": "^0.577.0", "lucide-react": "^0.577.0",
"radix-ui": "^1.4.3", "radix-ui": "^1.4.3",
@@ -25,7 +25,6 @@
"recharts": "^3.8.0", "recharts": "^3.8.0",
"tailwind-merge": "^3.5.0", "tailwind-merge": "^3.5.0",
"tw-animate-css": "^1.4.0", "tw-animate-css": "^1.4.0",
"workbox-window": "^7.4.0",
"zod": "^4.3.6", "zod": "^4.3.6",
"zustand": "^5.0.11" "zustand": "^5.0.11"
}, },
@@ -40,10 +39,11 @@
"eslint-plugin-react-hooks": "^7.0.1", "eslint-plugin-react-hooks": "^7.0.1",
"eslint-plugin-react-refresh": "^0.4.24", "eslint-plugin-react-refresh": "^0.4.24",
"globals": "^16.5.0", "globals": "^16.5.0",
"jsdom": "^26.1.0",
"postcss": "^8.5.6", "postcss": "^8.5.6",
"tailwindcss": "^4.1.18", "tailwindcss": "^4.1.18",
"typescript": "^5.9.3", "typescript": "^5.9.3",
"vite": "^7.2.4", "vite": "^7.2.4",
"vite-plugin-pwa": "^1.2.0" "vitest": "^3.2.4"
} }
} }

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,79 @@
import type { ReactNode } from 'react'
import { useEffect } from 'react'
import { useAuthStore } from '@/features/auth/stores/auth-store'
import { SessionCoordinator } from '@/lib/api/client'
interface SessionBootstrapProps {
children: ReactNode
}
export default function SessionBootstrap({ children }: SessionBootstrapProps) {
const accessToken = useAuthStore((state) => state.accessToken)
const refreshToken = useAuthStore((state) => state.refreshToken)
const user = useAuthStore((state) => state.user)
const hasBootstrapped = useAuthStore((state) => state.hasBootstrapped)
const isBootstrapping = useAuthStore((state) => state.isBootstrapping)
const beginBootstrap = useAuthStore((state) => state.beginBootstrap)
const completeBootstrap = useAuthStore((state) => state.completeBootstrap)
const logout = useAuthStore((state) => state.logout)
useEffect(() => {
if (hasBootstrapped || isBootstrapping) {
return
}
let cancelled = false
const bootstrap = async () => {
beginBootstrap()
if (accessToken) {
if (!cancelled) {
completeBootstrap()
}
return
}
if (!refreshToken || !user?.email) {
if (user) {
logout('bootstrap-missing-refresh')
}
if (!cancelled) {
completeBootstrap()
}
return
}
const refreshedToken = await SessionCoordinator.refresh()
if (!refreshedToken) {
logout('bootstrap-refresh-failed')
}
if (!cancelled) {
completeBootstrap()
}
}
void bootstrap()
return () => {
cancelled = true
}
}, [
accessToken,
refreshToken,
user,
hasBootstrapped,
isBootstrapping,
beginBootstrap,
completeBootstrap,
logout,
])
if (!hasBootstrapped) {
return null
}
return children
}

View File

@@ -1,6 +1,5 @@
import { Outlet, NavLink, useNavigate } from 'react-router-dom' import { Outlet, NavLink, useNavigate } from 'react-router-dom'
import { useAuthStore } from '@/features/auth/stores/auth-store' import { useAuthStore } from '@/features/auth/stores/auth-store'
import { useSyncStore } from '@/lib/sync/sync-store'
import { import {
LayoutDashboard, LayoutDashboard,
ShieldCheck, ShieldCheck,
@@ -9,74 +8,35 @@ import {
Settings, Settings,
LogOut, LogOut,
Droplets, Droplets,
MapPin,
Cpu,
History,
Users,
Wifi,
WifiOff,
Radio,
FolderOpen, FolderOpen,
Activity, Activity,
Smartphone, Globe2,
Zap, HardDrive
Gauge,
Thermometer,
Layers,
ClipboardList,
FileText,
Wrench,
} 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'
import { Separator } from '@/components/ui/separator'
const navSections = [ const navSections = [
{ {
label: 'VISTA GENERAL', label: 'GENERAL',
items: [ items: [
{ to: '/app/admin/dashboard', label: 'Dashboard', icon: LayoutDashboard }, { to: '/app/admin/dashboard', label: 'Panorama', icon: LayoutDashboard },
{ to: '/app/admin/projects', label: 'Proyectos', icon: FolderOpen }, { to: '/app/admin/projects', label: 'Proyectos Edge', icon: FolderOpen },
{ to: '/app/admin/validation', label: 'Validación', icon: ShieldCheck }, { to: '/app/admin/validation', label: 'Auditoría', icon: ShieldCheck },
], ],
}, },
{ {
label: 'OPERACIONES', label: 'OPERACIONES & ANALÍTICA',
items: [ items: [
{ to: '/app/admin/locations', label: 'Ubicaciones', icon: MapPin }, { to: '/app/admin/anh-report', label: 'Reportes ANH', icon: FileJson },
{ to: '/app/admin/equipment', label: 'Equipos', icon: Cpu }, { to: '/app/admin/realtime', label: 'Telemetría', icon: Activity },
{ to: '/app/admin/history', label: 'Histórico', icon: History },
{ to: '/app/admin/operators', label: 'Operarios', icon: Users },
], ],
}, },
{ {
label: 'SISTEMA', label: 'CONFIGURACIÓN',
items: [ items: [
{ to: '/app/admin/settings', label: 'Ajustes Globales', icon: Settings },
{ to: '/app/admin/alerts', label: 'Alertas', icon: Bell, badge: 3 }, { to: '/app/admin/alerts', label: 'Alertas', icon: Bell, badge: 3 },
{ to: '/app/admin/anh-report', label: 'Reporte ANH', icon: FileJson },
{ to: '/app/admin/settings', label: 'Configuración', icon: Settings },
],
},
]
const dataSourceGroups = [
{
label: 'Edge Agent (HF)',
icon: Zap,
items: [
{ to: '/app/admin/hf/pressure', label: 'Presión', icon: Gauge },
{ to: '/app/admin/hf/flow', label: 'Caudal', icon: Activity },
{ to: '/app/admin/hf/temperature', label: 'Temperatura', icon: Thermometer },
{ to: '/app/admin/hf/tanks', label: 'Nivel de Tanques', icon: Layers },
],
},
{
label: 'PWA (LF)',
icon: Smartphone,
items: [
{ to: '/app/admin/pwa/inspections', label: 'Inspecciones', icon: ClipboardList },
{ to: '/app/admin/pwa/field-reports', label: 'Reportes de Campo', icon: FileText },
{ to: '/app/admin/pwa/maintenance', label: 'Mantenimiento', icon: Wrench },
], ],
}, },
] ]
@@ -84,9 +44,12 @@ const dataSourceGroups = [
export default function AdminLayout() { export default function AdminLayout() {
const user = useAuthStore((s) => s.user) const user = useAuthStore((s) => s.user)
const logout = useAuthStore((s) => s.logout) const logout = useAuthStore((s) => s.logout)
const projects = useAuthStore((s) => s.projects)
const activeProjectId = useAuthStore((s) => s.activeProjectId)
const setActiveProject = useAuthStore((s) => s.setActiveProject)
const navigate = useNavigate() const navigate = useNavigate()
const isOnline = useSyncStore((s) => s.isOnline)
const pendingCount = useSyncStore((s) => s.pendingCount) const activeProject = projects.find((p) => p.id === activeProjectId)
const handleLogout = () => { const handleLogout = () => {
logout() logout()
@@ -94,48 +57,77 @@ export default function AdminLayout() {
} }
return ( return (
<div className="flex h-screen bg-background"> <div className="flex h-screen bg-background overflow-hidden selection:bg-primary/30">
{/* Sidebar */} {/* SIDEBAR - Panel Izquierdo Glassmórfico */}
<aside className="flex flex-col w-56 border-r border-border bg-sidebar shrink-0"> <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">
{/* Logo */} {/* Decoración de fondo */}
<div className="flex items-center gap-2.5 px-4 h-14 border-b border-border"> <div className="absolute top-0 inset-x-0 h-40 bg-gradient-to-b from-primary/10 to-transparent pointer-events-none" />
<div className="flex items-center justify-center h-8 w-8 rounded-lg bg-primary shrink-0">
<Droplets className="h-4 w-4 text-primary-foreground" /> {/* Brand/Logo */}
<div className="flex items-center gap-3 px-6 h-20 shrink-0 relative z-10">
<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">
<Droplets className="h-5 w-5 text-primary-foreground" />
</div> </div>
<div className="min-w-0"> <div className="min-w-0">
<p className="text-sm font-bold text-foreground leading-none">OmniOil</p> <h1 className="text-base font-black tracking-wide text-foreground leading-tight">OmniOil</h1>
<p className="text-[10px] text-muted-foreground mt-0.5">Analytics</p> <p className="text-xs text-primary font-medium uppercase tracking-widest mt-0.5">Control Center</p>
</div> </div>
</div> </div>
{/* Nav */} {/* Global Project Selector */}
<nav className="flex-1 overflow-y-auto py-3 px-2 space-y-4"> <div className="px-5 mb-4 relative z-10">
<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">
<HardDrive className="h-3.5 w-3.5 text-primary" /> Proyecto Activo
</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>
{/* Navigation Wrapper */}
<div className="flex-1 overflow-y-auto custom-scrollbar px-3 space-y-6 pb-6 relative z-10">
{navSections.map((section) => ( {navSections.map((section) => (
<div key={section.label}> <div key={section.label}>
<div className="px-3 mb-1"> <div className="px-4 mb-2">
<span className="text-xs font-medium uppercase tracking-wider text-muted-foreground"> <span className="text-[10px] font-bold uppercase tracking-widest text-muted-foreground/80">
{section.label} {section.label}
</span> </span>
</div> </div>
<div className="space-y-0.5"> <div className="space-y-1">
{section.items.map((item) => ( {section.items.map((item) => (
<NavLink <NavLink
key={item.to} key={item.to}
to={item.to} to={item.to}
title={item.label}
className={({ isActive }) => className={({ isActive }) =>
cn( cn(
'flex items-center gap-2 rounded-lg px-3 py-2 text-sm transition-colors', 'group flex items-center gap-3 rounded-xl px-4 py-2.5 text-sm font-medium transition-all duration-200',
isActive isActive
? 'bg-accent/10 text-foreground font-medium' ? 'bg-primary/10 text-primary shadow-[inset_2px_0_0_0_oklch(0.65_0.18_160)]'
: 'text-muted-foreground hover:text-foreground hover:bg-secondary' : 'text-muted-foreground hover:bg-secondary/60 hover:text-foreground'
) )
} }
> >
<item.icon className="h-4 w-4 shrink-0" /> <item.icon className="h-4 w-4 shrink-0 transition-transform group-hover:scale-110" />
<span className="flex-1">{item.label}</span> <span className="flex-1">{item.label}</span>
{'badge' in item && item.badge ? ( {'badge' in item && item.badge ? (
<Badge className="bg-destructive text-destructive-foreground text-xs h-5 px-1.5 ml-auto"> <Badge className="bg-destructive/10 text-destructive border-0 text-[10px] h-5 px-1.5 ml-auto">
{item.badge} {item.badge}
</Badge> </Badge>
) : null} ) : null}
@@ -144,102 +136,53 @@ export default function AdminLayout() {
</div> </div>
</div> </div>
))} ))}
{/* Fuentes de datos — grouped */}
<div>
<div className="px-3 mb-1">
<span className="text-xs font-medium uppercase tracking-wider text-muted-foreground">
FUENTES DE DATOS
</span>
</div>
{dataSourceGroups.map((group) => (
<div key={group.label} className="mb-1">
<div className="flex items-center gap-2 px-3 py-1.5 text-xs font-semibold text-muted-foreground/80">
<group.icon className="h-3.5 w-3.5 shrink-0" />
<span>{group.label}</span>
</div>
<div className="space-y-0.5 ml-3">
{group.items.map((item) => (
<NavLink
key={item.to}
to={item.to}
title={item.label}
className={({ isActive }) =>
cn(
'flex items-center gap-2 rounded-lg px-3 py-1.5 text-xs transition-colors',
isActive
? 'bg-accent/10 text-foreground font-medium'
: 'text-muted-foreground hover:text-foreground hover:bg-secondary'
)
}
>
<item.icon className="h-3.5 w-3.5 shrink-0" />
<span className="flex-1">{item.label}</span>
</NavLink>
))}
</div>
</div>
))}
</div>
</nav>
<Separator />
{/* Connection Status */}
<div className="p-3 space-y-1">
<p className="px-3 text-xs font-medium uppercase tracking-wider text-muted-foreground mb-2">
ESTADO CONEXIÓN
</p>
<div className="flex items-center gap-2 px-3 py-1.5">
{isOnline ? (
<Wifi className="h-3.5 w-3.5 text-success shrink-0" />
) : (
<WifiOff className="h-3.5 w-3.5 text-destructive shrink-0" />
)}
<span className="text-xs text-muted-foreground flex-1">Backend</span>
<span className={cn('text-xs font-medium', isOnline ? 'text-success' : 'text-destructive')}>
{isOnline ? 'Conectado' : 'Offline'}
</span>
</div>
<div className="flex items-center gap-2 px-3 py-1.5">
<Radio className="h-3.5 w-3.5 text-muted-foreground shrink-0" />
<span className="text-xs text-muted-foreground flex-1">PWA Sync</span>
{pendingCount > 0 ? (
<span className="text-xs font-medium text-warning">Pendiente ({pendingCount})</span>
) : (
<span className="text-xs font-medium text-success">Sincronizado</span>
)}
</div>
</div> </div>
<Separator /> {/* Server Status (Real) */}
<div className="px-5 pb-2 pt-4 relative z-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-success shrink-0" />
<div className="flex-1 text-xs">
<p className="text-foreground font-medium">Panel Gateway</p>
<p className="text-success opacity-80">En línea y Operativo</p>
</div>
<div className="h-2 w-2 rounded-full bg-success animate-pulse" />
</div>
</div>
{/* User + Logout */} {/* Footer User Profile */}
<div className="p-2"> <div className="p-4 relative z-10">
<div className="flex items-center gap-2 px-3 py-2"> <div className="bg-background/20 rounded-xl border border-border/30 p-2 flex items-center gap-3">
<div className="h-7 w-7 rounded-full bg-primary/20 flex items-center justify-center shrink-0"> <div className="h-9 w-9 rounded-lg bg-secondary flex items-center justify-center shrink-0 border border-border/50">
<Activity className="h-3.5 w-3.5 text-primary" /> <span className="text-sm font-bold text-foreground">
</div> {user?.email?.[0]?.toUpperCase() ?? 'U'}
<div className="flex-1 min-w-0"> </span>
<p className="text-xs font-medium text-foreground truncate">{user?.email}</p> </div>
<p className="text-[10px] text-muted-foreground capitalize">{user?.role}</p> <div className="flex-1 min-w-0">
</div> <p className="text-sm font-semibold text-foreground truncate">{user?.email}</p>
<p className="text-[10px] text-muted-foreground uppercase tracking-widest">{user?.role}</p>
</div>
<button
onClick={handleLogout}
title="Cerrar Sessión"
className="p-2 text-muted-foreground hover:text-destructive hover:bg-destructive/10 rounded-lg transition-colors"
aria-label="Cerrar sesión"
>
<LogOut className="h-4 w-4" />
</button>
</div> </div>
<button
onClick={handleLogout}
className="flex items-center gap-2 w-full rounded-lg px-3 py-2 text-sm text-muted-foreground hover:text-destructive hover:bg-destructive/10 transition-colors"
>
<LogOut className="h-4 w-4 shrink-0" />
Cerrar sesión
</button>
</div> </div>
</aside> </aside>
{/* Main */} {/* ÁREA PRINCIPAL */}
<div className="flex-1 flex flex-col overflow-hidden"> <main className="flex-1 relative flex flex-col min-w-0 overflow-y-auto custom-scrollbar">
<main className="flex-1 overflow-y-auto"> {/* Fondo gradiente sutil principal */}
<Outlet /> <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" />
</main>
</div> <div className="relative flex-1 p-6 md:p-8 lg:p-10 w-full max-w-[1600px] mx-auto z-10">
<Outlet />
</div>
</main>
</div> </div>
) )
} }

View File

@@ -1,67 +0,0 @@
import { Outlet, NavLink } from 'react-router-dom'
import { Home, FlaskConical, ArrowLeftRight, CircleOff, RefreshCw, Droplets } from 'lucide-react'
import { useSyncStore } from '@/lib/sync/sync-store'
import { useNetworkStatus } from '@/lib/sync/use-network-status'
const navItems = [
{ to: '/app/field', label: 'Inicio', icon: Home, end: true },
{ to: '/app/field/measurements', label: 'Medición', icon: FlaskConical, end: false },
{ to: '/app/field/movements', label: 'Movimientos', icon: ArrowLeftRight, end: false },
{ to: '/app/field/well-shutdowns', label: 'Paradas', icon: CircleOff, end: false },
{ to: '/app/field/sync', label: 'Sync', icon: RefreshCw, end: false },
]
export default function FieldLayout() {
useNetworkStatus()
const isOnline = useSyncStore((s) => s.isOnline)
const pendingCount = useSyncStore((s) => s.pendingCount)
return (
<div className="flex flex-col h-screen bg-[var(--background)]">
{/* Header */}
<header className="flex items-center justify-between h-14 px-4 border-b border-[var(--border)] bg-[var(--card)] shrink-0">
<div className="flex items-center gap-2">
<Droplets className="h-5 w-5 text-cyan-400" />
<span className="text-base font-bold text-white tracking-tight">OMNIOIL</span>
</div>
<div className="flex items-center gap-2">
{pendingCount > 0 && (
<span className="flex items-center gap-1 rounded-full bg-amber-500/10 border border-amber-500/20 px-2 py-0.5 text-xs font-medium text-amber-400">
<RefreshCw className="h-3 w-3" />
{pendingCount}
</span>
)}
<span
className={`h-2 w-2 rounded-full ${isOnline ? 'bg-emerald-400' : 'bg-red-400'}`}
title={isOnline ? 'Online' : 'Sin conexión'}
/>
</div>
</header>
{/* Content */}
<main className="flex-1 overflow-y-auto p-4">
<Outlet />
</main>
{/* Bottom Nav */}
<nav className="flex border-t border-[var(--border)] bg-[var(--card)] shrink-0">
{navItems.map((item) => (
<NavLink
key={item.to}
to={item.to}
end={item.end}
className={({ isActive }) =>
`flex flex-1 flex-col items-center justify-center gap-1 py-2.5 text-[10px] font-medium transition-colors ${
isActive ? 'text-cyan-400' : 'text-gray-500 hover:text-gray-300'
}`
}
>
<item.icon className="h-5 w-5" />
{item.label}
</NavLink>
))}
</nav>
</div>
)
}

View File

@@ -1,5 +1,7 @@
import { Navigate, Outlet } from 'react-router-dom' import { useEffect } from 'react'
import { Navigate, Outlet, useLocation } from 'react-router-dom'
import { useAuthStore } from '@/features/auth/stores/auth-store' import { useAuthStore } from '@/features/auth/stores/auth-store'
import { resolveAuthRoute } from '@/features/auth/lib/domain-routing'
interface ProtectedRouteProps { interface ProtectedRouteProps {
allowedRoles?: string[] allowedRoles?: string[]
@@ -8,13 +10,38 @@ interface ProtectedRouteProps {
export default function ProtectedRoute({ allowedRoles }: ProtectedRouteProps) { export default function ProtectedRoute({ allowedRoles }: ProtectedRouteProps) {
const token = useAuthStore((s) => s.token) const token = useAuthStore((s) => s.token)
const user = useAuthStore((s) => s.user) const user = useAuthStore((s) => s.user)
const location = useLocation()
const redirect = resolveAuthRoute({
role: user?.role,
pathname: location.pathname,
search: location.search,
origin: typeof window === 'undefined' ? undefined : window.location.origin,
})
useEffect(() => {
if (redirect.externalHref) {
window.location.replace(redirect.externalHref)
}
}, [redirect.externalHref])
if (!token) { if (!token) {
return <Navigate to="/login" replace /> return <Navigate to="/login" replace />
} }
if (allowedRoles && user && !allowedRoles.includes(user.role)) { if (redirect.externalHref) {
return <Navigate to="/login" replace /> return null
}
if (redirect.internalPath) {
return <Navigate to={redirect.internalPath} replace />
}
if (allowedRoles && user) {
const uRole = user.role.toLowerCase()
const hasRole = allowedRoles.some(r => r.toLowerCase() === uRole)
if (!hasRole && uRole !== 'superadmin') {
return <Navigate to="/login" replace />
}
} }
return <Outlet /> return <Outlet />

View File

@@ -3,15 +3,13 @@ import { createBrowserRouter, Navigate } from 'react-router-dom'
import LoginPage from '@/features/auth/pages/LoginPage' import LoginPage from '@/features/auth/pages/LoginPage'
import RegisterPage from '@/features/auth/pages/RegisterPage' import RegisterPage from '@/features/auth/pages/RegisterPage'
import AdminLayout from '@/app/layouts/AdminLayout' import AdminLayout from '@/app/layouts/AdminLayout'
import FieldLayout from '@/app/layouts/FieldLayout'
import OfflinePage from '@/features/offline/OfflinePage'
import DashboardPage from '@/features/admin/dashboard/pages/DashboardPage' import DashboardPage from '@/features/admin/dashboard/pages/DashboardPage'
import ProjectListPage from '@/features/admin/projects/pages/ProjectListPage' import ProjectListPage from '@/features/admin/projects/pages/ProjectListPage'
import CreateProjectPage from '@/features/admin/projects/pages/CreateProjectPage' 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 MeasurementPage = lazy(() => import('@/features/field/measurements/pages/MeasurementPage'))
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'))
@@ -36,13 +34,10 @@ export const router = createBrowserRouter([
path: '/register', path: '/register',
element: <RegisterPage />, element: <RegisterPage />,
}, },
{
path: '/offline',
element: <OfflinePage />,
},
{ {
path: '/app/admin', path: '/app/admin',
element: <ProtectedRoute allowedRoles={['admin']} />, element: <ProtectedRoute />,
children: [ children: [
{ {
element: <AdminLayout />, element: <AdminLayout />,
@@ -62,22 +57,7 @@ export const router = createBrowserRouter([
}, },
], ],
}, },
{
path: '/app/field',
element: <ProtectedRoute allowedRoles={['operador']} />,
children: [
{
element: <FieldLayout />,
children: [
{ index: true, element: <ComingSoon title="Portal de Campo" /> },
{ path: 'measurements', element: <Suspense fallback={null}><MeasurementPage /></Suspense> },
{ path: 'movements', element: <ComingSoon title="Registro de Movimientos" /> },
{ path: 'well-shutdowns', element: <ComingSoon title="Paradas de Pozo" /> },
{ path: 'sync', element: <ComingSoon title="Estado de Sincronización" /> },
],
},
],
},
{ {
path: '/', path: '/',
element: <Navigate to="/login" replace />, element: <Navigate to="/login" replace />,

View File

@@ -2,16 +2,10 @@ import { useEffect, useState } from 'react'
import { fetchDashboardSummary } from '../api/dashboard-api' import { fetchDashboardSummary } from '../api/dashboard-api'
import { import {
Gauge, Thermometer, Wind, Droplets, Gauge, Thermometer, Wind, Droplets,
TrendingUp, TrendingDown, Search, RefreshCw, Activity, Zap, Router, ShieldAlert
Bell, ChevronDown, Radio, Wifi,
} from 'lucide-react' } from 'lucide-react'
import {
AreaChart, Area, BarChart, Bar,
XAxis, YAxis, CartesianGrid, Tooltip, ResponsiveContainer,
} from 'recharts'
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card' import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'
import { Badge } from '@/components/ui/badge' import { Badge } from '@/components/ui/badge'
import { Button } from '@/components/ui/button'
import { cn } from '@/lib/utils/cn' import { cn } from '@/lib/utils/cn'
interface AssetData { interface AssetData {
@@ -20,54 +14,9 @@ interface AssetData {
telemetry: Record<string, unknown> telemetry: Record<string, unknown>
} }
const presionData = [
{ t: '00:00', v: 1240 }, { t: '00:05', v: 1245 }, { t: '00:10', v: 1252 },
{ t: '00:15', v: 1265 }, { t: '00:20', v: 1258 }, { t: '00:25', v: 1270 },
{ t: '00:30', v: 1262 }, { t: '00:35', v: 1255 }, { t: '00:40', v: 1248 },
{ t: '00:45', v: 1260 }, { t: '00:50', v: 1268 }, { t: '00:55', v: 1255 },
]
const temperaturaData = [
{ t: '00:00', v: 68 }, { t: '00:05', v: 70 }, { t: '00:10', v: 71 },
{ t: '00:15', v: 73 }, { t: '00:20', v: 72 }, { t: '00:25', v: 74 },
{ t: '00:30', v: 73 }, { t: '00:35', v: 72 }, { t: '00:40', v: 71 },
{ t: '00:45', v: 73 }, { t: '00:50', v: 74 }, { t: '00:55', v: 72 },
]
const flujoData = [
{ t: '00:00', v: 1180 }, { t: '00:05', v: 1200 }, { t: '00:10', v: 1220 },
{ t: '00:15', v: 1240 }, { t: '00:20', v: 1230 }, { t: '00:25', v: 1260 },
{ t: '00:30', v: 1245 }, { t: '00:35', v: 1235 }, { t: '00:40', v: 1225 },
{ t: '00:45', v: 1250 }, { t: '00:50', v: 1265 }, { t: '00:55', v: 1256 },
]
const produccionMensual = [
{ mes: 'Ene', v: 2400 }, { mes: 'Feb', v: 2200 }, { mes: 'Mar', v: 2600 },
{ mes: 'Abr', v: 2750 }, { mes: 'May', v: 2900 }, { mes: 'Jun', v: 2847 },
]
const chartTabs = [
{ key: 'presion', label: 'Presión', data: presionData, unit: 'PSI', color: 'var(--chart-2)' },
{ key: 'temperatura', label: 'Temperatura', data: temperaturaData, unit: '°C', color: 'var(--chart-3)' },
{ key: 'flujo', label: 'Flujo', data: flujoData, unit: 'm³/h', color: 'var(--chart-1)' },
]
const sensors = [
{ id: 'S-001', name: 'Pozo Alpha', status: 'active' },
{ id: 'S-002', name: 'Tanque TK-101', status: 'active' },
{ id: 'S-003', name: 'Separador SP-201', status: 'active' },
{ id: 'S-004', name: 'Bomba BM-301', status: 'active' },
{ id: 'S-005', name: 'Medidor M-401', status: 'warning' },
{ id: 'S-006', name: 'Sensor P-501', status: 'error' },
]
const statusDot: Record<string, string> = {
active: 'bg-success',
warning: 'bg-warning',
error: 'bg-destructive',
}
export default function DashboardPage() { export default function DashboardPage() {
const [assets, setAssets] = useState<AssetData[]>([]) const [assets, setAssets] = useState<AssetData[]>([])
const [loading, setLoading] = useState(true) const [loading, setLoading] = useState(true)
const [activeTab, setActiveTab] = useState('presion')
useEffect(() => { useEffect(() => {
fetchDashboardSummary() fetchDashboardSummary()
@@ -76,276 +25,148 @@ export default function DashboardPage() {
.finally(() => setLoading(false)) .finally(() => setLoading(false))
}, []) }, [])
const activeChart = chartTabs.find((t) => t.key === activeTab)!
const kpis = [ const kpis = [
{ {
label: 'Producción', label: 'Volumen Extraído',
value: '2,847', value: '',
unit: 'bbl/día', unit: 'bbl/d',
icon: Droplets, icon: Droplets,
color: 'text-chart-1', color: 'text-primary',
bg: 'bg-chart-1/10', bg: 'bg-primary/10',
trend: '+3.2%',
trendUp: true,
}, },
{ {
label: 'Presión Promedio', label: 'Inyección Gas',
value: '847', value: '',
unit: 'MSCF/d',
icon: Wind,
color: 'text-success',
bg: 'bg-success/10',
},
{
label: 'Presión Cabezal',
value: '—',
unit: 'PSI', unit: 'PSI',
icon: Gauge, icon: Gauge,
color: 'text-chart-2', color: 'text-info',
bg: 'bg-chart-2/10', bg: 'bg-info/10',
trend: '-1.4%',
trendUp: false,
}, },
{ {
label: 'Temperatura', label: 'Bomba (Frec.)',
value: '72.4', value: '',
unit: '°C', unit: 'Hz',
icon: Thermometer, icon: Activity,
color: 'text-chart-3', color: 'text-warning',
bg: 'bg-chart-3/10', bg: 'bg-warning/10',
trend: '+5.8%',
trendUp: true,
},
{
label: 'Flujo de Gas',
value: '1,256',
unit: 'm³/h',
icon: Wind,
color: 'text-chart-4',
bg: 'bg-chart-4/10',
trend: '+2.1%',
trendUp: true,
}, },
] ]
return ( return (
<div className="flex flex-col h-full"> <div className="flex flex-col h-full animate-in fade-in zoom-in-95 duration-500">
{/* Header */} {/* Header View */}
<div className="flex items-center gap-3 px-6 h-14 border-b border-border bg-card shrink-0"> <div className="mb-8">
<h1 className="text-base font-semibold">Dashboard</h1> <h1 className="text-3xl font-black text-foreground tracking-tight glow-text flex items-center gap-3">
<div className="flex items-center gap-2 ml-2"> Omniora Industrial Panorama
<button className="flex items-center gap-1.5 text-sm text-muted-foreground hover:text-foreground bg-secondary rounded-lg px-3 py-1.5 transition-colors"> <Badge className="bg-primary/20 text-primary border-primary/30 ml-2">LIVE</Badge>
Todas las ubicaciones <ChevronDown className="h-3.5 w-3.5" /> </h1>
</button> <p className="text-sm text-muted-foreground mt-2 font-medium">Control de telemetría de Edge Agents y adquisición SCADA.</p>
<button className="flex items-center gap-1.5 text-sm text-muted-foreground hover:text-foreground bg-secondary rounded-lg px-3 py-1.5 transition-colors">
Últimas 24h <ChevronDown className="h-3.5 w-3.5" />
</button>
</div>
<div className="flex items-center gap-2 ml-auto">
<button className="p-2 rounded-lg text-muted-foreground hover:text-foreground hover:bg-secondary transition-colors">
<Search className="h-4 w-4" />
</button>
<button className="p-2 rounded-lg text-muted-foreground hover:text-foreground hover:bg-secondary transition-colors">
<RefreshCw className="h-4 w-4" />
</button>
<button className="relative p-2 rounded-lg text-muted-foreground hover:text-foreground hover:bg-secondary transition-colors">
<Bell className="h-4 w-4" />
<span className="absolute top-1 right-1 h-2 w-2 rounded-full bg-destructive" />
</button>
<div className="flex items-center gap-2 pl-2 border-l border-border">
<div className="h-7 w-7 rounded-full bg-primary/20 flex items-center justify-center">
<span className="text-xs font-bold text-primary">J</span>
</div>
<span className="text-sm text-muted-foreground">Juan Díaz</span>
<ChevronDown className="h-3.5 w-3.5 text-muted-foreground" />
</div>
</div>
</div> </div>
{/* Content */} {/* Key Metrics / KPI Grid */}
<div className="flex-1 overflow-y-auto p-6 space-y-6"> <div className="grid gap-6 sm:grid-cols-2 lg:grid-cols-4 mb-8">
{/* KPI Cards */} {kpis.map((kpi, idx) => (
<div className="grid gap-4 sm:grid-cols-2 lg:grid-cols-4"> <Card
{kpis.map((kpi) => ( key={kpi.label}
<Card key={kpi.label} className="bg-card border-border"> className="glass-card border-0 overflow-hidden relative group hover:border-primary/50 transition-all duration-300 transform hover:-translate-y-1 hover:shadow-[0_10px_40px_-10px_rgba(var(--primary),0.3)]"
<CardContent className="p-5"> >
<div className="flex items-center gap-2 mb-3"> {/* Decal background */}
<div className={cn('p-1.5 rounded-lg', kpi.bg)}> <div className="absolute -right-6 -top-6 text-foreground/5 opacity-20 transform group-hover:scale-110 group-hover:opacity-30 transition-all duration-500 pointer-events-none">
<kpi.icon className={cn('h-4 w-4', kpi.color)} /> <kpi.icon className="w-32 h-32" strokeWidth={1} />
</div> </div>
<span className="text-sm text-muted-foreground">{kpi.label}</span>
<span className="ml-auto relative flex h-2 w-2">
<span className="animate-ping absolute h-full w-full rounded-full bg-success opacity-75" />
<span className="relative h-2 w-2 rounded-full bg-success" />
</span>
</div>
<div className="flex items-baseline gap-1">
<span className="text-4xl font-bold tabular-nums">{kpi.value}</span>
<span className="text-sm text-muted-foreground">{kpi.unit}</span>
</div>
<div className="mt-1.5 flex items-center gap-1 text-xs">
{kpi.trendUp ? (
<TrendingUp className="h-3 w-3 text-success" />
) : (
<TrendingDown className="h-3 w-3 text-destructive" />
)}
<span className={kpi.trendUp ? 'text-success' : 'text-destructive'}>{kpi.trend}</span>
<span className="text-muted-foreground">vs ayer</span>
</div>
</CardContent>
</Card>
))}
</div>
{/* Charts row */} <CardContent className="p-6 relative z-10">
<div className="grid gap-6 lg:grid-cols-3"> <div className="flex items-center gap-3 mb-4">
{/* Realtime chart */} <div className={cn('p-2.5 rounded-xl backdrop-blur-md', kpi.bg, "shadow-inner border border-white/5")}>
<Card className="bg-card border-border lg:col-span-2"> <kpi.icon className={cn('h-5 w-5 glow-icon', kpi.color)} />
<CardHeader className="pb-3">
<div className="flex items-center justify-between">
<div>
<CardTitle className="text-base font-semibold">Datos en Tiempo Real</CardTitle>
<p className="text-xs text-muted-foreground mt-0.5">Alta Frecuencia</p>
</div> </div>
<Badge className="bg-success/10 text-success border border-success/20 gap-1.5 text-xs"> <span className="text-sm font-semibold tracking-wider text-muted-foreground uppercase">{kpi.label}</span>
<span className="h-1.5 w-1.5 rounded-full bg-success animate-pulse" />
En vivo
</Badge>
</div> </div>
{/* Tab switcher */} <div className="flex items-baseline gap-2 mt-4">
<div className="flex gap-1 mt-3 bg-secondary rounded-lg p-1"> <span className="text-5xl font-black tabular-nums tracking-tighter text-foreground glow-text">{kpi.value}</span>
{chartTabs.map((tab) => ( <span className="text-base font-bold text-muted-foreground/70">{kpi.unit}</span>
<button
key={tab.key}
onClick={() => setActiveTab(tab.key)}
className={cn(
'flex-1 text-xs font-medium py-1.5 rounded-md transition-colors',
activeTab === tab.key
? 'bg-card text-foreground shadow-sm'
: 'text-muted-foreground hover:text-foreground'
)}
>
{tab.label}
</button>
))}
</div>
</CardHeader>
<CardContent>
<div className="h-52">
<ResponsiveContainer width="100%" height="100%">
<AreaChart data={activeChart.data}>
<defs>
<linearGradient id="gradActive" x1="0" y1="0" x2="0" y2="1">
<stop offset="5%" stopColor={activeChart.color} stopOpacity={0.3} />
<stop offset="95%" stopColor={activeChart.color} stopOpacity={0} />
</linearGradient>
</defs>
<CartesianGrid strokeDasharray="3 3" stroke="var(--border)" />
<XAxis dataKey="t" tick={{ fill: 'var(--muted-foreground)', fontSize: 10 }} axisLine={false} tickLine={false} />
<YAxis tick={{ fill: 'var(--muted-foreground)', fontSize: 10 }} axisLine={false} tickLine={false} />
<Tooltip contentStyle={{ backgroundColor: 'var(--card)', border: '1px solid var(--border)', borderRadius: '0.5rem', color: 'var(--foreground)', fontSize: '11px' }} />
<Area type="monotone" dataKey="v" stroke={activeChart.color} fill="url(#gradActive)" strokeWidth={2} name={`${activeChart.label} (${activeChart.unit})`} />
</AreaChart>
</ResponsiveContainer>
</div> </div>
</CardContent> </CardContent>
</Card> </Card>
))}
</div>
{/* Monthly production */} {/* Main Charts & Live Feed */}
<Card className="bg-card border-border"> <div className="grid gap-6 lg:grid-cols-3">
<CardHeader className="pb-3"> {/* Realtime Telemetry Grid */}
<CardTitle className="text-base font-semibold">Producción Mensual</CardTitle> <Card className="glass-card border-0 lg:col-span-2 relative overflow-hidden backdrop-blur-xl">
<p className="text-xs text-muted-foreground">Datos PWA Baja Frecuencia</p> <CardHeader className="pb-0 border-b border-border/20 mb-4 bg-background/20 p-5">
</CardHeader> <div className="flex items-center justify-between">
<CardContent> <div>
<div className="h-52"> <CardTitle className="text-lg font-bold text-foreground">Flujos Sensoriales en Vivo</CardTitle>
<ResponsiveContainer width="100%" height="100%"> <p className="text-xs text-muted-foreground mt-1">Conexión MQTT de ultra baja latencia</p>
<BarChart data={produccionMensual} barSize={20}>
<CartesianGrid strokeDasharray="3 3" stroke="var(--border)" vertical={false} />
<XAxis dataKey="mes" tick={{ fill: 'var(--muted-foreground)', fontSize: 10 }} axisLine={false} tickLine={false} />
<YAxis tick={{ fill: 'var(--muted-foreground)', fontSize: 10 }} axisLine={false} tickLine={false} />
<Tooltip contentStyle={{ backgroundColor: 'var(--card)', border: '1px solid var(--border)', borderRadius: '0.5rem', color: 'var(--foreground)', fontSize: '11px' }} />
<Bar dataKey="v" fill="var(--chart-1)" radius={[4, 4, 0, 0]} name="Producción (bbl)" />
</BarChart>
</ResponsiveContainer>
</div> </div>
</CardContent> <div className="flex items-center gap-2">
</Card> <span className="relative flex h-3 w-3">
</div> <span className="animate-ping absolute inline-flex h-full w-full rounded-full bg-primary opacity-75"></span>
<span className="relative inline-flex rounded-full h-3 w-3 bg-primary"></span>
</span>
<span className="text-xs font-semibold text-primary uppercase tracking-widest">Streaming</span>
</div>
</div>
</CardHeader>
<CardContent className="p-5">
<div className="h-72 w-full flex flex-col items-center justify-center gap-4 border border-dashed border-border/40 rounded-xl bg-background/30">
<Zap className="h-10 w-10 text-muted-foreground opacity-30" />
<div className="text-center">
<p className="text-sm font-semibold text-muted-foreground">Esperando series temporales</p>
<p className="text-xs text-muted-foreground/60 mt-1 max-w-sm">No se ha recibido telemetría en los últimos 5 minutos. Verifique que los Edge Agents estén desplegados.</p>
</div>
</div>
</CardContent>
</Card>
{/* Bottom row */} {/* Edge Agents Status */}
<div className="grid gap-6 lg:grid-cols-2"> <Card className="glass-card border-0 relative overflow-hidden">
{/* Sensors */} <CardHeader className="bg-background/20 p-5 border-b border-border/20">
<Card className="bg-card border-border"> <CardTitle className="text-lg font-bold flex items-center gap-2 text-foreground">
<CardHeader className="pb-3"> <Router className="h-5 w-5 text-primary glow-icon" />
<div className="flex items-center justify-between"> Edge Agents Activos
<div> </CardTitle>
<CardTitle className="text-base font-semibold flex items-center gap-2"> </CardHeader>
<Wifi className="h-4 w-4 text-primary" /> <CardContent className="p-0">
Sensores {loading ? (
</CardTitle> <div className="p-6 space-y-4">
<p className="text-xs text-muted-foreground mt-0.5">Monitoreo en tiempo real Alta Frecuencia</p> {[1, 2, 3].map((i) => <div key={i} className="h-12 w-full rounded-xl bg-secondary/50 animate-pulse" />)}
</div> </div>
<div className="flex items-center gap-1"> ) : assets.length === 0 ? (
{['active', 'active', 'active', 'active', 'warning', 'error'].map((s, i) => ( <div className="flex flex-col items-center justify-center py-16 px-6 text-center">
<span key={i} className={cn('h-2.5 w-2.5 rounded-full', statusDot[s])} /> <ShieldAlert className="h-10 w-10 text-muted-foreground opacity-30 mb-3" />
))} <p className="text-sm font-semibold text-foreground/80">Sin Agentes de Borde</p>
<p className="text-xs text-muted-foreground mt-1">Los controladores lógicos programables no están en comunicación.</p>
</div> </div>
</div> ) : (
</CardHeader> <div className="divide-y divide-border/30">
<CardContent className="p-0">
<div className="divide-y divide-border">
{sensors.map((sensor) => (
<div key={sensor.id} className="flex items-center gap-3 px-4 py-2.5">
<span className={cn('h-2 w-2 rounded-full shrink-0', statusDot[sensor.status])} />
<div className="flex-1 min-w-0">
<span className="text-sm font-medium">{sensor.name}</span>
</div>
<span className="text-xs text-muted-foreground font-mono">{sensor.id}</span>
</div>
))}
</div>
</CardContent>
</Card>
{/* PWA Sync */}
<Card className="bg-card border-border">
<CardHeader className="pb-3">
<div className="flex items-center justify-between">
<div>
<CardTitle className="text-base font-semibold flex items-center gap-2">
<Radio className="h-4 w-4 text-primary" />
Sincronización PWA
</CardTitle>
<p className="text-xs text-muted-foreground mt-0.5">Datos de campo Baja Frecuencia</p>
</div>
<Button size="sm" className="bg-primary text-primary-foreground hover:bg-primary/90 text-xs h-8">
<RefreshCw className="h-3.5 w-3.5 mr-1.5" />
Sincronizar
</Button>
</div>
</CardHeader>
<CardContent className="p-0">
{loading ? (
<div className="space-y-2 p-4">
{[1, 2, 3].map((i) => <div key={i} className="h-10 rounded-lg bg-muted animate-pulse" />)}
</div>
) : assets.length === 0 ? (
<div className="flex flex-col items-center justify-center py-10 px-4 text-center">
<Radio className="h-8 w-8 text-muted-foreground mb-2" />
<p className="text-sm text-muted-foreground">Sin datos sincronizados</p>
<p className="text-xs text-muted-foreground mt-1">Los operadores de campo no han enviado registros aún</p>
</div>
) : (
<div className="divide-y divide-border">
{assets.map((asset) => ( {assets.map((asset) => (
<div key={asset.asset} className="flex items-center gap-3 px-4 py-2.5"> <div key={asset.asset} className="flex items-center gap-4 px-6 py-4 hover:bg-secondary/20 transition-colors">
<span className="h-2 w-2 rounded-full bg-success shrink-0" /> <div className="h-9 w-9 rounded-lg bg-primary/10 flex items-center justify-center border border-primary/20 shrink-0">
<div className="flex-1 min-w-0"> <span className="h-2.5 w-2.5 rounded-full bg-success"></span>
<p className="text-sm font-medium">{asset.asset}</p> </div>
<p className="text-xs text-muted-foreground">{new Date(asset.last_updated).toLocaleString('es-CO')}</p> <div className="flex-1 min-w-0">
<p className="text-sm font-bold text-foreground truncate">{asset.asset}</p>
<p className="text-xs text-muted-foreground font-mono mt-0.5">
LT: {new Date(asset.last_updated).toISOString().replace('T', ' ').substring(0, 19)}
</p>
</div> </div>
<Badge className="bg-success/10 text-success border border-success/20 text-xs shrink-0">Sync</Badge>
</div> </div>
))} ))}
</div> </div>
)} )}
</CardContent> </CardContent>
</Card> </Card>
</div>
</div> </div>
</div> </div>
) )

View File

@@ -9,6 +9,7 @@ export interface EdgeProject {
is_active: boolean is_active: boolean
installer_status?: 'NOT_STARTED' | 'BUILDING' | 'READY' | 'FAILED' installer_status?: 'NOT_STARTED' | 'BUILDING' | 'READY' | 'FAILED'
installer_url?: string installer_url?: string
last_health_report?: any
created_at: string created_at: string
} }
@@ -37,9 +38,19 @@ export async function createProject(data: CreateProjectRequest) {
}) })
} }
export interface BuildTriggerResponse {
message: string
deployment_id?: string
}
export interface BuildMetadata {
installer_url?: string
version?: string
}
export async function triggerBuild(projectId: string) { export async function triggerBuild(projectId: string) {
const token = useAuthStore.getState().token const token = useAuthStore.getState().token
return apiClient<any>(`/edge/projects/${projectId}/build`, { return apiClient<BuildTriggerResponse>(`/edge/projects/${projectId}/build`, {
method: 'POST', method: 'POST',
token, token,
}) })
@@ -50,10 +61,7 @@ export interface EdgeDeployment {
project_id: string project_id: string
image_tag: string image_tag: string
status: 'BUILDING' | 'BUILT' | 'PUSHING' | 'PUSHED' | 'DEPLOYING' | 'DEPLOYED' | 'FAILED' | 'ROLLED_BACK' status: 'BUILDING' | 'BUILT' | 'PUSHING' | 'PUSHED' | 'DEPLOYING' | 'DEPLOYED' | 'FAILED' | 'ROLLED_BACK'
build_metadata: { build_metadata: BuildMetadata
installer_url?: string
[key: string]: any
}
error_message?: string error_message?: string
created_at: string created_at: string
} }
@@ -62,3 +70,11 @@ export async function listDeployments(projectId: string) {
const token = useAuthStore.getState().token const token = useAuthStore.getState().token
return apiClient<EdgeDeployment[]>(`/edge/projects/${projectId}/deployments`, { token }) return apiClient<EdgeDeployment[]>(`/edge/projects/${projectId}/deployments`, { token })
} }
export async function deploySystem(projectId: string) {
const token = useAuthStore.getState().token
return apiClient<{ message: string, flows_generated: boolean }>(`/edge/projects/${projectId}/deploy`, {
method: 'POST',
token,
})
}

View File

@@ -1,12 +1,14 @@
import { apiClient } from '@/lib/api/client' import { apiClient } from '@/lib/api/client'
import { useAuthStore } from '@/features/auth/stores/auth-store' import { useAuthStore } from '@/features/auth/stores/auth-store'
export type EdgeAssetType = 'POZO' | 'TANQUE' | 'MEDIDOR_GAS' | 'AGUA' | 'OTRO'
export interface EdgeAsset { export interface EdgeAsset {
id: string id: string
project_id: string project_id: string
code: string code: string
name: string name: string
asset_type: string asset_type: EdgeAssetType
is_active: boolean is_active: boolean
metadata: Record<string, unknown> metadata: Record<string, unknown>
created_at: string created_at: string
@@ -17,7 +19,7 @@ export interface CreateAssetRequest {
project_id: string project_id: string
name: string name: string
code: string code: string
asset_type: string asset_type: EdgeAssetType
} }
export async function listAssets(projectId: string) { export async function listAssets(projectId: string) {

View File

@@ -1,8 +1,9 @@
import { useEffect, useState } from 'react' import { useEffect, useState } from 'react'
import { useAuthStore } from '@/features/auth/stores/auth-store'
import { useParams, Link } from 'react-router-dom' import { useParams, Link } from 'react-router-dom'
import { listAssets, createAsset, EdgeAsset } from '../api/wells-api' import { listAssets, createAsset, EdgeAsset } from '../api/wells-api'
import { getProject, EdgeProject } from '../api/projects-api' import { getProject, EdgeProject, deploySystem } from '../api/projects-api'
import { Plus, Loader2, ArrowLeft, Droplets, MapPin, HardDrive, Cpu, Download, Clock } from 'lucide-react' import { Plus, Loader2, ArrowLeft, Droplets, MapPin, HardDrive, Cpu, Download, Clock, Activity, ShieldCheck, AlertCircle } from 'lucide-react'
import { Card, CardContent } from '@/components/ui/card' import { Card, CardContent } from '@/components/ui/card'
import { Badge } from '@/components/ui/badge' import { Badge } from '@/components/ui/badge'
import { Button } from '@/components/ui/button' import { Button } from '@/components/ui/button'
@@ -15,6 +16,21 @@ export default function ProjectDetailsPage() {
const [loading, setLoading] = useState(true) const [loading, setLoading] = useState(true)
const [showCreateModal, setShowCreateModal] = useState(false) const [showCreateModal, setShowCreateModal] = useState(false)
const [newWell, setNewWell] = useState({ name: '', code: '' }) const [newWell, setNewWell] = useState({ name: '', code: '' })
const [deploying, setDeploying] = useState(false)
const handleDeploySystem = async () => {
if (!id) return
setDeploying(true)
try {
await deploySystem(id)
alert("Flujo de Edge Agent desplegado exitosamente. flows.json generado y cargado en el borde.")
pollProjectStatus()
} catch (err: any) {
alert("Error desplegando el sistema: " + err.message)
} finally {
setDeploying(false)
}
}
useEffect(() => { useEffect(() => {
if (id) { if (id) {
@@ -64,9 +80,49 @@ export default function ProjectDetailsPage() {
} }
} }
const handleDownloadInstaller = () => { const handleDownloadInstaller = async () => {
if (project?.installer_url) { if (!project?.id) return
window.location.href = project.installer_url
try {
const token = useAuthStore.getState().token
const response = await fetch(`${import.meta.env.VITE_API_URL || ''}/api/edge/projects/${project.id}/installer`, {
headers: {
'Authorization': `Bearer ${token}`
}
})
if (!response.ok) {
let det = ''
try { det = await response.text() } catch (_) { }
throw new Error(`[${response.status}] ${response.statusText} - ${det}`)
}
const blob = await response.blob()
let filename = `OmniOil-EdgeInstaller-${project.id.split('-')[0]}.msi`
const disposition = response.headers.get('Content-Disposition')
if (disposition && disposition.indexOf('filename=') !== -1) {
const filenameRegex = /filename[^;=\n]*=((['"]).*?\2|[^;\n]*)/
const matches = filenameRegex.exec(disposition)
if (matches != null && matches[1]) {
filename = matches[1].replace(/['"]/g, '')
}
}
const url = window.URL.createObjectURL(blob)
const link = document.createElement('a')
link.href = url
link.download = filename
document.body.appendChild(link)
link.click()
link.remove()
window.URL.revokeObjectURL(url)
} catch (e: any) {
console.error('Error al descargar instalador seguro:', e)
alert("Error descargando instalador: " + (e.message || e))
} }
} }
@@ -111,7 +167,7 @@ export default function ProjectDetailsPage() {
{isInstallerReady ? ( {isInstallerReady ? (
<> <>
<Download className="h-3.5 w-3.5" /> <Download className="h-3.5 w-3.5" />
Descargar Instalador (Edge Agent) Descargar Instalador (Sist. Despliegue)
</> </>
) : isInstallerBuilding ? ( ) : isInstallerBuilding ? (
<> <>
@@ -127,10 +183,26 @@ export default function ProjectDetailsPage() {
</Button> </Button>
)} )}
{assets.length > 0 && (
<Button
size="sm"
onClick={handleDeploySystem}
disabled={deploying}
className="bg-primary/20 text-primary hover:bg-primary/30 border border-primary/50 text-xs h-8 gap-1.5 transition-all glow-text shadow-[0_0_10px_rgba(var(--primary),0.3)]"
>
{deploying ? (
<Loader2 className="h-3.5 w-3.5 animate-spin" />
) : (
<HardDrive className="h-3.5 w-3.5 glow-icon" />
)}
Desplegar Sistema
</Button>
)}
<Button <Button
size="sm" size="sm"
onClick={() => setShowCreateModal(true)} onClick={() => setShowCreateModal(true)}
className="bg-primary text-primary-foreground hover:bg-primary/90 text-xs h-8 gap-1.5" className="bg-primary text-primary-foreground hover:bg-primary/90 text-xs h-8 gap-1.5 shadow-lg shadow-primary/20"
> >
<Plus className="h-3.5 w-3.5" /> <Plus className="h-3.5 w-3.5" />
Registrar Pozo Registrar Pozo
@@ -138,6 +210,47 @@ export default function ProjectDetailsPage() {
</div> </div>
</div> </div>
{/* Health Status Ribbon */}
{project && project.last_health_report && (
<div className="bg-primary/5 border-b border-border px-6 py-2 flex items-center gap-6 overflow-x-auto scrollbar-hide">
<div className="flex items-center gap-2 whitespace-nowrap">
<Activity className="h-3.5 w-3.5 text-primary" />
<span className="text-xs font-semibold uppercase tracking-wider text-muted-foreground">Sistema de Despliegue:</span>
</div>
<div className="flex items-center gap-4">
<div className="flex items-center gap-1.5">
<span className="text-xs text-muted-foreground">Hostname:</span>
<span className="text-xs font-medium">{project.last_health_report.agent_hostname || 'Desconocido'}</span>
</div>
<div className="flex items-center gap-1.5">
<span className="text-xs text-muted-foreground">Versión:</span>
<span className="text-xs font-medium">{project.last_health_report.version || 'v0.0.0'}</span>
</div>
<div className="flex items-center gap-1.5">
<div className="flex items-center gap-1.5 px-2 py-0.5 rounded-full bg-success/10 border border-success/20">
<div className="h-1.5 w-1.5 rounded-full bg-success animate-pulse" />
<span className="text-[10px] font-bold text-success uppercase">Online</span>
</div>
</div>
{project.last_health_report.uptime_secs && (
<div className="flex items-center gap-1.5">
<Clock className="h-3 w-3 text-muted-foreground" />
<span className="text-[10px] text-muted-foreground">
Uptime: {Math.floor(project.last_health_report.uptime_secs / 3600)}h {Math.floor((project.last_health_report.uptime_secs % 3600) / 60)}m
</span>
<span className="text-[10px] text-muted-foreground ml-2 opacity-60">
(Ultimo reporte: {new Date(project.last_health_report.timestamp).toLocaleTimeString()})
</span>
</div>
)}
</div>
</div>
)}
{/* Content */} {/* Content */}
<div className="flex-1 overflow-y-auto p-6"> <div className="flex-1 overflow-y-auto p-6">
{assets.length === 0 ? ( {assets.length === 0 ? (
@@ -151,7 +264,7 @@ export default function ProjectDetailsPage() {
) : ( ) : (
<div className="grid gap-4 sm:grid-cols-2 lg:grid-cols-3"> <div className="grid gap-4 sm:grid-cols-2 lg:grid-cols-3">
{assets.map((asset) => ( {assets.map((asset) => (
<Card key={asset.id} className="bg-card border-border hover:border-primary/40 transition-colors"> <Card key={asset.id} className="glass-card border-0 hover:border-primary/40 transition-all hover:shadow-[0_0_15px_rgba(var(--primary),0.2)]">
<CardContent className="p-5"> <CardContent className="p-5">
<div className="flex items-start justify-between mb-3"> <div className="flex items-start justify-between mb-3">
<div className="p-2 rounded-lg bg-primary/10"> <div className="p-2 rounded-lg bg-primary/10">
@@ -196,7 +309,7 @@ export default function ProjectDetailsPage() {
{/* Create Modal */} {/* Create Modal */}
{showCreateModal && ( {showCreateModal && (
<div className="fixed inset-0 z-50 flex items-center justify-center p-4 bg-background/80 backdrop-blur-sm"> <div className="fixed inset-0 z-50 flex items-center justify-center p-4 bg-background/80 backdrop-blur-sm">
<div className="w-full max-w-md bg-card border border-border rounded-2xl p-8 shadow-2xl relative"> <div className="w-full max-w-md glass-card border border-border/50 rounded-2xl p-8 shadow-2xl relative">
<h2 className="text-base font-semibold mb-6">Nuevo Pozo</h2> <h2 className="text-base font-semibold mb-6">Nuevo Pozo</h2>
<form onSubmit={handleCreateWell} className="space-y-4"> <form onSubmit={handleCreateWell} className="space-y-4">
<div> <div>

View File

@@ -50,17 +50,21 @@ export default function ProjectListPage() {
return ( return (
<div className="flex flex-col h-full"> <div className="flex flex-col h-full">
{/* Header */} {/* Header */}
<div className="flex items-center gap-3 px-6 h-14 border-b border-border bg-card shrink-0"> <div className="flex items-center gap-3 px-6 py-5 border-b border-border/20 bg-background/20 backdrop-blur-md shrink-0">
<Package className="h-4 w-4 text-primary" /> <div className="h-10 w-10 bg-primary/10 rounded-xl flex items-center justify-center border border-primary/20 shadow-inner">
<h1 className="text-base font-semibold">Proyectos Operativos</h1> <Package className="h-5 w-5 text-primary glow-icon" />
<p className="text-xs text-muted-foreground ml-1 hidden sm:block"> </div>
Gestión de despliegues y agentes de borde por campo. <div>
</p> <h1 className="text-xl font-bold text-foreground glow-text">Panel de Despliegues Activos</h1>
<p className="text-xs text-muted-foreground mt-0.5 tracking-wide">
Gestión de proyectos SCADA, edge agents y redes sensoriales.
</p>
</div>
<Link <Link
to="/app/admin/projects/new" to="/app/admin/projects/new"
className="ml-auto flex items-center gap-1.5 rounded-lg bg-primary px-3 py-1.5 text-xs font-semibold text-primary-foreground hover:bg-primary/90 transition-colors" className="ml-auto group flex items-center gap-2 rounded-xl bg-primary px-4 py-2 text-sm font-bold text-primary-foreground hover:bg-primary/90 transition-all shadow-lg hover:shadow-[0_0_20px_rgba(var(--primary),0.4)]"
> >
<Plus className="h-3.5 w-3.5" /> <Plus className="h-4 w-4 transition-transform group-hover:rotate-90" />
Nuevo Proyecto Nuevo Proyecto
</Link> </Link>
</div> </div>
@@ -79,54 +83,53 @@ export default function ProjectListPage() {
<p className="text-sm font-medium text-muted-foreground">No hay proyectos creados.</p> <p className="text-sm font-medium text-muted-foreground">No hay proyectos creados.</p>
</div> </div>
) : ( ) : (
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4"> <div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4 gap-6">
{projects.map((project) => ( {projects.map((project) => (
<Card key={project.id} className="bg-card border-border hover:border-primary/40 transition-colors"> <Card key={project.id} className="glass-card border-0 hover:border-primary/40 transition-all duration-300 hover:shadow-[0_0_25px_rgba(var(--primary),0.15)] group">
<CardContent className="p-5"> <CardContent className="p-6">
<div className="flex items-start justify-between mb-3"> <div className="flex items-start justify-between mb-4">
<div className="p-2 rounded-lg bg-primary/10"> <div className="p-2.5 rounded-xl bg-primary/10 border border-primary/20">
<Package className="h-4 w-4 text-primary" /> <Folder className="h-5 w-5 text-primary group-hover:scale-110 transition-transform" />
</div> </div>
<Badge className={project.is_active <Badge className={project.is_active
? 'bg-success/10 text-success border border-success/20 text-xs' ? 'bg-success/10 text-success border-0 px-2 py-0.5 text-[10px] uppercase font-bold tracking-wider'
: 'bg-destructive/10 text-destructive border border-destructive/20 text-xs' : 'bg-destructive/10 text-destructive border-0 px-2 py-0.5 text-[10px] uppercase font-bold tracking-wider'
}> }>
{project.is_active ? 'Activo' : 'Inactivo'} {project.is_active ? 'Activo' : 'Offline'}
</Badge> </Badge>
</div> </div>
<h3 className="text-sm font-semibold text-foreground mb-0.5">{project.name}</h3> <h3 className="text-base font-bold text-foreground mb-1 glow-text truncate" title={project.name}>{project.name}</h3>
<p className="text-xs text-muted-foreground mb-3 line-clamp-2"> <p className="text-xs text-muted-foreground mb-4 line-clamp-2 h-8">
{project.description || 'Sin descripción'} {project.description || 'Sin descripción corporativa'}
</p> </p>
<div className="flex items-center gap-3 text-xs text-muted-foreground mb-4"> <div className="flex bg-secondary/30 rounded-lg p-2.5 items-center justify-between text-[11px] text-muted-foreground/80 mb-5 font-mono">
<div className="flex items-center gap-1"> <div className="flex items-center gap-1.5">
<Activity className="h-3 w-3" /> <Activity className="h-3.5 w-3.5 text-primary" />
ID: {project.id.split('-')[0]} ID: {project.id.split('-')[0]}
</div> </div>
<div>{new Date(project.created_at).toLocaleDateString()}</div> <div className="opacity-80">{new Date(project.created_at).toLocaleDateString()}</div>
</div> </div>
<div className="flex items-center gap-2 pt-3 border-t border-border"> <div className="flex items-center gap-2 pt-4 border-t border-border/30">
<Button <Button
size="sm"
variant="secondary" variant="secondary"
disabled={buildingId === project.id} disabled={buildingId === project.id}
onClick={() => handleBuild(project.id)} onClick={() => handleBuild(project.id)}
className="flex-1 text-xs h-8 gap-1.5" className="flex-1 text-xs h-9 font-semibold gap-2 bg-secondary/50 hover:bg-secondary border border-border/20 transition-all"
> >
{buildingId === project.id {buildingId === project.id
? <Loader2 className="h-3 w-3 animate-spin" /> ? <Loader2 className="h-3.5 w-3.5 animate-spin" />
: <Download className="h-3 w-3" /> : <Download className="h-3.5 w-3.5" />
} }
Instalador Instalador
</Button> </Button>
<Link <Link
to={`/app/admin/projects/${project.id}`} to={`/app/admin/projects/${project.id}`}
className="flex-1 flex items-center justify-center gap-1.5 rounded-lg bg-primary/10 hover:bg-primary/20 px-3 h-8 text-xs font-semibold text-primary border border-primary/20 transition-colors" className="flex-1 flex items-center justify-center gap-1.5 rounded-lg bg-primary/10 hover:bg-primary/20 px-3 h-9 text-xs font-bold text-primary border border-primary/30 transition-colors shadow-sm"
> >
Ver Pozos Auditar Config
</Link> </Link>
</div> </div>
</CardContent> </CardContent>

View File

@@ -1,5 +1,6 @@
import { useEffect, useState } from 'react' import { useEffect, useState } from 'react'
import { listTanks } from '../api/tanks-api' import { listTanks } from '../api/tanks-api'
import { useAuthStore } from '@/features/auth/stores/auth-store'
import type { EdgeAsset } from '@/features/admin/projects/api/wells-api' import type { EdgeAsset } from '@/features/admin/projects/api/wells-api'
interface UseTanksResult { interface UseTanksResult {
@@ -8,23 +9,27 @@ interface UseTanksResult {
error: string | null error: string | null
} }
// TODO: reemplazar por project_id del JWT cuando se agregue a Claims export function useTanks(): UseTanksResult {
// Ver engram: deuda/project-id-jwt const activeProjectId = useAuthStore((s) => s.activeProjectId)
const HARDCODED_PROJECT_ID = '12489ed6-dde2-4174-ae22-9365323149b0'
export function useTanks(projectId: string = HARDCODED_PROJECT_ID): UseTanksResult {
const [tanks, setTanks] = useState<EdgeAsset[]>([]) const [tanks, setTanks] = useState<EdgeAsset[]>([])
const [loading, setLoading] = useState(true) const [loading, setLoading] = useState(false)
const [error, setError] = useState<string | null>(null) const [error, setError] = useState<string | null>(null)
useEffect(() => { useEffect(() => {
if (!activeProjectId) {
setTanks([])
setLoading(false)
setError(null)
return
}
setLoading(true) setLoading(true)
setError(null) setError(null)
listTanks(projectId) listTanks(activeProjectId)
.then((data) => setTanks(data)) .then((data) => setTanks(data))
.catch((err: Error) => setError(err.message)) .catch((err: Error) => setError(err.message))
.finally(() => setLoading(false)) .finally(() => setLoading(false))
}, [projectId]) }, [activeProjectId])
return { tanks, loading, error } return { tanks, loading, error }
} }

Some files were not shown because too many files have changed in this diff Show More