Compare commits
32 Commits
6ef3ded051
...
recovery/d
| Author | SHA1 | Date | |
|---|---|---|---|
| 017c180c85 | |||
|
|
9f5d03142e | ||
|
|
251bf7635e | ||
|
|
6d3443de37 | ||
|
|
cde41d0c8e | ||
|
|
9135c9db7e | ||
|
|
93e0359789 | ||
|
|
aacedc1c91 | ||
|
|
055d036ecd | ||
|
|
3608585768 | ||
| f0a23ef6ce | |||
| 4ea6d12bd7 | |||
| dcf423185d | |||
| 06202ab5f6 | |||
|
|
75bd4b3482 | ||
|
|
ac2fa56a22 | ||
|
|
24b0082690 | ||
|
|
ffbe78a09c | ||
| 693360ea79 | |||
|
|
e726160c0d | ||
|
|
dfe0e563a5 | ||
|
|
37149607dd | ||
|
|
7754b69989 | ||
|
|
77e88ce77a | ||
|
|
86c834e2b4 | ||
|
|
9b16294a41 | ||
|
|
7482704fd7 | ||
|
|
7d90aa7b6a | ||
|
|
4d35a56719 | ||
|
|
0c10dd5eed | ||
| 416cc476ef | |||
| 16495a052f |
@@ -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
46
.env.example
Normal 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
42
AGENTS.md
Normal 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/: End‑to‑end 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`; 4‑space indent; `snake_case` for files/functions, `PascalCase` for types; keep modules small and reuse `services/shared-lib`.
|
||||||
|
- TypeScript/React: 2‑space 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
1297
Cargo.lock
generated
File diff suppressed because it is too large
Load Diff
@@ -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"
|
||||||
|
|||||||
@@ -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
|
||||||
|
|
||||||
|
|||||||
16
README.md
16
README.md
@@ -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.
|
||||||
|
|||||||
52
SECURITY_HARDENING_REPORT.md
Normal file
52
SECURITY_HARDENING_REPORT.md
Normal 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
53
TRACKING.md
Normal 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/**`.
|
||||||
|
- Multi‑tenant: 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.
|
||||||
|
|
||||||
@@ -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"
|
||||||
|
|||||||
@@ -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:
|
||||||
|
|||||||
@@ -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 |
|
||||||
|
|||||||
@@ -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.
|
||||||
|
|||||||
99
docs/INFRASTRUCTURE_AND_API.md
Normal file
99
docs/INFRASTRUCTURE_AND_API.md
Normal 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.
|
||||||
346
docs/PRD_ESTABILIZACION_PRODUCCION.md
Normal file
346
docs/PRD_ESTABILIZACION_PRODUCCION.md
Normal 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 |
|
||||||
87
docs/PRD_RESUMEN_EJECUTIVO.md
Normal file
87
docs/PRD_RESUMEN_EJECUTIVO.md
Normal 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`.
|
||||||
|
- Multi‑tenant 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 (high‑level)
|
||||||
|
|
||||||
|
- 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 multi‑tenant 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 (PWA→API) < 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 2–3: Fase 2 completa (funcional base + consistencia).
|
||||||
|
- Semanas 3–6: 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 (env‑vars/secrets manager).
|
||||||
|
|
||||||
|
## 9) Seguimiento
|
||||||
|
|
||||||
|
- Tablero “Estabilización” con épicas por fase; etiquetas `P0/P1` y `Seguridad/Funcional/Calidad`.
|
||||||
|
- Stand‑up corto de riesgos diarios; demo quincenal de avances de Fases 2–3.
|
||||||
|
|
||||||
43
docs/REPORTE_TRANSICION_MQTT.md
Normal file
43
docs/REPORTE_TRANSICION_MQTT.md
Normal 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
75
docs/SECURITY_AUDIT.md
Normal 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.
|
||||||
BIN
docs/architecture/assets/omnioil_enterprise_architecture.png
Normal file
BIN
docs/architecture/assets/omnioil_enterprise_architecture.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 901 KiB |
BIN
docs/architecture/assets/omnioil_er_diagram.png
Normal file
BIN
docs/architecture/assets/omnioil_er_diagram.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 518 KiB |
BIN
docs/architecture/assets/omnioil_high_res_schema.png
Normal file
BIN
docs/architecture/assets/omnioil_high_res_schema.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 550 KiB |
114
docs/architecture/database.md
Normal file
114
docs/architecture/database.md
Normal 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
|
||||||
|
|
||||||
|

|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 🔬 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).
|
||||||
65
docs/frontend_tiempo_real_grafica.md
Normal file
65
docs/frontend_tiempo_real_grafica.md
Normal 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.
|
||||||
205
docs/full_schema_2026_04_10.sql
Normal file
205
docs/full_schema_2026_04_10.sql
Normal 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
34
docs/repair.md
Normal 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
13
generate_hash.py
Normal 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}')
|
||||||
15
infrastructure/mosquitto/Dockerfile
Normal file
15
infrastructure/mosquitto/Dockerfile
Normal 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"]
|
||||||
16
infrastructure/mosquitto/entrypoint.sh
Normal file
16
infrastructure/mosquitto/entrypoint.sh
Normal 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
|
||||||
@@ -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
|
|
||||||
44
infrastructure/mosquitto/mosquitto.conf.template
Normal file
44
infrastructure/mosquitto/mosquitto.conf.template
Normal 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/
|
||||||
@@ -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;
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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.';
|
|
||||||
@@ -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 $$;
|
|
||||||
56
infrastructure/tests/PRUEBAS.md
Normal file
56
infrastructure/tests/PRUEBAS.md
Normal 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.
|
||||||
61
infrastructure/tests/simulate_agent.py
Normal file
61
infrastructure/tests/simulate_agent.py
Normal 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()
|
||||||
46
infrastructure/tests/test_mqtt_auth.py
Normal file
46
infrastructure/tests/test_mqtt_auth.py
Normal 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()
|
||||||
@@ -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"
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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()
|
|
||||||
);
|
|
||||||
@@ -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 $$;
|
||||||
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
63
services/backend-api/src/errors.rs
Normal file
63
services/backend-api/src/errors.rs
Normal 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)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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
|
||||||
"#,
|
"#,
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|
||||||
|
|||||||
@@ -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(¶ms.level)
|
)
|
||||||
.bind(¶ms.category)
|
.bind(¶ms.level)
|
||||||
.bind(limit)
|
.bind(¶ms.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(¶ms.level)
|
||||||
|
.bind(¶ms.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,
|
||||||
|
|||||||
@@ -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)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
@@ -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)
|
||||||
|
}
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
110
services/backend-api/src/handlers/operations_movements.rs
Normal file
110
services/backend-api/src/handlers/operations_movements.rs
Normal 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)))
|
||||||
|
}
|
||||||
|
|
||||||
@@ -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>(
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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"
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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!(
|
||||||
|
|||||||
@@ -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();
|
||||||
|
|||||||
@@ -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;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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"
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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; }
|
||||||
|
|
||||||
|
|||||||
@@ -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"
|
||||||
|
|||||||
@@ -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"]
|
||||||
|
|||||||
Binary file not shown.
@@ -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
|
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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>,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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" }))
|
||||||
|
}
|
||||||
|
|||||||
@@ -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(),
|
||||||
|
|||||||
@@ -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;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
121
services/edge-agent/src/persistence_manager.rs
Normal file
121
services/edge-agent/src/persistence_manager.rs
Normal 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)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
|
|||||||
@@ -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")
|
||||||
|
|||||||
22
services/edge-agent/wix/install-service.bat
Normal file
22
services/edge-agent/wix/install-service.bat
Normal 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
|
||||||
@@ -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" />
|
||||||
|
|||||||
12
services/edge-agent/wix/uninstall-service.bat
Normal file
12
services/edge-agent/wix/uninstall-service.bat
Normal 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
|
||||||
@@ -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"
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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(),
|
||||||
};
|
};
|
||||||
|
|||||||
6705
services/frontend-admin/package-lock.json
generated
6705
services/frontend-admin/package-lock.json
generated
File diff suppressed because it is too large
Load Diff
@@ -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"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
2858
services/frontend-admin/pnpm-lock.yaml
generated
2858
services/frontend-admin/pnpm-lock.yaml
generated
File diff suppressed because it is too large
Load Diff
@@ -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
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
@@ -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 />
|
||||||
|
|||||||
@@ -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 />,
|
||||||
|
|||||||
@@ -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>
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -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,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|||||||
@@ -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) {
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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
Reference in New Issue
Block a user